mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
Compare commits
110
Commits
feat/plugin
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8fde279c | ||
|
|
2068ab2051 | ||
|
|
d9267eea41 | ||
|
|
4420c33f81 | ||
|
|
4909fbff4f | ||
|
|
3725f27d7b | ||
|
|
3ed02f9a09 | ||
|
|
51927a0587 | ||
|
|
d23c341471 | ||
|
|
5f23405704 | ||
|
|
127cf8b883 | ||
|
|
c0d5f9a815 | ||
|
|
0ce64e20ba | ||
|
|
14eb18b8b3 | ||
|
|
3b37c33607 | ||
|
|
fc1e96d6a1 | ||
|
|
02ecbedf7d | ||
|
|
ff9978f74c | ||
|
|
1268e99b5a | ||
|
|
99d527d9c1 | ||
|
|
fdae9c41c7 | ||
|
|
9637efd795 | ||
|
|
d8190fc07e | ||
|
|
f1ab8c7221 | ||
|
|
03b25be9d8 | ||
|
|
7484bb9e07 | ||
|
|
20fdea8fb3 | ||
|
|
c379c315e6 | ||
|
|
2cb15b541b | ||
|
|
8fbbcd5ae9 | ||
|
|
900364229e | ||
|
|
4700d3abc0 | ||
|
|
50ac77cdf3 | ||
|
|
c3951a448c | ||
|
|
0a8934b46d | ||
|
|
37f9c4c4c8 | ||
|
|
6ad0d74379 | ||
|
|
2f3bffd15a | ||
|
|
edcdd77cad | ||
|
|
242861436d | ||
|
|
3aae326914 | ||
|
|
082b6f05d8 | ||
|
|
0f564c0e7c | ||
|
|
3039eaff21 | ||
|
|
aa47152f1f | ||
|
|
56b67aff88 | ||
|
|
296c9a947f | ||
|
|
a000ac8921 | ||
|
|
02db171f2c | ||
|
|
cd09c60657 | ||
|
|
52c52abc66 | ||
|
|
82f6add55d | ||
|
|
17342167ac | ||
|
|
8942287a27 | ||
|
|
924b5e5926 | ||
|
|
a511fb983d | ||
|
|
49a38ca857 | ||
|
|
e0f9bd6780 | ||
|
|
543f618fee | ||
|
|
42811a27f5 | ||
|
|
e4b5e132d6 | ||
|
|
a42f84cfe7 | ||
|
|
8fdcdd7f84 | ||
|
|
e1d6bd9726 | ||
|
|
147d13f04e | ||
|
|
dbec16dd8b | ||
|
|
75b7ea9cc8 | ||
|
|
0fd5e9d6f2 | ||
|
|
ddaa61c827 | ||
|
|
919c1d8032 | ||
|
|
669f129dcd | ||
|
|
f22c7b6680 | ||
|
|
5cdf642dd1 | ||
|
|
2dad593263 | ||
|
|
d7578bcc15 | ||
|
|
76417e005b | ||
|
|
e768980dad | ||
|
|
2c63bd845a | ||
|
|
6e74637c69 | ||
|
|
d004139526 | ||
|
|
8c6c415a84 | ||
|
|
d83e14af9a | ||
|
|
29fde7d7c7 | ||
|
|
1269110615 | ||
|
|
86c3899016 | ||
|
|
38a4c3f516 | ||
|
|
ab4d3adee5 | ||
|
|
24c41d0347 | ||
|
|
b6fc7326e7 | ||
|
|
e87abace8b | ||
|
|
ee2592c469 | ||
|
|
49c1fb6378 | ||
|
|
788868f8b9 | ||
|
|
4c3fe230ce | ||
|
|
ac14245d19 | ||
|
|
84addec26a | ||
|
|
5de547041c | ||
|
|
8bd0d70add | ||
|
|
3ac4402a86 | ||
|
|
e535a98945 | ||
|
|
45e694fd63 | ||
|
|
9120b90be7 | ||
|
|
b807608bf3 | ||
|
|
e7a1cca4c6 | ||
|
|
76baa3b0e7 | ||
|
|
fc79adbb43 | ||
|
|
e673a034df | ||
|
|
6ac666da4f | ||
|
|
9e2d22418b | ||
|
|
97ee138de8 |
@@ -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/);
|
||||
});
|
||||
@@ -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
|
||||
@@ -158,14 +158,27 @@ jobs:
|
||||
mkdir -p legacy-update
|
||||
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
|
||||
|
||||
- name: Package legacy Windows updater asset
|
||||
- name: Package and sign legacy Windows updater asset
|
||||
if: matrix.platform == 'windows-x86_64'
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force legacy-update | Out-Null
|
||||
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
|
||||
$asset = "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
|
||||
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath $asset
|
||||
Push-Location apps/desktop
|
||||
npm exec tauri signer sign -- "../../$asset"
|
||||
Pop-Location
|
||||
$entries = @(tar -tf $asset)
|
||||
if ($entries.Count -ne 1 -or [System.IO.Path]::GetFileName($entries[0]) -ne 'cursor-byok-desktop.exe') {
|
||||
throw "Windows updater archive must contain only cursor-byok-desktop.exe"
|
||||
}
|
||||
if (!(Test-Path "$asset.sig")) {
|
||||
throw "Windows updater archive signature was not generated"
|
||||
}
|
||||
|
||||
- name: Package legacy macOS updater asset
|
||||
if: contains(matrix.platform, 'macos')
|
||||
@@ -213,6 +226,20 @@ jobs:
|
||||
--output legacy-update/update.json \
|
||||
--notes "Cursor BYOK v${VERSION}"
|
||||
|
||||
- name: Generate signed Windows portable update manifest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
asset="cursor-byok-${VERSION}-windows-amd64.zip"
|
||||
test -f "legacy-update/${asset}"
|
||||
test -f "legacy-update/${asset}.sig"
|
||||
node .github/scripts/generate-portable-update.mjs \
|
||||
--version "${VERSION}" \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--asset "${asset}" \
|
||||
--signature "legacy-update/${asset}.sig" \
|
||||
--output legacy-update/portable-latest.json
|
||||
|
||||
- name: Normalize Tauri updater download URLs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
Generated
+24
-1
@@ -1172,10 +1172,12 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.5-beta.1"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cursor-server",
|
||||
"libc",
|
||||
"mimalloc",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1187,12 +1189,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]]
|
||||
@@ -3413,6 +3418,15 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.19"
|
||||
@@ -3600,6 +3614,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
|
||||
|
||||
.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker
|
||||
.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker
|
||||
|
||||
check:
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
npm --prefix apps/desktop run check
|
||||
npm --prefix apps/docs run check
|
||||
|
||||
dev-web:
|
||||
npm --prefix apps/desktop run dev:web
|
||||
|
||||
dev-docs:
|
||||
npm --prefix apps/docs run dev
|
||||
|
||||
dev-server:
|
||||
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
|
||||
|
||||
@@ -24,9 +20,6 @@ dev-desktop:
|
||||
build-web:
|
||||
npm --prefix apps/desktop run build
|
||||
|
||||
build-docs:
|
||||
npm --prefix apps/docs run build
|
||||
|
||||
build-server:
|
||||
cargo build --release --package cursor-server --bin cursor-server
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ cursor-byok/
|
||||
│ │ │ ├── i18n/ # 本地化运行时与语言目录
|
||||
│ │ │ └── styles/ # 全局主题与排版令牌
|
||||
│ │ └── src-tauri/ # Tauri 桌面生命周期
|
||||
│ └── docs/ # Next.js 与 Fumadocs 中文/英文文档站
|
||||
├── server/
|
||||
│ ├── src/
|
||||
│ │ ├── cursor/ # Cursor 协议适配
|
||||
@@ -167,7 +166,6 @@ cursor-byok/
|
||||
|
||||
```bash
|
||||
npm --prefix apps/desktop install
|
||||
npm --prefix apps/docs install
|
||||
```
|
||||
|
||||
### 启动开发环境
|
||||
@@ -184,14 +182,6 @@ make dev-web
|
||||
make dev-desktop
|
||||
```
|
||||
|
||||
启动文档站:
|
||||
|
||||
```bash
|
||||
make dev-docs
|
||||
```
|
||||
|
||||
文档站默认地址为 <http://localhost:3000>。
|
||||
|
||||
### 检查与构建
|
||||
|
||||
运行完整检查:
|
||||
@@ -205,13 +195,10 @@ make check
|
||||
```bash
|
||||
make build-web # 构建桌面前端
|
||||
make build-server # 构建 Rust 本地服务
|
||||
make build-docs # 构建文档站
|
||||
make build-desktop # 构建 Tauri 桌面安装包
|
||||
make build-docker # 构建 Docker 镜像
|
||||
```
|
||||
|
||||
文档内容位于 `apps/docs/content/docs` 和 `apps/docs/content/blog`。修改文档侧边栏时同步更新 `apps/docs/content/docs/meta.json`。
|
||||
|
||||
## 路线图
|
||||
|
||||
项目将继续改进模型兼容性、Agent 工具、本地运行稳定性和自托管体验,并探索支持更多 IDE、聊天和 Agent 工作流。
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5-beta.1",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5-beta.1",
|
||||
"version": "1.0.1",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5-beta.1",
|
||||
"version": "1.0.1",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
||||
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
|
||||
"build": "vite build",
|
||||
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
|
||||
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||
@@ -34,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,7 +9,7 @@ import { normalizePath, type Plugin } from "vite"
|
||||
const traverse = traverseModule.default ?? traverseModule
|
||||
|
||||
const SOURCE_LOCALE = "zh-CN"
|
||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
|
||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "pt-BR"]
|
||||
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
|
||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
||||
const AUTO_IMPORT_NAME = "__staticI18nT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.5-beta.1"
|
||||
version = "1.0.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -14,6 +14,8 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
cursor-server = { path = "../../../server" }
|
||||
libc = "0.2"
|
||||
mimalloc = "0.1"
|
||||
rfd = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -24,9 +26,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"] }
|
||||
|
||||
@@ -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"]
|
||||
@@ -36,6 +36,7 @@ struct DesktopRuntime {
|
||||
shutdown: CancellationToken,
|
||||
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||
exiting: AtomicBool,
|
||||
server_addr: std::net::SocketAddr,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -141,6 +142,21 @@ fn create_main_window(
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// 按需打开主窗口:webview 仅在需要界面时创建,关闭窗口即销毁释放内存。
|
||||
pub(crate) fn open_main_window(app: &AppHandle) -> tauri::Result<()> {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return Ok(());
|
||||
}
|
||||
let address = app.state::<DesktopRuntime>().server_addr;
|
||||
let window = create_main_window(app, address)?;
|
||||
window.show()?;
|
||||
window.set_focus()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run() -> ExitCode {
|
||||
let diagnostics = match StartupDiagnostics::initialize() {
|
||||
Ok(diagnostics) => diagnostics,
|
||||
@@ -149,6 +165,23 @@ pub fn run() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let open_file_limit = match crate::resource_limits::raise_open_file_limit() {
|
||||
Ok(limit) => limit,
|
||||
Err(error) => {
|
||||
diagnostics.report_fatal(&error);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
requested = crate::resource_limits::REQUESTED_OPEN_FILE_LIMIT,
|
||||
previous = open_file_limit.previous,
|
||||
effective = open_file_limit.effective,
|
||||
hard = open_file_limit.hard,
|
||||
"open file limit configured"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
os = std::env::consts::OS,
|
||||
@@ -160,10 +193,14 @@ pub fn run() -> ExitCode {
|
||||
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_terminal_with_command,
|
||||
crate::update::check_portable_update,
|
||||
crate::update::install_portable_update,
|
||||
])
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
||||
tray::show_main_window(app);
|
||||
let _ = open_main_window(app);
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
@@ -219,15 +256,15 @@ pub fn run() -> ExitCode {
|
||||
shutdown,
|
||||
server: Mutex::new(Some(task)),
|
||||
exiting: AtomicBool::new(false),
|
||||
server_addr: address,
|
||||
});
|
||||
let window = create_main_window(app.handle(), address)?;
|
||||
if desktop_settings.silent_start && started_by_autostart {
|
||||
tracing::info!("silent autostart enabled; keeping the main window hidden");
|
||||
tracing::info!("silent autostart enabled; starting without the main window");
|
||||
} else {
|
||||
window.show()?;
|
||||
window.set_focus()?;
|
||||
open_main_window(app.handle())?;
|
||||
}
|
||||
tray::create(app)?;
|
||||
crate::update::signal_ready_if_requested()?;
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!());
|
||||
@@ -240,46 +277,42 @@ pub fn run() -> ExitCode {
|
||||
};
|
||||
|
||||
app.run(|app, event| match event {
|
||||
RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} if label == MAIN_WINDOW_LABEL => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.load(Ordering::Acquire) {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.hide();
|
||||
// code 为 None 表示所有窗口已被关闭(轻量模式),阻止退出,
|
||||
// 转发服务继续在托盘后台运行;code 为 Some 时是显式退出请求。
|
||||
RunEvent::ExitRequested { code, api, .. } => match code {
|
||||
None => api.prevent_exit(),
|
||||
Some(_) => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.swap(true, Ordering::AcqRel) {
|
||||
api.prevent_exit();
|
||||
runtime.shutdown.cancel();
|
||||
let server = runtime.server.lock().expect("server lock poisoned").take();
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(server) = server {
|
||||
match tokio::time::timeout(Duration::from_secs(11), server).await {
|
||||
Ok(Ok(Ok(()))) => {}
|
||||
Ok(Ok(Err(error))) => {
|
||||
tracing::error!(%error, "desktop server shutdown failed")
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::error!(%error, "desktop server task failed")
|
||||
}
|
||||
Err(_) => tracing::warn!("desktop server shutdown timed out"),
|
||||
}
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
RunEvent::ExitRequested { api, .. } => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.swap(true, Ordering::AcqRel) {
|
||||
api.prevent_exit();
|
||||
runtime.shutdown.cancel();
|
||||
let server = runtime.server.lock().expect("server lock poisoned").take();
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(server) = server {
|
||||
match tokio::time::timeout(Duration::from_secs(11), server).await {
|
||||
Ok(Ok(Ok(()))) => {}
|
||||
Ok(Ok(Err(error))) => {
|
||||
tracing::error!(%error, "desktop server shutdown failed")
|
||||
}
|
||||
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
|
||||
Err(_) => tracing::warn!("desktop server shutdown timed out"),
|
||||
}
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Reopen {
|
||||
has_visible_windows: false,
|
||||
..
|
||||
} => tray::show_main_window(app),
|
||||
} => {
|
||||
let _ = open_main_window(app);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
mod desktop;
|
||||
#[cfg(not(dev))]
|
||||
mod frontend;
|
||||
mod resource_limits;
|
||||
mod startup;
|
||||
mod tray;
|
||||
mod update;
|
||||
|
||||
pub use desktop::run;
|
||||
// mimalloc 在释放时主动向操作系统归还内存,避免 glibc 保留页导致
|
||||
// 关闭窗口后 RSS 无法回落到静默启动水平。
|
||||
#[global_allocator]
|
||||
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
pub fn run() -> std::process::ExitCode {
|
||||
if let Some(exit_code) = update::run_replacement_if_requested() {
|
||||
return exit_code;
|
||||
}
|
||||
desktop::run()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Configures process resource limits before the desktop runtime starts.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::io;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) const REQUESTED_OPEN_FILE_LIMIT: u64 = 65_536;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) struct OpenFileLimit {
|
||||
pub(crate) previous: u64,
|
||||
pub(crate) effective: u64,
|
||||
pub(crate) hard: u64,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn raise_open_file_limit() -> io::Result<OpenFileLimit> {
|
||||
let mut limits = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
// SAFETY: `limits` points to writable memory for one `rlimit` value.
|
||||
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let previous = limits.rlim_cur;
|
||||
let target = limits
|
||||
.rlim_max
|
||||
.min(REQUESTED_OPEN_FILE_LIMIT as libc::rlim_t);
|
||||
if previous < target {
|
||||
let requested = libc::rlimit {
|
||||
rlim_cur: target,
|
||||
rlim_max: limits.rlim_max,
|
||||
};
|
||||
// SAFETY: `requested` is a valid `rlimit` value and does not raise the hard limit.
|
||||
if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &requested) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
|
||||
let mut effective = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
// SAFETY: `effective` points to writable memory for one `rlimit` value.
|
||||
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut effective) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(OpenFileLimit {
|
||||
previous,
|
||||
effective: effective.rlim_cur,
|
||||
hard: effective.rlim_max,
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
App, AppHandle, Manager,
|
||||
App,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
|
||||
|
||||
use crate::desktop::MAIN_WINDOW_LABEL;
|
||||
use crate::desktop::open_main_window;
|
||||
|
||||
const OPEN_MENU_ID: &str = "tray-open";
|
||||
const QUIT_MENU_ID: &str = "tray-quit";
|
||||
@@ -24,7 +24,9 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
OPEN_MENU_ID => show_main_window(app),
|
||||
OPEN_MENU_ID => {
|
||||
let _ = open_main_window(app);
|
||||
}
|
||||
QUIT_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
@@ -36,7 +38,7 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(tray.app_handle());
|
||||
let _ = open_main_window(tray.app_handle());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -45,11 +47,3 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.5-beta.1",
|
||||
"version": "1.0.1",
|
||||
"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"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { TooltipProvider } from "./shared/ui/Tooltip";
|
||||
import { MessageProvider } from "./shared/ui/MessageProvider";
|
||||
import { useMessage } from "./shared/ui/message";
|
||||
import { AppFrame } from "./shell/AppFrame";
|
||||
@@ -13,9 +14,11 @@ 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 />} />
|
||||
@@ -32,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(() => {
|
||||
@@ -47,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,6 +1,7 @@
|
||||
import type {
|
||||
CallDetail,
|
||||
CursorHarnessStatus,
|
||||
ExternalApiSettings,
|
||||
LlmCall,
|
||||
Model,
|
||||
Overview,
|
||||
@@ -73,27 +74,29 @@ const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => {
|
||||
};
|
||||
});
|
||||
|
||||
const harnessStatus: CursorHarnessStatus = {
|
||||
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 externalApiSettings: ExternalApiSettings = { enabled: false, api_key: "" };
|
||||
let proxySettings: ProxySettings = {
|
||||
mode: "system",
|
||||
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 };
|
||||
let storage: StatisticsStorage = { call_count: calls.length, trace_count: calls.length };
|
||||
|
||||
export function installDemoApi() {
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
@@ -107,7 +110,7 @@ export function installDemoApi() {
|
||||
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
||||
const body = await readBody(input, init);
|
||||
|
||||
if (path === "/ads") return json({ slots: [] });
|
||||
if (path === "/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);
|
||||
@@ -126,7 +129,17 @@ export function installDemoApi() {
|
||||
if (path === "/llm-calls") return json(calls);
|
||||
if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length)));
|
||||
if (path === "/harness/cursor/status") return json(harnessStatus);
|
||||
if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus);
|
||||
if (path === "/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);
|
||||
@@ -137,12 +150,15 @@ export function installDemoApi() {
|
||||
portSettings = body as typeof portSettings;
|
||||
return json(portSettings);
|
||||
}
|
||||
if (path === "/settings/external-api" && method === "GET") return json(externalApiSettings);
|
||||
if (path === "/settings/external-api") {
|
||||
externalApiSettings = body as ExternalApiSettings;
|
||||
return json(externalApiSettings);
|
||||
}
|
||||
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 };
|
||||
storage = scope === "all" ? { call_count: 0, trace_count: 0 } : storage;
|
||||
return json(storage);
|
||||
}
|
||||
if (path === "/settings/proxy" && method === "GET") return json(proxySettings);
|
||||
@@ -185,6 +201,7 @@ function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/re
|
||||
model_hash: hash,
|
||||
sort_order: order,
|
||||
display_name: name,
|
||||
group_name: null,
|
||||
type,
|
||||
base_url: url,
|
||||
use_full_url: false,
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, configuredPluginModels, type Overview } from "../../shared/api";
|
||||
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 } from "./overview/OverviewTimeRangeFilter";
|
||||
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, 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,9 +115,26 @@ 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);
|
||||
@@ -105,13 +144,18 @@ export function HomePage() {
|
||||
...models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
group: modelProviderName(model),
|
||||
icon: iconFor(model.type),
|
||||
})),
|
||||
...configuredPluginModels(plugins).map((model) => ({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
icon: flatColorOrganizationIcon,
|
||||
})),
|
||||
...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[] = [
|
||||
{
|
||||
@@ -138,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}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { init, Rect, type ElementEvent } from "zrender";
|
||||
import type { Locale } from "../../../i18n/runtime";
|
||||
import { useI18n } from "../../../i18n/store";
|
||||
import { Tooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
|
||||
import { 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,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
|
||||
import { useAppStore } from "../../../shared/store/appStore";
|
||||
import { Icon } from "../../../shared/ui/Icon";
|
||||
import { Tooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
|
||||
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
|
||||
import { informationOutlineIcon } from "../../../shared/ui/icons";
|
||||
import { CacheHitRateChart } from "./CacheHitRateChart";
|
||||
import styles from "./HomeMetrics.module.scss";
|
||||
@@ -16,13 +16,6 @@ export type HomeMetricsData = {
|
||||
cacheWriteTokens: number;
|
||||
};
|
||||
|
||||
const TOKEN_PRICE_PER_MILLION = {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
} as const;
|
||||
|
||||
function formatMetricValue(value: number) {
|
||||
const full = formatInteger(value);
|
||||
const compact = formatCompactInteger(value);
|
||||
@@ -53,25 +46,21 @@ 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 }) {
|
||||
const { pricing } = useAppStore();
|
||||
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
||||
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
||||
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
||||
@@ -81,10 +70,10 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
|
||||
);
|
||||
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
|
||||
const costs = {
|
||||
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
|
||||
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
|
||||
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
|
||||
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
|
||||
input: priceTokens(inputTokens, pricing.input_per_million),
|
||||
output: priceTokens(outputTokens, pricing.output_per_million),
|
||||
cacheRead: priceTokens(data.cacheReadTokens, pricing.cache_read_per_million),
|
||||
cacheWrite: priceTokens(data.cacheWriteTokens, pricing.cache_write_per_million),
|
||||
};
|
||||
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
|
||||
const cacheCost = costs.cacheRead + costs.cacheWrite;
|
||||
@@ -117,27 +106,27 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
|
||||
t("缓存读写已计入提示词侧统计。"),
|
||||
].join("\n");
|
||||
const costTooltip = [
|
||||
t("按 Claude Opus 4.7 价格估算。"),
|
||||
t("按配置的 Token 价格估算。"),
|
||||
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
|
||||
"",
|
||||
t("普通输入:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(inputTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.input,
|
||||
price: pricing.input_per_million,
|
||||
cost: formatUSD(costs.input),
|
||||
}),
|
||||
t("模型输出:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(outputTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.output,
|
||||
price: pricing.output_per_million,
|
||||
cost: formatUSD(costs.output),
|
||||
}),
|
||||
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(data.cacheReadTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.cacheRead,
|
||||
price: pricing.cache_read_per_million,
|
||||
cost: formatUSD(costs.cacheRead),
|
||||
}),
|
||||
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(data.cacheWriteTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
|
||||
price: pricing.cache_write_per_million,
|
||||
cost: formatUSD(costs.cacheWrite),
|
||||
}),
|
||||
"",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,25 @@ import { createPortal } from "react-dom";
|
||||
import { parseTimeInput } from "../../../shared/utils/parseTimeInput";
|
||||
import controls from "../../../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../../../shared/ui/Icon";
|
||||
import { MultiSelect, type MultiSelectOption } from "../../../shared/ui/MultiSelect";
|
||||
import { 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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Sortable from "sortablejs";
|
||||
import type { Model, PluginModelDescriptor } from "../../shared/api";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { 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";
|
||||
@@ -32,6 +32,7 @@ type CursorModelCardsProps = {
|
||||
onTestPluginModel: (model: PluginModelDescriptor) => void;
|
||||
onPluginSettings: (model: PluginModelDescriptor) => void;
|
||||
onReorder: (modelHashes: string[]) => void;
|
||||
onGroupSettings: (group: CursorModelGroup) => void;
|
||||
};
|
||||
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping" | "pluginModels" | "onTestPluginModel" | "onPluginSettings"> & {
|
||||
@@ -60,6 +61,8 @@ export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
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}
|
||||
@@ -77,9 +80,10 @@ export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
return <div className={styles.modelGroups}>
|
||||
{builtins}
|
||||
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
|
||||
key={group.pluginId}
|
||||
key={`${props.grouping}:${group.pluginId}`}
|
||||
label={group.pluginName}
|
||||
iconSrc={group.icon}
|
||||
defaultOpen={props.grouping === "flat"}
|
||||
>
|
||||
{group.models.map((model) => <PluginModelRow
|
||||
key={model.id}
|
||||
@@ -107,25 +111,41 @@ function pluginGroups(models: PluginModelDescriptor[]) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CollapsibleGroup({ label, icon, iconSrc, children }: {
|
||||
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(true);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return <Card className={styles.groupCard}>
|
||||
<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>
|
||||
<Icon icon={open ? chevronDownIcon : chevronRightIcon} size="1em" />
|
||||
</button>
|
||||
<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>;
|
||||
}
|
||||
@@ -273,8 +293,9 @@ function ModelGrid({
|
||||
}
|
||||
|
||||
function providerGroup(model: Model) {
|
||||
const label = providerDomain(model.base_url);
|
||||
return { key: label, label, icon: flatColorOrganizationIcon };
|
||||
const key = providerDomain(model.base_url);
|
||||
const label = model.group_name?.trim() || key;
|
||||
return { key, label, icon: flatColorOrganizationIcon };
|
||||
}
|
||||
|
||||
function providerDomain(baseUrl: string) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -5,7 +6,7 @@ import { Button } from "../../shared/ui/Button";
|
||||
import { Checkbox } from "../../shared/ui/Checkbox";
|
||||
import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls";
|
||||
import { JsonEditor } from "../../shared/ui/JsonEditor";
|
||||
import { Combobox, Select } from "../../shared/ui/Select";
|
||||
import { 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";
|
||||
@@ -24,6 +25,7 @@ export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
model: {
|
||||
sort_order: 0,
|
||||
display_name: "",
|
||||
group_name: null,
|
||||
type: "openai",
|
||||
base_url: "",
|
||||
use_full_url: false,
|
||||
@@ -54,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) => {
|
||||
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
|
||||
@@ -88,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];
|
||||
@@ -150,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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -73,8 +87,37 @@
|
||||
.groupCard {
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@@ -2,16 +2,18 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, configuredPluginModels, type Model, type ModelInput } from "../../shared/api";
|
||||
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "./CursorGates";
|
||||
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "./CursorModelCards";
|
||||
import { CursorModelCards, cursorModelGroups, type CursorModelGroup, type CursorModelGrouping } from "./CursorModelCards";
|
||||
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "./CursorModelEditor";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { LegacyModelImport } from "./LegacyModelImport";
|
||||
import { ConfirmDialog } from "../../shared/ui/ConfirmDialog";
|
||||
import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls";
|
||||
import controls from "../../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
import { Switch } from "../../shared/ui/Switch";
|
||||
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
|
||||
import { addIcon } from "../../shared/ui/icons";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
@@ -29,13 +31,21 @@ 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 })),
|
||||
@@ -78,8 +88,8 @@ export function CursorSettingsPage() {
|
||||
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);
|
||||
@@ -91,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);
|
||||
}
|
||||
@@ -117,11 +129,10 @@ 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 });
|
||||
}
|
||||
};
|
||||
@@ -208,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("排序失败"));
|
||||
@@ -228,6 +272,7 @@ export function CursorSettingsPage() {
|
||||
onTestPluginModel={(model) => void testModel({ model_hash: model.id, display_name: model.displayName })}
|
||||
onPluginSettings={() => navigate("/plugins")}
|
||||
onReorder={reorderModels}
|
||||
onGroupSettings={openGroupSettings}
|
||||
/>;
|
||||
|
||||
const refreshCa = async () => {
|
||||
@@ -256,24 +301,64 @@ export function CursorSettingsPage() {
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
|
||||
|
||||
return <>
|
||||
{testTargets.length > 0 && <PageActions position="left">
|
||||
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
|
||||
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
|
||||
<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="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>
|
||||
</>;
|
||||
}
|
||||
@@ -283,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,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api, pluginText, type PluginDescriptor, type PluginImportFile, type Plu
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { ActionMenu } from "../../shared/ui/ActionMenu";
|
||||
import { 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";
|
||||
@@ -78,6 +78,8 @@ export function PluginManagementPage() {
|
||||
? 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} />}
|
||||
@@ -168,6 +170,38 @@ function PluginCard({ plugin, onOpen }: {
|
||||
}
|
||||
};
|
||||
|
||||
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}>
|
||||
@@ -189,9 +223,9 @@ function PluginCard({ plugin, onOpen }: {
|
||||
models: modelCount,
|
||||
})}
|
||||
</span>
|
||||
<span className={styles.pluginAuthor}>
|
||||
{[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
{plugin.author && (
|
||||
<span className={styles.pluginAuthor}>{plugin.author}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.cardActions}>
|
||||
<TruncatedButton
|
||||
@@ -207,39 +241,9 @@ function PluginCard({ plugin, onOpen }: {
|
||||
onClick={() => onOpen(plugin.id, "settings")}
|
||||
/>
|
||||
)}
|
||||
{(importResource || exportResource) && (
|
||||
{moreItems.length > 0 && (
|
||||
<span className={styles.moreAction}>
|
||||
<ActionMenu
|
||||
label={t("更多")}
|
||||
items={[
|
||||
...(importResource
|
||||
? [
|
||||
{
|
||||
id: "import",
|
||||
label: importing ? t("正在导入…") : t("批量导入"),
|
||||
disabled: importing,
|
||||
onSelect: () => importInput.current?.click(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(exportResource
|
||||
? [
|
||||
{
|
||||
id: "export",
|
||||
label: t("批量导出"),
|
||||
onSelect: () =>
|
||||
void api.openExternalUrl(
|
||||
api.pluginResourceExportUrl(
|
||||
ports.service_port,
|
||||
plugin.id,
|
||||
exportResource.type,
|
||||
),
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
<ActionMenu label={t("更多")} items={moreItems} />
|
||||
</span>
|
||||
)}
|
||||
{importResource && (
|
||||
|
||||
@@ -85,6 +85,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -118,6 +162,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
type PluginDescriptor,
|
||||
type PluginOAuthBegin,
|
||||
type PluginProviderDescriptor,
|
||||
type PluginResourceAction,
|
||||
type PluginResourceActionCard,
|
||||
type PluginResourceActionResult,
|
||||
type PluginResourceDescriptor,
|
||||
type PluginResourceView,
|
||||
} from "../../shared/api";
|
||||
@@ -13,7 +16,10 @@ 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;
|
||||
@@ -109,7 +115,7 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id);
|
||||
setBegun(next);
|
||||
setStatus("polling");
|
||||
await api.copyCursorText(next.userCode).catch(() => undefined);
|
||||
if (next.userCode) await api.copyCursorText(next.userCode).catch(() => undefined);
|
||||
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
|
||||
} catch (cause) {
|
||||
setStatus("error");
|
||||
@@ -117,13 +123,14 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
}
|
||||
};
|
||||
|
||||
const userCode = begun?.userCode;
|
||||
return <Card className={styles.methodCard}>
|
||||
<strong>{pluginText(method.displayName, locale)}</strong>
|
||||
{method.description && <span>{pluginText(method.description, locale)}</span>}
|
||||
{begun && status === "polling" && <div className={styles.deviceCode}>
|
||||
{userCode && status === "polling" && <div className={styles.deviceCode}>
|
||||
<small>{t("设备验证码")}</small>
|
||||
<button type="button" onClick={() => void copyCode(begun.userCode)}>{begun.userCode}</button>
|
||||
<button type="button" className={styles.copy} onClick={() => void copyCode(begun.userCode)}>
|
||||
<button type="button" onClick={() => void copyCode(userCode)}>{userCode}</button>
|
||||
<button type="button" className={styles.copy} onClick={() => void copyCode(userCode)}>
|
||||
{copied ? t("已复制") : t("复制")}
|
||||
</button>
|
||||
</div>}
|
||||
@@ -141,6 +148,14 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
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);
|
||||
@@ -155,12 +170,45 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
})}
|
||||
@@ -169,6 +217,7 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
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);
|
||||
})}
|
||||
@@ -177,13 +226,34 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
})}
|
||||
/>)}
|
||||
{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, onSync }: {
|
||||
function ProviderRow({ provider, busy, syncing, onManageModels, onSync }: {
|
||||
provider: PluginProviderDescriptor;
|
||||
busy: boolean;
|
||||
syncing: boolean;
|
||||
onManageModels: () => void;
|
||||
onSync: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
@@ -198,15 +268,72 @@ function ProviderRow({ provider, busy, syncing, onSync }: {
|
||||
{provider.configured ? t("可调用") : t("未就绪")}
|
||||
</span>
|
||||
</div>
|
||||
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
|
||||
{syncing ? t("正在同步…") : t("同步模型")}
|
||||
</Button>}
|
||||
{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 ResourceList({ resource, busy, onRefresh, onDelete }: {
|
||||
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;
|
||||
}) {
|
||||
@@ -231,8 +358,10 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
||||
{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)}
|
||||
/>)}
|
||||
@@ -247,10 +376,12 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
||||
</FormField>;
|
||||
}
|
||||
|
||||
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
|
||||
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;
|
||||
}) {
|
||||
@@ -267,12 +398,88 @@ function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
|
||||
</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>;
|
||||
|
||||
@@ -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,231 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api, pluginText, type CommitSettingsView } from "../../shared/api";
|
||||
import { commitPromptLocale } from "../../i18n/runtime";
|
||||
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);
|
||||
const promptLocale = commitPromptLocale(locale);
|
||||
if (!loaded.prompt.trim() && loaded.prompt_locale !== promptLocale) {
|
||||
loaded = await api.setCommitSettings({
|
||||
model_id: loaded.model_id,
|
||||
prompt: "",
|
||||
prompt_locale: promptLocale,
|
||||
});
|
||||
}
|
||||
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: commitPromptLocale(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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row, .address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.address {
|
||||
flex-wrap: wrap;
|
||||
|
||||
code {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ExternalApiSettings } from "../../shared/api";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { FormField, SecretTextInput } from "../../shared/ui/FormControls";
|
||||
import { Switch } from "../../shared/ui/Switch";
|
||||
import { TitledCard } from "../../shared/ui/TitledCard";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
import styles from "./ExternalApiSettingsCard.module.scss";
|
||||
|
||||
export function ExternalApiSettingsCard({ servicePort }: { servicePort: number }) {
|
||||
const message = useMessage();
|
||||
const [saved, setSaved] = useState<ExternalApiSettings | null>(null);
|
||||
const [draft, setDraft] = useState<ExternalApiSettings>({ enabled: false, api_key: "" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void api.externalApiSettings().then((settings) => {
|
||||
setSaved(settings);
|
||||
setDraft(settings);
|
||||
}).catch((cause: unknown) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const settings = await api.setExternalApiSettings(draft);
|
||||
setSaved(settings);
|
||||
setDraft(settings);
|
||||
message(t("外部 API 设置已保存"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const address = `http://127.0.0.1:${servicePort}/byok/v1`;
|
||||
const changed = saved && (saved.enabled !== draft.enabled || saved.api_key !== draft.api_key);
|
||||
|
||||
return <TitledCard title={t("外部 API")} action={<Button size="small" variant="primary" disabled={!changed || saving} onClick={() => void save()}>
|
||||
{saving ? t("保存中…") : t("保存")}
|
||||
</Button>}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.description}>
|
||||
<strong>{t("开启外部 API")}</strong>
|
||||
<small>{t("允许本机应用使用密钥调用已配置的模型,包括插件模型。")}</small>
|
||||
</div>
|
||||
<Switch label={t("开启外部 API")} checked={draft.enabled} disabled={!saved || saving}
|
||||
onChange={(enabled) => setDraft((current) => ({ ...current, enabled }))} />
|
||||
</div>
|
||||
<FormField label={t("API 密钥")} hint={t("开启后,所有外部请求都必须提供此密钥。")}>
|
||||
<SecretTextInput value={draft.api_key} autoComplete="off" disabled={!saved || saving}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, api_key: event.target.value }))} />
|
||||
</FormField>
|
||||
<div className={styles.address}>
|
||||
<strong>{t("基础地址")}</strong>
|
||||
<code>{address}</code>
|
||||
</div>
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { TokenPricingSettings } from "../../shared/api";
|
||||
import { appStore, DEFAULT_TOKEN_PRICING, useAppStore } from "../../shared/store/appStore";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { FormField, TextInput } from "../../shared/ui/FormControls";
|
||||
import { TitledCard } from "../../shared/ui/TitledCard";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
import styles from "./ProxySettingsCard.module.scss";
|
||||
|
||||
type PricingDraft = {
|
||||
input_per_million: string;
|
||||
output_per_million: string;
|
||||
cache_read_per_million: string;
|
||||
cache_write_per_million: string;
|
||||
};
|
||||
|
||||
function toDraft(pricing: TokenPricingSettings): PricingDraft {
|
||||
return {
|
||||
input_per_million: String(pricing.input_per_million),
|
||||
output_per_million: String(pricing.output_per_million),
|
||||
cache_read_per_million: String(pricing.cache_read_per_million),
|
||||
cache_write_per_million: String(pricing.cache_write_per_million),
|
||||
};
|
||||
}
|
||||
|
||||
function formatPrice(value: number) {
|
||||
return `$${value}`;
|
||||
}
|
||||
|
||||
function parsePrice(value: string, label: string) {
|
||||
const price = Number(value);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
throw new Error(t("{label}必须是非负数", { label }));
|
||||
}
|
||||
return price;
|
||||
}
|
||||
|
||||
function toSettings(draft: PricingDraft): TokenPricingSettings {
|
||||
return {
|
||||
input_per_million: parsePrice(draft.input_per_million, t("输入价格")),
|
||||
output_per_million: parsePrice(draft.output_per_million, t("输出价格")),
|
||||
cache_read_per_million: parsePrice(draft.cache_read_per_million, t("缓存读取价格")),
|
||||
cache_write_per_million: parsePrice(draft.cache_write_per_million, t("缓存写入价格")),
|
||||
};
|
||||
}
|
||||
|
||||
export function PricingSettingsCard() {
|
||||
const { pricing } = useAppStore();
|
||||
const message = useMessage();
|
||||
const [draft, setDraft] = useState<PricingDraft>(() => toDraft(pricing));
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(toDraft(pricing));
|
||||
}, [pricing, editing]);
|
||||
|
||||
const edit = () => {
|
||||
setDraft(toDraft(pricing));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setDraft(toDraft(pricing));
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const next = toSettings(draft);
|
||||
if (await appStore.updatePricingSettings(next)) {
|
||||
setEditing(false);
|
||||
message(t("定价设置已保存"));
|
||||
}
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreDefault = () => {
|
||||
setDraft(toDraft(DEFAULT_TOKEN_PRICING));
|
||||
};
|
||||
|
||||
const action = editing ? (
|
||||
<div className={styles.actionGroup}>
|
||||
<Button size="small" disabled={saving} onClick={restoreDefault}>{t("恢复默认")}</Button>
|
||||
<Button size="small" disabled={saving} onClick={cancel}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? t("保存中…") : t("保存")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={styles.headerAction} onClick={edit}>{t("编辑")}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<TitledCard title={t("Token 定价")} action={action}>
|
||||
<div className={styles.content}>
|
||||
<small>{t("用于首页价值估算的 Token 单价,单位:美元 / 百万 Token。")}</small>
|
||||
{editing ? (
|
||||
<div className={styles.customFields}>
|
||||
<FormField label={t("输入价格($/1M)")}>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={draft.input_per_million}
|
||||
onChange={(event) => setDraft({ ...draft, input_per_million: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("输出价格($/1M)")}>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={draft.output_per_million}
|
||||
onChange={(event) => setDraft({ ...draft, output_per_million: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("缓存读取价格($/1M)")}>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={draft.cache_read_per_million}
|
||||
onChange={(event) => setDraft({ ...draft, cache_read_per_million: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("缓存写入价格($/1M)")}>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={draft.cache_write_per_million}
|
||||
onChange={(event) => setDraft({ ...draft, cache_write_per_million: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("输入价格($/1M)")}</strong>
|
||||
<span className={styles.value}>{formatPrice(pricing.input_per_million)}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("输出价格($/1M)")}</strong>
|
||||
<span className={styles.value}>{formatPrice(pricing.output_per_million)}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("缓存读取价格($/1M)")}</strong>
|
||||
<span className={styles.value}>{formatPrice(pricing.cache_read_per_million)}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("缓存写入价格($/1M)")}</strong>
|
||||
<span className={styles.value}>{formatPrice(pricing.cache_write_per_million)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TitledCard>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
width: min(320px, 50%);
|
||||
}
|
||||
|
||||
.value {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorag
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { LegacyModelImport } from "../models/LegacyModelImport";
|
||||
import { AppLifecycleSettingsCard } from "./AppLifecycleSettingsCard";
|
||||
import { CommitSettingsCard } from "./CommitSettingsCard";
|
||||
import { ExternalApiSettingsCard } from "./ExternalApiSettingsCard";
|
||||
import { PricingSettingsCard } from "./PricingSettingsCard";
|
||||
import { ProxySettingsCard } from "./ProxySettingsCard";
|
||||
import { TabSettingsCard } from "./TabSettingsCard";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
@@ -30,7 +33,7 @@ export function SettingsPage() {
|
||||
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);
|
||||
@@ -38,13 +41,16 @@ export function SettingsPage() {
|
||||
const [editingTab, setEditingTab] = useState(false);
|
||||
const [savingTab, setSavingTab] = useState(false);
|
||||
useEffect(() => {
|
||||
void Promise.all([api.statisticsStorage(), api.proxySettings(), api.tabSettings()]).then(([nextStorage, nextProxy, nextTab]) => {
|
||||
setStorage(nextStorage);
|
||||
setOutboundProxy(nextProxy);
|
||||
setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" });
|
||||
setTabSettings(nextTab);
|
||||
setTabDraft(nextTab);
|
||||
}).catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
const report = (cause: unknown) => message(cause instanceof Error ? cause.message : String(cause));
|
||||
void api.statisticsStorage().then(setStorage).catch(report);
|
||||
void api.proxySettings().then((next) => {
|
||||
setOutboundProxy(next);
|
||||
setProxyDraft({ mode: next.mode, address: next.address, auth_enabled: next.auth_enabled, username: next.username, password: "" });
|
||||
}).catch(report);
|
||||
void api.tabSettings().then((next) => {
|
||||
setTabSettings(next);
|
||||
setTabDraft(next);
|
||||
}).catch(report);
|
||||
}, [message]);
|
||||
useEffect(() => {
|
||||
setProxyPort(String(ports.proxy_port));
|
||||
@@ -147,14 +153,6 @@ export function SettingsPage() {
|
||||
setSavingTab(false);
|
||||
}
|
||||
};
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
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 和应用设置不会受到影响,此操作无法撤销。")
|
||||
@@ -227,8 +225,11 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</TitledCard>
|
||||
<ExternalApiSettingsCard servicePort={ports.service_port} />
|
||||
<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 />
|
||||
<PricingSettingsCard />
|
||||
<AppLifecycleSettingsCard />
|
||||
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
|
||||
<div className={styles.importRow}>
|
||||
@@ -245,7 +246,7 @@ export function SettingsPage() {
|
||||
<div className={styles.settingRow}>
|
||||
<div>
|
||||
<strong>{t("界面语言")}</strong>
|
||||
<small>{t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : "English" })}</small>
|
||||
<small>{t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : locale === "pt-BR" ? "Português (Brasil)" : "English" })}</small>
|
||||
</div>
|
||||
<div className={styles.languageControl}>
|
||||
<Select
|
||||
@@ -255,6 +256,7 @@ export function SettingsPage() {
|
||||
{ value: "system", label: t("跟随系统") },
|
||||
{ value: "zh-CN", label: "简体中文" },
|
||||
{ value: "en-US", label: "English" },
|
||||
{ value: "pt-BR", label: "Português (Brasil)" },
|
||||
]}
|
||||
onChange={(value) => setLocalePreference(value as LocalePreference)}
|
||||
/>
|
||||
@@ -278,7 +280,7 @@ export function SettingsPage() {
|
||||
<div className={styles.storageRow}>
|
||||
<div>
|
||||
<strong>{t("统计数据")}</strong>
|
||||
<small>{storage ? formatBytes(storage.bytes) : t("计算中…")}</small>
|
||||
<small>{storage ? t("调用记录 {calls} 条 · 追踪记录 {traces} 条", { calls: storage.call_count, traces: storage.trace_count }) : t("计算中…")}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
width: min(320px, 50%);
|
||||
}
|
||||
|
||||
.value {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
"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",
|
||||
@@ -26,11 +27,14 @@
|
||||
"0e41f8e3d59ec47b": "Storage management",
|
||||
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
|
||||
"0ec1e85b0c3cfa65": "Call details",
|
||||
"0ecfbbe0697af7ea": "Input price ($/1M)",
|
||||
"100fad4a0b3ab781": "Last 24 hours",
|
||||
"105a9082c346f958": "Testing…",
|
||||
"12430375c0db4727": "Estimated using configured token pricing.",
|
||||
"124be3f86f197802": "Token usage",
|
||||
"12ae77e6202d063e": "Custom Headers",
|
||||
"12b86e89701b0241": "Enter a model name and press Enter to add it",
|
||||
"133340e53175128a": "Test all",
|
||||
"13a494f82d7f26df": "Enable external API",
|
||||
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
|
||||
"13b61c5f697b6700": "Cache hit rate",
|
||||
"146da2e2a991493e": "Fetching…",
|
||||
@@ -40,6 +44,7 @@
|
||||
"1813d362a82fd437": "Maximize window",
|
||||
"18165f8865eacc91": "No plugins installed",
|
||||
"19658d9fa9aa8de4": "Installing…",
|
||||
"19bb071f8c362695": "Prompt settings",
|
||||
"1a3f0617d6de8e52": "Username",
|
||||
"1a60c9eb3cf1dbb5": "Import finished: {added} added, {updated} updated",
|
||||
"1aa65c55c6cc6163": "Device code",
|
||||
@@ -49,11 +54,13 @@
|
||||
"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",
|
||||
@@ -62,11 +69,13 @@
|
||||
"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.",
|
||||
@@ -75,10 +84,11 @@
|
||||
"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",
|
||||
@@ -88,12 +98,12 @@
|
||||
"382f2e3419a02fef": "Only clear detailed records",
|
||||
"38844b135cf70dfc": "More",
|
||||
"393e1241552b1870": "Request",
|
||||
"398f8e6c6f0a0b97": "Continue selecting or typing",
|
||||
"39f52eee100131d7": "Cached input",
|
||||
"3a0fb74abe2460b6": "Chunks",
|
||||
"3a3f595df70ec8ff": "Clear storage",
|
||||
"3a5040b68abf75f9": "Select all",
|
||||
"3a8c76b2ce785f96": "Review and import",
|
||||
"3b63f1f9d5922e65": "Cache write price ($/1M)",
|
||||
"3b67824289b5fa1e": "Dock icon hidden",
|
||||
"3c94b4c75940c178": "Input Tokens",
|
||||
"3cfae5728b92b334": "Token usage: {tokens}",
|
||||
@@ -111,6 +121,7 @@
|
||||
"43cb41d62de2d179": "Proxy requires authentication",
|
||||
"461d6a57900c2ed7": "Connectivity test failed: {error}",
|
||||
"470049252e54de6a": "Success rate: {rate}",
|
||||
"4791868cb0a4be4d": "Output price",
|
||||
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
|
||||
"48a3bf87eb254591": "Start sign-in",
|
||||
"48b970b568a7f8f9": "Proxy settings",
|
||||
@@ -131,33 +142,43 @@
|
||||
"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",
|
||||
"54d735fcd15e8c93": "Output price ($/1M)",
|
||||
"54e6745ff43c9c74": "Unable to save model order",
|
||||
"550eddc3c7fefa99": "Sponsored",
|
||||
"552a5d4baf45d878": "Commit Message Model Settings",
|
||||
"56432ba297009bdc": "Initialize the CA first",
|
||||
"565678b3704a383d": "Pricing settings saved",
|
||||
"56627c94a9decee6": "Maximum output tokens",
|
||||
"576d81bb0631b165": "Import",
|
||||
"5886afc1c71df1fe": "Shown in the Cursor model description.",
|
||||
"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",
|
||||
"5c62e36c152dfc7c": "Plugin runtime initialized",
|
||||
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
||||
"5f600b307b4eb0fb": "API key",
|
||||
"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",
|
||||
"60e7671141df2731": "Input price",
|
||||
"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}",
|
||||
@@ -165,6 +186,7 @@
|
||||
"656ab25e264cc4e4": "No models are available to Cursor yet",
|
||||
"65a6318e07ec1e07": "Tools",
|
||||
"65cb9a7b4f620b6b": "Prompt {tokens}",
|
||||
"66f7ceff962da68c": "Token unit prices used for the home page estimated value. Unit: USD per million tokens.",
|
||||
"680680288a6d2ad2": "Show in Dock",
|
||||
"68102220092c1f0f": "Detailed records cleared",
|
||||
"68ad603fafe4e0d6": "Import legacy configuration",
|
||||
@@ -185,10 +207,12 @@
|
||||
"7a3cec4ca715de80": "Call statistics",
|
||||
"7ba2d6728fe2531b": "Confirm clear",
|
||||
"7c10d97162c96dbd": "Validating the plugin runtime",
|
||||
"7cc1cf151d967bda": "Allow local apps to call configured models, including plugin models, with an API key.",
|
||||
"7cea2f3c46565d29": "OpenAI extra parameters",
|
||||
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
|
||||
"7e0891860c9e6374": "TAB service address is required",
|
||||
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
|
||||
"7ed9f96f513813a4": "External API settings saved",
|
||||
"7f3c8312816fe26a": "Refreshing…",
|
||||
"7f68ebad19ba6bcd": "Check for updates",
|
||||
"802b0faf0ceb513e": "{label}: {percent}% left",
|
||||
@@ -200,6 +224,8 @@
|
||||
"83fcfb4c1f2c1641": "Fetch models",
|
||||
"842b9f11cdd96bda": "Launch at login",
|
||||
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
||||
"844b8cc8dff7c1d8": "Default",
|
||||
"857d282a0213d785": "External API",
|
||||
"864597982c308d72": "Silent start enabled",
|
||||
"86de7c4ee8fa7689": "Sync models",
|
||||
"8716e1344b0daddb": "Cursor official",
|
||||
@@ -220,13 +246,19 @@
|
||||
"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",
|
||||
"99ac861433209a53": "All external requests must provide this key when enabled.",
|
||||
"9a84733cc9ab1706": "Resource details",
|
||||
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
||||
"9b9bc9cd7c76406f": "Open authorization page",
|
||||
"9c41b3a9e12ac994": "Reasoning effort",
|
||||
@@ -241,6 +273,7 @@
|
||||
"9fb48101d237ff96": "Last week",
|
||||
"a026f37e613cf48b": "Output Tokens",
|
||||
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
|
||||
"a0ad0c340abf41c7": "Cache read price",
|
||||
"a0c42c24e74f8380": "{name} Copy",
|
||||
"a12ee6a3e98a29c2": "Hide sensitive content",
|
||||
"a1a42cd9b16e2162": "Application",
|
||||
@@ -258,6 +291,7 @@
|
||||
"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",
|
||||
@@ -267,9 +301,12 @@
|
||||
"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.",
|
||||
"b1968db970026087": "Base URL",
|
||||
"b254ff315d861346": "Try initializing again",
|
||||
"b2617bf9ae663752": "Group settings",
|
||||
"b4411558b932266f": "Provider type",
|
||||
"b4c9e08870d41aa2": "Initialize the plugin runtime first",
|
||||
"b502b1d414664337": "Prompt: {tokens}",
|
||||
@@ -283,13 +320,16 @@
|
||||
"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",
|
||||
"bb4e1ee4a6ae46df": "{calls} call records · {traces} trace records",
|
||||
"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",
|
||||
@@ -321,9 +361,12 @@
|
||||
"d59e47070f7f358e": "Callable",
|
||||
"d60669bb26a22f5d": "Leave blank to use the default",
|
||||
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
|
||||
"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.",
|
||||
"d86fa42c3848c680": "Use system proxy",
|
||||
"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",
|
||||
@@ -331,6 +374,7 @@
|
||||
"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",
|
||||
@@ -348,6 +392,7 @@
|
||||
"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",
|
||||
@@ -358,38 +403,48 @@
|
||||
"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",
|
||||
"ec917db99e814b58": "{label} must be a non-negative number",
|
||||
"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.",
|
||||
"eec6bf2dad677b9f": "Granted: {time}",
|
||||
"ef5d9908c45f4b88": "Cache read price ($/1M)",
|
||||
"f4694c46b1e19602": "Final request type",
|
||||
"f4a0b686421619eb": "Cache write price",
|
||||
"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",
|
||||
"fe6ec799e02ed9a7": "Token pricing",
|
||||
"fea405f9b01d1416": "Summary",
|
||||
"fec45092945f8790": "User guide",
|
||||
"fec7210590309465": "Clear all statistics",
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"0006d696d8e1ec28": "Novo",
|
||||
"00929f23850e4ff0": "Chamadas com sucesso: {count}",
|
||||
"01f3e69a5a9b2c9b": "A chave necessária para acessar o serviço do modelo.",
|
||||
"023810003eb4563d": "{count} modelos",
|
||||
"028a4de61bff743d": "Entrada padrão: {tokens} × ${price}/1M = {cost}",
|
||||
"028c60a8a8e30a1b": "Página {page} / {total}",
|
||||
"03ff62ab4b818492": "Gravação em cache: {tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "Editar",
|
||||
"0525054436acdb6e": "Prompt da mensagem de commit",
|
||||
"05468af47054d488": "Teste de conectividade do modelo {model} realizado com sucesso ({duration} ms)",
|
||||
"0580e0a99a6f1afc": "Artefatos",
|
||||
"06619f339fa0ab46": "Preparando o runtime de plugins",
|
||||
"076832c1b2de22c3": "Gravação em cache: {tokens}",
|
||||
"07879e064ae16542": "Saída estimada: {tokens}",
|
||||
"07c657ed4747126e": "Parâmetros adicionais da Anthropic",
|
||||
"08791ba06e7441de": "{accounts} contas · {models} modelos",
|
||||
"092b520558eff5f2": "Não testado",
|
||||
"099008ea7a42ebd1": "Todos os valores de cabeçalhos personalizados devem ser strings",
|
||||
"09ebc2643631ba25": "Valor estimado",
|
||||
"0b96da34f6fbdd3b": "Leitura de cache: {tokens} × ${price}/1M = {cost}",
|
||||
"0bbb2c0ce279d6d5": "Nenhum modelo sincronizado ainda",
|
||||
"0c70665b6eb65f1a": "Não",
|
||||
"0c72229b7db0e1a9": "Saída do modelo",
|
||||
"0d2dab3d62eb73d6": "Todas as estatísticas foram limpas",
|
||||
"0d5e2bdb15579fc4": "Mensagens",
|
||||
"0e41f8e3d59ec47b": "Gerenciamento de armazenamento",
|
||||
"0e67021ebf0a3580": "Importação concluída: {imported} modelos adicionados e {skipped} modelos existentes ignorados",
|
||||
"0ec1e85b0c3cfa65": "Detalhes da chamada",
|
||||
"0ecfbbe0697af7ea": "Preço de entrada ($/1M)",
|
||||
"100fad4a0b3ab781": "Últimas 24 horas",
|
||||
"105a9082c346f958": "Testando…",
|
||||
"12430375c0db4727": "Estimado com base nos preços de tokens configurados.",
|
||||
"124be3f86f197802": "Uso de tokens",
|
||||
"12ae77e6202d063e": "Cabeçalhos personalizados",
|
||||
"133340e53175128a": "Testar tudo",
|
||||
"13a494f82d7f26df": "Ativar API externa",
|
||||
"13a9ac7a68c5fd96": "A CA é armazenada apenas neste dispositivo e é usada para inspecionar com segurança as requisições HTTPS do Cursor.",
|
||||
"13b61c5f697b6700": "Taxa de acerto de cache",
|
||||
"146da2e2a991493e": "Obtendo…",
|
||||
"15730c19fd7eef51": "Protocolo de requisição",
|
||||
"168e845a86bc3703": "Adicionar modelo",
|
||||
"16d0d7e2b332af72": "Total de chamadas: {count}",
|
||||
"1813d362a82fd437": "Maximizar janela",
|
||||
"18165f8865eacc91": "Nenhum plugin instalado",
|
||||
"19658d9fa9aa8de4": "Instalando…",
|
||||
"19bb071f8c362695": "Configurações de prompt",
|
||||
"1a3f0617d6de8e52": "Nome de usuário",
|
||||
"1a60c9eb3cf1dbb5": "Importação concluída: {added} adicionados, {updated} atualizados",
|
||||
"1aa65c55c6cc6163": "Código do dispositivo",
|
||||
"1ae6b0a0f8266382": "Fechar janela",
|
||||
"1b5932b8946d2d68": "Excluir modelo",
|
||||
"1b7d5b1a9315fc64": "Calculando…",
|
||||
"1c6926877b1bbfb2": "Total de requisições: {tokens}",
|
||||
"1cb7e646ac883fb2": "Porta do proxy",
|
||||
"1cdde778cfe6a130": "Fórmula: leitura de cache / (leitura de cache + entrada não em cache)",
|
||||
"1d19efc260a0a18f": "Usar cartão de redefinição",
|
||||
"1d27f02ed278ebc9": "Arquivo de configuração",
|
||||
"1f9b46e61efcccb5": "Filtro: {label}, {summary}",
|
||||
"202064bb84804852": "Deixe em branco para usar o padrão.",
|
||||
"20e14248fd4fb981": "{label} deve ser um número inteiro maior que 0",
|
||||
"217cfe7db1e3d10a": "Seguir o sistema",
|
||||
"21bd738e0d7191a7": "Carregando…",
|
||||
"22c6b4eb4caee6ae": "Configurações de proxy salvas",
|
||||
"22d7895ea5fca72e": "Por provedor",
|
||||
"23ae7a90b1b9816d": "Escopo de limpeza",
|
||||
"23e49479e15e6770": "Nova versão {version} disponível",
|
||||
"2400fbd0aeab9e13": "Baixado {downloaded}",
|
||||
"24a0a24864454575": "Já existe, ignorado",
|
||||
"2555d6c7fbb7e070": "Insira o ID do modelo diretamente ou carregue os modelos retornados pela API.",
|
||||
"29585d7193539200": "Versão atual {version}",
|
||||
"296da37506328d89": "Desativar integração com o Cursor",
|
||||
"29fbbef32a6eb58b": "Não mostrar este anúncio novamente",
|
||||
"2a2773134a829016": "Agrupado pelo histórico de chamadas LLM; chamadas em andamento não são incluídas.",
|
||||
"2caeaec539e78898": "Tokens de orçamento de pensamento",
|
||||
"2cbc58108d78b06c": "Aguardando confirmação de autorização no navegador…",
|
||||
"2cd0f3be8738a86c": "Cancelar",
|
||||
"2cdf7d3275016cc2": "Desativar integração com o Cursor?",
|
||||
"2d30c2a98ebb5278": "Atual: {rate}",
|
||||
"2eb2bf7c6597ab9a": "Registros detalhados",
|
||||
"2f280a8120c0db12": "Usado apenas para exibição na interface; não altera o nome do modelo enviado ao serviço.",
|
||||
"2f4a361f878176d1": "{label} deve ser um JSON válido",
|
||||
"2f4a9609285d8f49": "Configurações do TAB salvas",
|
||||
"2f5f1d6fbfb061ed": "Não definido",
|
||||
"2f6416a2c424856b": "URL final da requisição",
|
||||
"2f7ba5fd1d12f7f9": "Abrir o tutorial de uso?",
|
||||
"2f9daa828907b93f": "Excluir",
|
||||
"2fe0e3339ac4d1bb": "Expirado",
|
||||
"2fe5a8d0eee9f14c": "Inválido",
|
||||
"303c30f301514250": "Pesquisar recursos",
|
||||
"3260348163d03b8e": "Deixe em branco para manter inalterado",
|
||||
"32896fdaaaa4c106": "Conta salva e catálogo de modelos sincronizado.",
|
||||
"346ff60e6c7c5181": "Lendo…",
|
||||
"36f33adaf0942634": "Confirmar",
|
||||
"37125ef2e1d707cb": "Endereço do servidor ou URL completa da requisição, chave de API, nome do modelo, nome de exibição e observações são obrigatórios",
|
||||
"378bb0eec39fa8a2": "Última página",
|
||||
"37cb98ff4d5dcfcc": "Sucesso {successful} / Falha {failed}",
|
||||
"382f2e3419a02fef": "Apenas limpar registros detalhados",
|
||||
"38844b135cf70dfc": "Mais",
|
||||
"393e1241552b1870": "Requisição",
|
||||
"39f52eee100131d7": "Entrada em cache",
|
||||
"3a0fb74abe2460b6": "Chunks",
|
||||
"3a3f595df70ec8ff": "Limpar espaço de armazenamento",
|
||||
"3a5040b68abf75f9": "Selecionar tudo",
|
||||
"3a8c76b2ce785f96": "Revisar e importar",
|
||||
"3b63f1f9d5922e65": "Preço de gravação em cache ($/1M)",
|
||||
"3b67824289b5fa1e": "Ícone do Dock ocultado",
|
||||
"3c94b4c75940c178": "Tokens de entrada",
|
||||
"3cfae5728b92b334": "Uso de tokens: {tokens}",
|
||||
"3d13868593ae4eeb": "Idioma da interface",
|
||||
"3da0bf1610ff5db5": "Recomendados",
|
||||
"3f6c25aa329163a4": "O caminho original do endpoint será adicionado a este endereço de serviço.",
|
||||
"3fd118e2ffe0b2b6": "Cancelar todos os testes",
|
||||
"3fd47edce45b3603": "Fechar",
|
||||
"402495402ce333b1": "Reinicializar plugins",
|
||||
"40a08e7cf320ae07": "Tem certeza de que deseja limpar os registros detalhados?",
|
||||
"4125fc7ba333524c": "Claro padrão",
|
||||
"42655ed8e4108ae2": "Entrada (não em cache)",
|
||||
"42a1d9e5b037c210": "Bytes",
|
||||
"42aa8e01e98c0d8c": "Duração total",
|
||||
"43cb41d62de2d179": "O proxy requer autenticação",
|
||||
"461d6a57900c2ed7": "Falha no teste de conectividade: {error}",
|
||||
"470049252e54de6a": "Taxa de sucesso: {rate}",
|
||||
"4791868cb0a4be4d": "Preço de saída",
|
||||
"47d1c20aa017ff05": "Ocultar a janela principal ao iniciar com o sistema, mantendo apenas o ícone na bandeja.",
|
||||
"48a3bf87eb254591": "Iniciar login",
|
||||
"48b970b568a7f8f9": "Configurações de proxy",
|
||||
"48d8db17bae06246": "{count} no total",
|
||||
"492042ed1fdc29ed": "A versão {version} está pronta para ser instalada",
|
||||
"4927a53bcc886afb": "Carregando…",
|
||||
"497c85690c4cc0fc": "Nenhum dado disponível",
|
||||
"499c729eb09aa2a6": "Tokens da janela de contexto",
|
||||
"49be72e6045c007d": "Cancelar teste",
|
||||
"4a861200ad513a3c": "Inicializar runtime de plugins",
|
||||
"4a8d6841b4023edf": "Confirmar importação",
|
||||
"4aca6a31090fe2b8": "Inicializando…",
|
||||
"4b458e6e147221d7": "O caminho padrão do endpoint é adicionado automaticamente de acordo com o protocolo selecionado.",
|
||||
"4d0680f9efaef147": "Não lido",
|
||||
"4d99c976beb8827e": "Disponível",
|
||||
"4e30d7c9ed2b0eee": "Não definir",
|
||||
"4eafa9e925b30bcd": "Personalizado",
|
||||
"51d04bc3d286f018": "Último dia civil",
|
||||
"51de3bcec137ab1b": "Testes de conectividade concluídos com sucesso para todos os {count} modelos",
|
||||
"5228358a6db59fe7": "Ex.: agora, 2026-08-23 18:00",
|
||||
"5319e7374f78fb38": "Integrado",
|
||||
"5401344227e49e2f": "Configurações do TAB",
|
||||
"54644705e9c61009": "Configurações de porta",
|
||||
"54c53e5fe791d1f3": "Inicializar CA",
|
||||
"54d735fcd15e8c93": "Preço de saída ($/1M)",
|
||||
"54e6745ff43c9c74": "Não foi possível salvar a ordenação dos modelos",
|
||||
"550eddc3c7fefa99": "Patrocinado",
|
||||
"552a5d4baf45d878": "Configurações do modelo de mensagem de commit",
|
||||
"56432ba297009bdc": "Inicialize a CA primeiro",
|
||||
"565678b3704a383d": "Configurações de preço salvas",
|
||||
"56627c94a9decee6": "Tokens máximos de saída",
|
||||
"576d81bb0631b165": "Importar",
|
||||
"5886afc1c71df1fe": "Exibido na descrição do modelo no Cursor.",
|
||||
"59346e82b3dd2998": "Endereço do serviço TAB",
|
||||
"5a284a1a2be8da0e": "Lê modelos da configuração legada local. Modelos novos e existentes são exibidos antes da confirmação.",
|
||||
"5a3bd99fa69a40c1": "Usar serviço público",
|
||||
"5acb7d61688e3c4f": "Confirmar uso",
|
||||
"5b17f59d33bde39e": "Erro: {error}",
|
||||
"5ba65a74c4e792c5": "Por tipo",
|
||||
"5c55a67935af8f45": "Todos",
|
||||
"5c62e36c152dfc7c": "Runtime de plugins inicializado com sucesso",
|
||||
"5d59857bf039cac9": "Assistente Cursor v{version}",
|
||||
"5f600b307b4eb0fb": "Chave da API",
|
||||
"5f8d556a9c47da3c": "Inicialização com o sistema desativada",
|
||||
"5f9acfb945229062": "Tem certeza de que não deseja mais ver este anúncio?",
|
||||
"5fd2ec5a6e9b654c": "Total: {cost}",
|
||||
"6003d3246f0fca2b": "Gerenciamento de modelos",
|
||||
"6078a681a306930d": "Gravação em cache",
|
||||
"609640f72d422b57": "Intervalos de tempo rápidos",
|
||||
"60e7671141df2731": "Preço de entrada",
|
||||
"61a4c7bac12dc125": "Chamadas com falha: {count}",
|
||||
"61c7d1f758b647d5": "Informações da chamada",
|
||||
"621f63a5f08384ac": "Leitura de cache: {tokens}",
|
||||
"6320b4a8722a851f": "Status",
|
||||
"63c73c4730f4473e": "Aplicar",
|
||||
"63d90d977348ab1f": "Duplicar",
|
||||
"6449a43900b609a4": "Gerenciamento de modelos {name}",
|
||||
"6478a5f1218c484e": "Use o aplicativo de desktop para copiar para a área de transferência",
|
||||
"651f274470153a05": "Atualizações de software",
|
||||
"652ec5d40c29fd6a": "Velocidade {speed} tokens/s · primeiro token {firstText} ms · tempo total {duration} ms · saída {tokens} tokens{estimated} · resposta: {output}",
|
||||
"653b123c956d3bcb": "Chamadas",
|
||||
"656ab25e264cc4e4": "Nenhum modelo disponível para o Cursor ainda",
|
||||
"65a6318e07ec1e07": "Ferramentas",
|
||||
"65cb9a7b4f620b6b": "Prompt {tokens}",
|
||||
"66f7ceff962da68c": "Preços unitários de tokens usados no valor estimado da página inicial. Unidade: USD por milhão de tokens.",
|
||||
"680680288a6d2ad2": "Exibir no Dock",
|
||||
"68102220092c1f0f": "Registros detalhados limpos",
|
||||
"68ad603fafe4e0d6": "Importar configuração legada",
|
||||
"68ea5dd4d7af20e6": "Configurações do sistema",
|
||||
"6a9906c79f26c0ba": "Horário de início",
|
||||
"6aa8f49cc992dfd7": "Testar",
|
||||
"6ae80538c2b2572d": "Minimizar janela",
|
||||
"6d1876364ac6457d": "Modo do proxy",
|
||||
"6e86570183c3cdd0": "Você já está na versão mais recente",
|
||||
"7005693f4f050bce": "E/S de cache {cost}",
|
||||
"72644ec4389da2f7": "Layout padrão",
|
||||
"736c9dc2a04c65fd": "A configuração do modelo foi alterada. Atualize e tente novamente.",
|
||||
"7392e20d61abaa07": "Também armazena requisições completas e respostas em fluxo; por padrão, apenas tempo, status e uso são gravados.",
|
||||
"77c9e582e85583af": "Falha no teste",
|
||||
"788db1cfec2a3db5": "Tema",
|
||||
"7995087e5a3dfe66": "Restaurar janela",
|
||||
"7a2229f6a6d330a5": "Abra um terminal no aplicativo de desktop para instalar a CA",
|
||||
"7a3cec4ca715de80": "Estatísticas de chamadas",
|
||||
"7ba2d6728fe2531b": "Confirmar limpeza",
|
||||
"7c10d97162c96dbd": "Validando o runtime de plugins",
|
||||
"7cc1cf151d967bda": "Permite que aplicativos locais acessem os modelos configurados, incluindo modelos de plugins, com uma chave de API.",
|
||||
"7cea2f3c46565d29": "Parâmetros adicionais da OpenAI",
|
||||
"7d9f043f8f7ab45c": "Versão {version} disponível nas Configurações",
|
||||
"7e0891860c9e6374": "O endereço do serviço TAB é obrigatório",
|
||||
"7e7df68f2a82e09e": "Importar a mesma configuração novamente não criará modelos duplicados. Modelos existentes são ignorados automaticamente.",
|
||||
"7ed9f96f513813a4": "Configurações da API externa salvas",
|
||||
"7f3c8312816fe26a": "Atualizando…",
|
||||
"7f68ebad19ba6bcd": "Verificar atualizações",
|
||||
"802b0faf0ceb513e": "{label}: {percent}% restante",
|
||||
"80a57e03f0717f91": "Não configurado",
|
||||
"811a3b22a5a7f2d5": "Não foi possível conectar ao serviço de gerenciamento local",
|
||||
"8213941f12320ce1": "Este sistema operacional ou arquitetura de CPU não é suportado no momento",
|
||||
"83c4efccd9a6bf69": "Teste de conectividade cancelado: {successful} com sucesso, {failed} com falha",
|
||||
"83e8d0b7aff2b394": "Baixado {downloaded} / {total}",
|
||||
"83fcfb4c1f2c1641": "Obter modelos",
|
||||
"842b9f11cdd96bda": "Iniciar com o sistema",
|
||||
"843ac7e15a5047a7": "Confirmar importação da configuração legada de modelos",
|
||||
"844b8cc8dff7c1d8": "Padrão",
|
||||
"857d282a0213d785": "API externa",
|
||||
"864597982c308d72": "Inicialização silenciosa ativada",
|
||||
"86de7c4ee8fa7689": "Sincronizar modelos",
|
||||
"8716e1344b0daddb": "Cursor Oficial",
|
||||
"878a8ab176429a86": "Ver instruções",
|
||||
"8911e4f1407d58cb": "Baixando o runtime de plugins",
|
||||
"89a101b809be7cfc": "Este endereço é usado exatamente como informado, sem alterar nem adicionar caminhos à requisição.",
|
||||
"8a8542f6964852dc": "Próxima página",
|
||||
"8b6ff498515bcc2f": "Horário",
|
||||
"8cbcf741e727dbf7": "Modelos",
|
||||
"8ccaf87ddb9ca3f4": "Configuração legada",
|
||||
"8d0c47eb9eac2d34": "Tipo de chamada",
|
||||
"8df48894086d6fbd": "Motivo (opcional)",
|
||||
"8e2d04638a11a7cb": "Determina apenas o formato da requisição e resposta; não altera o endereço da URL.",
|
||||
"8f6f8d979c981ced": "Copiado",
|
||||
"8f9b0d6cc477d334": "Escolha como o Cursor se conecta aos endpoints do TAB.",
|
||||
"90800c48a1dd0655": "{label} deve ser um objeto JSON",
|
||||
"919cb0ce0c8db4e7": "Deixe em branco para manter a senha atual",
|
||||
"91aaf184cfc17ffd": "Visão geral",
|
||||
"91af6e57e7453fbe": "Adicionar conta",
|
||||
"92156a483d4ba248": "Apenas o conteúdo detalhado de requisições, respostas e anexos de rastreamento são excluídos; resumos de chamadas, métricas e configurações são mantidos.",
|
||||
"92e26b27d5ea8f0e": "Falha ao verificar atualizações: {error}",
|
||||
"9305c0e13642cab4": "Não integrado",
|
||||
"940a168911ade998": "Itens por página",
|
||||
"945fb1c67eca8493": "Instalando o runtime de plugins",
|
||||
"946b3ffc02f026c0": "Tem certeza de que deseja excluir este modelo?",
|
||||
"954ec984cd4f49d1": "Sincronizando…",
|
||||
"966498853d801a52": "Conexão do TAB",
|
||||
"96f7642963ca0dbf": "Ativar integração com o Cursor",
|
||||
"9845c165151daee3": "Utilizado",
|
||||
"9850ed41a5bfbb0c": "{count} selecionados",
|
||||
"997ec8201c2adeda": "Abrir terminal para instalar CA",
|
||||
"99ac861433209a53": "Todas as solicitações externas devem fornecer esta chave quando a API estiver ativada.",
|
||||
"9a84733cc9ab1706": "Detalhes do recurso",
|
||||
"9b1b7ed518ee401d": "O tutorial de uso será aberto no navegador padrão. Deseja continuar?",
|
||||
"9b9bc9cd7c76406f": "Abrir página de autorização",
|
||||
"9c41b3a9e12ac994": "Esforço de raciocínio",
|
||||
"9db205c6055bacc4": "Falha na inicialização do runtime de plugins",
|
||||
"9e356080c56877f8": "Inicialização silenciosa desativada",
|
||||
"9e46da6923836182": "Ex.: 2026-08-23 09:00, há 1 hora",
|
||||
"9ebeab8c4532d671": "Contas de {name}",
|
||||
"9ec4caa5fe43b8e3": "Os plugins instalados aparecerão aqui.",
|
||||
"9ed11266ead88f5b": "Verificando o arquivo baixado do runtime de plugins",
|
||||
"9ef7da883941091c": "Conta salva, mas falha ao sincronizar modelos: {error}",
|
||||
"9f6fee1aba17a565": "Idioma",
|
||||
"9fb48101d237ff96": "Última semana",
|
||||
"a026f37e613cf48b": "Tokens de saída",
|
||||
"a03a1a0cb35414f8": " deve ser um número inteiro entre 0 e 65535",
|
||||
"a0ad0c340abf41c7": "Preço de leitura de cache",
|
||||
"a0c42c24e74f8380": "Cópia de {name}",
|
||||
"a12ee6a3e98a29c2": "Ocultar conteúdo sensível",
|
||||
"a1a42cd9b16e2162": "Configurações do aplicativo",
|
||||
"a1b8c98f29374a2f": "Inicialização silenciosa",
|
||||
"a3030bf8f16dc63c": "Salvar",
|
||||
"a340bdf12a15fd80": "Todos os {count} modelos nesta configuração já existem; nenhuma importação necessária",
|
||||
"a363743025795ec7": "Já inicializei, atualizar",
|
||||
"a3ab741ceb188e9e": "Conteúdo da requisição não gravado. Ative os registros detalhados e tente novamente.",
|
||||
"a49ffd73bc85333d": "Média",
|
||||
"a4d222236dc1003d": "Falha ao cancelar o teste: {error}",
|
||||
"a5fb6189a8ad011d": "Abrir tutorial",
|
||||
"a621ab606db2a11f": "Senha",
|
||||
"a66e11477dcc97c1": "Adicionar conta {name}",
|
||||
"a693d69af48bfe48": "Salvar e testar",
|
||||
"a748cc074f78de00": "Ver detalhes",
|
||||
"a7617f42f898b2bf": "Usar URL completa da requisição",
|
||||
"a8036485f9227f2c": "Arrastar para reordenar",
|
||||
"a80b53f8848e6d27": "Falha ao instalar atualização: {error}",
|
||||
"a98585871c5313ff": "Nome de exibição",
|
||||
"ab9084a640fbb864": "Desmarcar todos",
|
||||
"abecab6701177721": "Inicialização com o sistema ativada",
|
||||
"ac58d0f9a3f8d389": "Insira observações sobre o modelo",
|
||||
"ac69f68b7010ec79": "Baixar e instalar",
|
||||
"ad6a60ee93d3ba3e": "Carregando detalhes da chamada…",
|
||||
"ae2d0b7f79cea4a3": "Saída do modelo: {tokens} × ${price}/1M = {cost}",
|
||||
"aecb952b1e6cce36": "Oculta o ícone do Dock quando desativado. Você ainda pode abrir o aplicativo pelo ícone da barra de menus.",
|
||||
"aee88743413144a2": "Atualizar",
|
||||
"affb73206cfa035d": "Expira em: {time}",
|
||||
"b06325c5660f0c29": "Conexão direta",
|
||||
"b16c3b2ecedd6fe1": "A integração com o Cursor está ativa. Adicione uma configuração de modelo para usar modelos BYOK.",
|
||||
"b1968db970026087": "URL base",
|
||||
"b254ff315d861346": "Tente inicializar novamente",
|
||||
"b2617bf9ae663752": "Configurações do grupo",
|
||||
"b4411558b932266f": "Tipo de provedor",
|
||||
"b4c9e08870d41aa2": "É necessário inicializar o runtime de plugins primeiro",
|
||||
"b502b1d414664337": "Prompt: {tokens}",
|
||||
"b5141d3d19e9a048": "Sim",
|
||||
"b6725f218ebaef26": "Ícone do Dock exibido",
|
||||
"b67e408132f4dbd7": "Tem certeza de que deseja limpar todas as estatísticas?",
|
||||
"b75a46aad3e7c132": "Entrada não em cache: {tokens}",
|
||||
"b79354009c614ae9": "Estatísticas",
|
||||
"b86967982067d295": " (estimado)",
|
||||
"b89a0e4584f27ab5": "Abrir terminal",
|
||||
"b8c9b486c83b5778": "Não exibir anúncio",
|
||||
"b9670c85a4ab939e": "Rota",
|
||||
"b9af2de88d903be7": "Endereço do proxy",
|
||||
"ba2e93e73037c71e": "Restaurar padrão",
|
||||
"ba5865fbc734e672": "Por exemplo: Modelo principal",
|
||||
"ba6403d22876d626": "Em resfriamento",
|
||||
"baff6c144180b185": "Testes de conectividade concluídos: {successful} com sucesso, {failed} com falha",
|
||||
"bb2b7736433ae867": "Rastreamento do Cursor",
|
||||
"bb4e1ee4a6ae46df": "{calls} registros de chamadas · {traces} registros de rastreamento",
|
||||
"bb7efdcb6af6e805": "Escuro padrão",
|
||||
"bda62ce1d5e4ace9": "Conte-nos o motivo",
|
||||
"bda74b5674b6a57d": "Inicializar plugins",
|
||||
"be961dc60ab610da": "Quando alterado, aplica-se a todos os modelos deste grupo; deixe em branco para manter a configuração atual de cada modelo.",
|
||||
"bf57afd709694b55": "Intervalo de tempo da visão geral",
|
||||
"bfc01caf9fe0c841": "Taxa de acerto de cache {rate}",
|
||||
"c0b3fbff51ccc40b": "Concluído",
|
||||
"c1e98892a77f7a19": "{count} itens/página",
|
||||
"c3760858cdb6d9f4": "Corpo da requisição",
|
||||
"c54863655e879b36": "O runtime de plugins não é suportado neste sistema operacional",
|
||||
"c6e7e1a9da356efc": "Nenhum recurso ainda. Adicione um primeiro.",
|
||||
"c7ea2c9bc43134bd": "Editar modelo",
|
||||
"c8c14507b2d37395": "Intensidade de raciocínio",
|
||||
"c8df3c14a003bfcd": "Não foi possível carregar os detalhes da chamada",
|
||||
"c98e118e0a43f078": "Modelo",
|
||||
"c9b9ae7a61444ab7": "Página anterior",
|
||||
"c9d146d006993cc1": "Política de estatísticas de cache: padrão ({rate})",
|
||||
"cb2f1709f983d2f4": "Nome do modelo",
|
||||
"cb99f0138b032687": "A inicialização baixará e instalará o runtime de plugins.",
|
||||
"cea1aafe9416de7b": "Cabeçalhos da requisição",
|
||||
"cfae1a14d2120c57": "Modo detalhado",
|
||||
"cfe085015632e9c8": "Porta do serviço de gerenciamento local usada pelo frontend desktop. Digite 0 para escolher uma porta aleatória ao iniciar.",
|
||||
"d0bfccc77315d887": "Último mês",
|
||||
"d1251cd752d4ec25": "Deixe em branco para usar adaptive thinking.",
|
||||
"d1a3d72618d1ed27": "Todos os resumos de chamadas, conteúdos detalhados e registros de rastreamento serão excluídos. Configurações de modelo, CA e aplicativo não serão afetadas. Esta ação não pode ser desfeita.",
|
||||
"d2d648bd1c94b7f9": "Autenticação",
|
||||
"d2fcdde81f06645c": "Exportar em lote",
|
||||
"d34335433395cd3a": "Iniciar o Cursor BYOK automaticamente ao fazer login no sistema.",
|
||||
"d3716cc5a2f5a810": "Endereço do servidor",
|
||||
"d3d21191f32e79a5": "Processando…",
|
||||
"d507652243a2151e": "Mostrar conteúdo sensível",
|
||||
"d58c88688e1a949d": "Predefinições comuns",
|
||||
"d59e47070f7f358e": "Disponível para chamada",
|
||||
"d60669bb26a22f5d": "Deixe em branco para usar o padrão",
|
||||
"d6b1f203680f5496": "Deixe em branco para usar adaptive thinking",
|
||||
"d71b0171c44b668a": "Desativar removerá a configuração de proxy local do Cursor. Se você precisa fazer login em uma conta oficial, geralmente não é necessário desativar; basta fazer login diretamente, pois os modelos BYOK e da conta oficial agora funcionam juntos perfeitamente. Deseja continuar desativando e limpando o proxy?",
|
||||
"d766536c18e8e990": "O runtime de plugins {version} está instalado e pronto para uso.",
|
||||
"d7e266bdc8064193": "Nome do grupo",
|
||||
"d896c62fb6712bda": "Ativar {model}",
|
||||
"d8c47e9776cf1082": "Menu principal",
|
||||
"d8c589c455675b46": "Configurações de prompt salvas",
|
||||
"da521d1c1cbd36af": "É necessária autorização para instalar o certificado",
|
||||
"da7ae985487c38e6": "Última hora",
|
||||
"daede9881787abe7": "Observações",
|
||||
"db340a9896306d08": "Teste cancelado",
|
||||
"dbd3596e4a86f3c2": "Modelos configurados",
|
||||
"ddde16f8839da3ce": "Total de requisições",
|
||||
"de8184da1ef88d03": "Configurado",
|
||||
"de878020fe02f0e9": "O uso deste cartão o consumirá imediatamente e não poderá ser desfeito. Deseja continuar?",
|
||||
"dea7749c4cd77e6d": "O total de tokens de requisição inclui o prompt e a saída do modelo.",
|
||||
"df1baa9f706d970b": "A adicionar",
|
||||
"df3d58c7d84b85f2": "Configurações",
|
||||
"df8b71c74d9b8478": "Fluxo de resposta",
|
||||
"dfb802238b38fbd4": "Ativado",
|
||||
"e025f1ff71996425": "Definido",
|
||||
"e049096ab5614581": "Este plugin não precisa de recursos.",
|
||||
"e0fae77446a389a3": "Velocidade: {speed} tokens/s",
|
||||
"e1295adecbb77755": "Fechar anúncio",
|
||||
"e14115de7f7c5795": "Uso de tokens no último ano",
|
||||
"e14f20d572c02611": "Sequência de chamadas do provedor",
|
||||
"e17a5b9c90cda6ab": "Modelo duplicado",
|
||||
"e18516550b9a5105": "Sem uso",
|
||||
"e231f1f3428d1c93": "Solicitando código de autorização…",
|
||||
"e24096c81b1a8af4": "A autorização foi recusada ou falhou.",
|
||||
"e24ebe4a866d69bf": "Falha no teste: {error}",
|
||||
"e25bf3f419bb68f0": "Histórico de chamadas",
|
||||
"e2ebc59779f012ed": "Modelo de geração",
|
||||
"e3fee05f688708b4": "Chamadas LLM",
|
||||
"e4760c6a1df24f17": "URL completa da requisição",
|
||||
"e5043c7a2b408271": "Últimos 10 minutos",
|
||||
"e59ae97924d62f01": "Primeira página",
|
||||
"e5b9961a0d5242e3": "Configurações de porta salvas. Reinicie o aplicativo para que tenham efeito.",
|
||||
"e5c84c9aa7826566": "Não preparado",
|
||||
"e77e3d58b0dcffaa": "Duração",
|
||||
"e825a2a42c22380e": "Tipo de modelo",
|
||||
"e828bd3a0151edc2": "A CA local deve ser confiada pelo sistema",
|
||||
"e8b1268c1e3610f2": "Já existe",
|
||||
"e9d8d890d33584e8": "Nenhum cartão de redefinição disponível.",
|
||||
"ea26b760e930a7ca": "Observabilidade de chamadas",
|
||||
"eb11e2df1d8ae387": "URL do provedor",
|
||||
"eb4a3db23661fb52": "Aplica-se a todos os modelos deste grupo e é usado como rótulo de destaque no seletor de modelos do Cursor; limpe para voltar a exibir o domínio do servidor.",
|
||||
"eb77492c9f76a7e1": "O comando de instalação foi copiado automaticamente. Clique em \"Abrir terminal\", cole o comando no terminal e insira sua senha quando solicitado.",
|
||||
"eba54690937bc532": "Gerenciamento de contas",
|
||||
"ec917db99e814b58": "{label} deve ser um número não negativo",
|
||||
"ed31fbb483ee1b0a": "Ações",
|
||||
"edc70de18c6da1a6": "Instalar CA local",
|
||||
"ee239f3943293f87": "Domingo",
|
||||
"ee6b89a6a740a4c4": "Se uma porta estiver ocupada, uma nova porta aleatória será selecionada e salva automaticamente. Reinicie o aplicativo após alterar essas configurações.",
|
||||
"eec6bf2dad677b9f": "Concedido em: {time}",
|
||||
"ef5d9908c45f4b88": "Preço de leitura de cache ($/1M)",
|
||||
"f4694c46b1e19602": "Tipo final de requisição",
|
||||
"f4a0b686421619eb": "Preço de gravação em cache",
|
||||
"f4dcb6a3ceb32247": "Página {page} de {count}",
|
||||
"f4f0ead1116b5b62": "Ativado",
|
||||
"f4fa9f31ea2ae58d": "Calendário de uso de tokens do último ano",
|
||||
"f50276449943286c": "Horário de término",
|
||||
"f69273dbbebfb3a1": "Formatar",
|
||||
"f6dc1b1641600dd0": "Importando…",
|
||||
"f78265089144369a": "Plugins",
|
||||
"f78413c36d36f090": "Por padrão segue o sistema operacional; idiomas não suportados usam inglês. Atual: {language}",
|
||||
"f784b165bd3fcb0e": "Últimas 4 horas",
|
||||
"f85537d1fd2ef6f6": "Filtros personalizados da visão geral",
|
||||
"f95ea7f4c063eea7": "Desativado",
|
||||
"f9615d05d8e18595": "Limpar filtro de {label}",
|
||||
"f9aa11dbb15ce647": "Sábado",
|
||||
"f9b55ca75425161b": "Conteúdo da resposta não gravado. Ative os registros detalhados e tente novamente.",
|
||||
"fa5b4b8a751c7d1b": "Porta do proxy local usada pelo Cursor; digite 0 para selecionar aleatoriamente ao iniciar.",
|
||||
"fac2a67ad87807c4": "Confirmar",
|
||||
"fad86bf65f72c747": "Progresso do download",
|
||||
"fb11aa6f29827095": "Verificando…",
|
||||
"fbe8778fa8b9bab5": "É necessário inicializar a CA local primeiro",
|
||||
"fc22d1ab9ac73c6f": "Verificando o runtime de plugins",
|
||||
"fc3947ebe6b2177b": "Padrão {defaultRate} / inclui criação {reuseRate}",
|
||||
"fc50d0b72bc871db": "Desativar integração",
|
||||
"fcd311fd8ad42462": "Abrir lista de modelos",
|
||||
"fd415f8e0097c832": "Leituras e gravações de cache já estão incluídas nas estatísticas do prompt.",
|
||||
"fd77192739703811": "Importação em lote",
|
||||
"fdc4cabc370fa3f7": "Nenhuma opção",
|
||||
"fe6ec799e02ed9a7": "Preços de tokens",
|
||||
"fea405f9b01d1416": "Visão geral",
|
||||
"fec45092945f8790": "Tutorial de uso",
|
||||
"fec7210590309465": "Limpar todas as estatísticas",
|
||||
"ff509c9ba052a21c": "Salvando…",
|
||||
"ff66b1c7e010bbc2": "Cole o comando de autorização no terminal e insira sua senha; ao concluir, clique no botão abaixo",
|
||||
"ff673195f05fb056": "Porta do serviço"
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"028c60a8a8e30a1b": "第 {page} / {total} 页",
|
||||
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "编辑",
|
||||
"0525054436acdb6e": "提交信息提示词",
|
||||
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
|
||||
"0580e0a99a6f1afc": "工件数",
|
||||
"06619f339fa0ab46": "正在准备插件运行时",
|
||||
@@ -26,11 +27,14 @@
|
||||
"0e41f8e3d59ec47b": "存储管理",
|
||||
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
|
||||
"0ec1e85b0c3cfa65": "调用详情",
|
||||
"0ecfbbe0697af7ea": "输入价格($/1M)",
|
||||
"100fad4a0b3ab781": "近24小时",
|
||||
"105a9082c346f958": "测试中…",
|
||||
"12430375c0db4727": "按配置的 Token 价格估算。",
|
||||
"124be3f86f197802": "Token 消耗",
|
||||
"12ae77e6202d063e": "自定义 Headers",
|
||||
"12b86e89701b0241": "输入模型名称后按 Enter 添加",
|
||||
"133340e53175128a": "一键测试",
|
||||
"13a494f82d7f26df": "开启外部 API",
|
||||
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
|
||||
"13b61c5f697b6700": "缓存命中率",
|
||||
"146da2e2a991493e": "获取中…",
|
||||
@@ -40,6 +44,7 @@
|
||||
"1813d362a82fd437": "最大化窗口",
|
||||
"18165f8865eacc91": "还没有安装插件",
|
||||
"19658d9fa9aa8de4": "安装中…",
|
||||
"19bb071f8c362695": "提示词设置",
|
||||
"1a3f0617d6de8e52": "用户名",
|
||||
"1a60c9eb3cf1dbb5": "导入完成:新增 {added},更新 {updated}",
|
||||
"1aa65c55c6cc6163": "设备验证码",
|
||||
@@ -49,11 +54,13 @@
|
||||
"1c6926877b1bbfb2": "总请求:{tokens}",
|
||||
"1cb7e646ac883fb2": "代理端口",
|
||||
"1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)",
|
||||
"1d19efc260a0a18f": "使用重置卡",
|
||||
"1d27f02ed278ebc9": "配置文件",
|
||||
"1f9b46e61efcccb5": "筛选项:{label},{summary}",
|
||||
"202064bb84804852": "留空时使用默认值。",
|
||||
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
|
||||
"217cfe7db1e3d10a": "跟随系统",
|
||||
"21bd738e0d7191a7": "正在加载…",
|
||||
"22c6b4eb4caee6ae": "代理设置已保存",
|
||||
"22d7895ea5fca72e": "按供应商",
|
||||
"23ae7a90b1b9816d": "清理范围",
|
||||
@@ -62,11 +69,13 @@
|
||||
"24a0a24864454575": "已存在,跳过",
|
||||
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
|
||||
"29585d7193539200": "当前版本 {version}",
|
||||
"296da37506328d89": "关闭接管Cursor",
|
||||
"29fbbef32a6eb58b": "不再显示此广告",
|
||||
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cbc58108d78b06c": "等待网页端确认授权中…",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2cdf7d3275016cc2": "关闭接管Cursor?",
|
||||
"2d30c2a98ebb5278": "当前:{rate}",
|
||||
"2eb2bf7c6597ab9a": "详细记录",
|
||||
"2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。",
|
||||
@@ -75,10 +84,11 @@
|
||||
"2f5f1d6fbfb061ed": "未设置",
|
||||
"2f6416a2c424856b": "最终请求地址",
|
||||
"2f7ba5fd1d12f7f9": "打开使用教程?",
|
||||
"2f7dec3be28d7597": "已选择 {count} 个",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"2fe0e3339ac4d1bb": "已过期",
|
||||
"2fe5a8d0eee9f14c": "已失效",
|
||||
"303c30f301514250": "搜索资源",
|
||||
"3260348163d03b8e": "留空保持不变",
|
||||
"32896fdaaaa4c106": "账号已保存,模型目录已同步。",
|
||||
"346ff60e6c7c5181": "读取中…",
|
||||
"36f33adaf0942634": "确认",
|
||||
@@ -88,12 +98,12 @@
|
||||
"382f2e3419a02fef": "仅清理详细记录",
|
||||
"38844b135cf70dfc": "更多",
|
||||
"393e1241552b1870": "请求",
|
||||
"398f8e6c6f0a0b97": "继续选择或输入",
|
||||
"39f52eee100131d7": "缓存输入",
|
||||
"3a0fb74abe2460b6": "分块数",
|
||||
"3a3f595df70ec8ff": "清理存储空间",
|
||||
"3a5040b68abf75f9": "全选",
|
||||
"3a8c76b2ce785f96": "查看并导入",
|
||||
"3b63f1f9d5922e65": "缓存写入价格($/1M)",
|
||||
"3b67824289b5fa1e": "已隐藏 Dock 栏图标",
|
||||
"3c94b4c75940c178": "输入 Token",
|
||||
"3cfae5728b92b334": "Token 用量:{tokens}",
|
||||
@@ -111,6 +121,7 @@
|
||||
"43cb41d62de2d179": "代理需要认证",
|
||||
"461d6a57900c2ed7": "连通性测试失败:{error}",
|
||||
"470049252e54de6a": "成功占比:{rate}",
|
||||
"4791868cb0a4be4d": "输出价格",
|
||||
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
|
||||
"48a3bf87eb254591": "开始登录",
|
||||
"48b970b568a7f8f9": "代理设置",
|
||||
@@ -131,33 +142,43 @@
|
||||
"51d04bc3d286f018": "近1自然日",
|
||||
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
|
||||
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
|
||||
"5319e7374f78fb38": "已接管",
|
||||
"5401344227e49e2f": "TAB 设置",
|
||||
"54644705e9c61009": "端口设置",
|
||||
"54c53e5fe791d1f3": "初始化 CA",
|
||||
"54d735fcd15e8c93": "输出价格($/1M)",
|
||||
"54e6745ff43c9c74": "排序失败",
|
||||
"550eddc3c7fefa99": "推广",
|
||||
"552a5d4baf45d878": "Commit 提交代码模型设置",
|
||||
"56432ba297009bdc": "请先初始化 CA",
|
||||
"565678b3704a383d": "定价设置已保存",
|
||||
"56627c94a9decee6": "最大输出 Token",
|
||||
"576d81bb0631b165": "导入",
|
||||
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
|
||||
"59346e82b3dd2998": "TAB 服务地址",
|
||||
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
|
||||
"5a3bd99fa69a40c1": "使用公益服务",
|
||||
"5acb7d61688e3c4f": "确认使用",
|
||||
"5b17f59d33bde39e": "错误:{error}",
|
||||
"5ba65a74c4e792c5": "按类型",
|
||||
"5c55a67935af8f45": "全部",
|
||||
"5c62e36c152dfc7c": "插件运行时初始化完成",
|
||||
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
||||
"5f600b307b4eb0fb": "API 密钥",
|
||||
"5f8d556a9c47da3c": "已关闭开机启动",
|
||||
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
||||
"5fd2ec5a6e9b654c": "合计:{cost}",
|
||||
"6003d3246f0fca2b": "模型管理",
|
||||
"6078a681a306930d": "缓存写入",
|
||||
"609640f72d422b57": "快捷时间范围",
|
||||
"60e7671141df2731": "输入价格",
|
||||
"61a4c7bac12dc125": "异常调用:{count}",
|
||||
"61c7d1f758b647d5": "调用信息",
|
||||
"621f63a5f08384ac": "缓存读取:{tokens}",
|
||||
"6320b4a8722a851f": "状态",
|
||||
"63c73c4730f4473e": "应用",
|
||||
"63d90d977348ab1f": "复制",
|
||||
"6449a43900b609a4": "{name} 模型管理",
|
||||
"6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板",
|
||||
"651f274470153a05": "软件更新",
|
||||
"652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}",
|
||||
@@ -165,6 +186,7 @@
|
||||
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
|
||||
"65a6318e07ec1e07": "工具数",
|
||||
"65cb9a7b4f620b6b": "提示词 {tokens}",
|
||||
"66f7ceff962da68c": "用于首页价值估算的 Token 单价,单位:美元 / 百万 Token。",
|
||||
"680680288a6d2ad2": "在 Dock 栏显示",
|
||||
"68102220092c1f0f": "详细记录已清理",
|
||||
"68ad603fafe4e0d6": "导入旧版配置",
|
||||
@@ -185,10 +207,12 @@
|
||||
"7a3cec4ca715de80": "调用统计",
|
||||
"7ba2d6728fe2531b": "确认清理",
|
||||
"7c10d97162c96dbd": "正在验证插件运行时",
|
||||
"7cc1cf151d967bda": "允许本机应用使用密钥调用已配置的模型,包括插件模型。",
|
||||
"7cea2f3c46565d29": "OpenAI 额外参数",
|
||||
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
|
||||
"7e0891860c9e6374": "TAB 服务地址不能为空",
|
||||
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
||||
"7ed9f96f513813a4": "外部 API 设置已保存",
|
||||
"7f3c8312816fe26a": "刷新中…",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
||||
@@ -200,6 +224,8 @@
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"842b9f11cdd96bda": "开机启动",
|
||||
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
||||
"844b8cc8dff7c1d8": "默认",
|
||||
"857d282a0213d785": "外部 API",
|
||||
"864597982c308d72": "已开启静默启动",
|
||||
"86de7c4ee8fa7689": "同步模型",
|
||||
"8716e1344b0daddb": "Cursor 官方",
|
||||
@@ -220,13 +246,19 @@
|
||||
"91aaf184cfc17ffd": "数据概览",
|
||||
"91af6e57e7453fbe": "添加账号",
|
||||
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||
"92e26b27d5ea8f0e": "检查更新失败:{error}",
|
||||
"9305c0e13642cab4": "未接管",
|
||||
"940a168911ade998": "每页条数",
|
||||
"945fb1c67eca8493": "正在安装插件运行时",
|
||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||
"954ec984cd4f49d1": "正在同步…",
|
||||
"966498853d801a52": "TAB 选择",
|
||||
"96f7642963ca0dbf": "开启接管Cursor",
|
||||
"9845c165151daee3": "已使用",
|
||||
"9850ed41a5bfbb0c": "已选 {count} 项",
|
||||
"997ec8201c2adeda": "打开终端安装 CA",
|
||||
"99ac861433209a53": "开启后,所有外部请求都必须提供此密钥。",
|
||||
"9a84733cc9ab1706": "资源详情",
|
||||
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
||||
"9b9bc9cd7c76406f": "打开授权网页",
|
||||
"9c41b3a9e12ac994": "思考强度",
|
||||
@@ -241,6 +273,7 @@
|
||||
"9fb48101d237ff96": "近一周",
|
||||
"a026f37e613cf48b": "输出 Token",
|
||||
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
|
||||
"a0ad0c340abf41c7": "缓存读取价格",
|
||||
"a0c42c24e74f8380": "{name} 副本",
|
||||
"a12ee6a3e98a29c2": "隐藏敏感内容",
|
||||
"a1a42cd9b16e2162": "应用设置",
|
||||
@@ -258,6 +291,7 @@
|
||||
"a748cc074f78de00": "查看详情",
|
||||
"a7617f42f898b2bf": "使用完整请求地址",
|
||||
"a8036485f9227f2c": "拖动排序",
|
||||
"a80b53f8848e6d27": "安装更新失败:{error}",
|
||||
"a98585871c5313ff": "显示名称",
|
||||
"ab9084a640fbb864": "全不选",
|
||||
"abecab6701177721": "已开启开机启动",
|
||||
@@ -267,9 +301,12 @@
|
||||
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
|
||||
"aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。",
|
||||
"aee88743413144a2": "刷新",
|
||||
"affb73206cfa035d": "到期时间:{time}",
|
||||
"b06325c5660f0c29": "直连",
|
||||
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
|
||||
"b1968db970026087": "基础地址",
|
||||
"b254ff315d861346": "请重试初始化",
|
||||
"b2617bf9ae663752": "分组设置",
|
||||
"b4411558b932266f": "上游类型",
|
||||
"b4c9e08870d41aa2": "需要先初始化插件运行时",
|
||||
"b502b1d414664337": "提示词:{tokens}",
|
||||
@@ -283,13 +320,16 @@
|
||||
"b8c9b486c83b5778": "不再显示广告",
|
||||
"b9670c85a4ab939e": "路由",
|
||||
"b9af2de88d903be7": "代理地址",
|
||||
"ba2e93e73037c71e": "恢复默认",
|
||||
"ba5865fbc734e672": "例如:主力模型",
|
||||
"ba6403d22876d626": "冷却中",
|
||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||
"bb2b7736433ae867": "Cursor 追踪",
|
||||
"bb4e1ee4a6ae46df": "调用记录 {calls} 条 · 追踪记录 {traces} 条",
|
||||
"bb7efdcb6af6e805": "默认暗色",
|
||||
"bda62ce1d5e4ace9": "可以告诉我们原因",
|
||||
"bda74b5674b6a57d": "初始化插件",
|
||||
"be961dc60ab610da": "修改后应用于该分组下的全部模型;留空保持各模型现有配置不变。",
|
||||
"bf57afd709694b55": "概览时间范围",
|
||||
"bfc01caf9fe0c841": "缓存命中率 {rate}",
|
||||
"c0b3fbff51ccc40b": "完成",
|
||||
@@ -321,9 +361,12 @@
|
||||
"d59e47070f7f358e": "可调用",
|
||||
"d60669bb26a22f5d": "留空使用默认值",
|
||||
"d6b1f203680f5496": "留空使用 adaptive thinking",
|
||||
"d71b0171c44b668a": "关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?",
|
||||
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
|
||||
"d86fa42c3848c680": "使用系统代理",
|
||||
"d7e266bdc8064193": "分组名称",
|
||||
"d896c62fb6712bda": "启用 {model}",
|
||||
"d8c47e9776cf1082": "主菜单",
|
||||
"d8c589c455675b46": "提示词设置已保存",
|
||||
"da521d1c1cbd36af": "需要授权安装证书",
|
||||
"da7ae985487c38e6": "近1小时",
|
||||
"daede9881787abe7": "备注",
|
||||
@@ -331,6 +374,7 @@
|
||||
"dbd3596e4a86f3c2": "配置模型",
|
||||
"ddde16f8839da3ce": "总请求",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
"de878020fe02f0e9": "使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?",
|
||||
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
|
||||
"df1baa9f706d970b": "将新增",
|
||||
"df3d58c7d84b85f2": "设置",
|
||||
@@ -348,6 +392,7 @@
|
||||
"e24096c81b1a8af4": "授权被拒绝或已失败。",
|
||||
"e24ebe4a866d69bf": "测试失败:{error}",
|
||||
"e25bf3f419bb68f0": "调用详细",
|
||||
"e2ebc59779f012ed": "生成模型",
|
||||
"e3fee05f688708b4": "LLM 调用",
|
||||
"e4760c6a1df24f17": "完整请求 URL",
|
||||
"e5043c7a2b408271": "近10分钟",
|
||||
@@ -358,38 +403,48 @@
|
||||
"e825a2a42c22380e": "模型类型",
|
||||
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
||||
"e8b1268c1e3610f2": "已存在",
|
||||
"e9d8d890d33584e8": "没有可用的重置卡。",
|
||||
"ea26b760e930a7ca": "调用观测",
|
||||
"eb11e2df1d8ae387": "上游地址",
|
||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||
"eb4a3db23661fb52": "应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。",
|
||||
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
|
||||
"eba54690937bc532": "账号管理",
|
||||
"ec917db99e814b58": "{label}必须是非负数",
|
||||
"ed31fbb483ee1b0a": "操作",
|
||||
"edc70de18c6da1a6": "安装本地 CA",
|
||||
"ee239f3943293f87": "周日",
|
||||
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||
"eec6bf2dad677b9f": "发放时间:{time}",
|
||||
"ef5d9908c45f4b88": "缓存读取价格($/1M)",
|
||||
"f4694c46b1e19602": "最终请求类型",
|
||||
"f4a0b686421619eb": "缓存写入价格",
|
||||
"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": "暂无选项",
|
||||
"fe6ec799e02ed9a7": "Token 定价",
|
||||
"fea405f9b01d1416": "概览",
|
||||
"fec45092945f8790": "使用教程",
|
||||
"fec7210590309465": "清理全部统计数据",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import zhCN from "./locales/zh-CN.json";
|
||||
import enUS from "./locales/en-US.json";
|
||||
import ptBR from "./locales/pt-BR.json";
|
||||
|
||||
export type Locale = "zh-CN" | "en-US" | "pt-BR";
|
||||
export type CommitPromptLocale = "zh-CN" | "en-US";
|
||||
|
||||
export function commitPromptLocale(locale: Locale): CommitPromptLocale {
|
||||
return locale === "zh-CN" ? "zh-CN" : "en-US";
|
||||
}
|
||||
|
||||
export type Locale = "zh-CN" | "en-US";
|
||||
export type TranslationValue = string | number;
|
||||
export type TranslationParams = Readonly<Record<string, TranslationValue>>;
|
||||
|
||||
@@ -9,6 +16,7 @@ const sourceMessages = zhCN as Record<string, string>;
|
||||
const localeMessages: Record<Locale, Record<string, string>> = {
|
||||
"zh-CN": sourceMessages,
|
||||
"en-US": enUS as Record<string, string>,
|
||||
"pt-BR": ptBR as Record<string, string>,
|
||||
};
|
||||
|
||||
let currentMessages: Record<string, string> = {};
|
||||
|
||||
@@ -14,7 +14,7 @@ let initialized = false;
|
||||
let snapshot: I18nSnapshot = { preference: "system", locale: "en-US" };
|
||||
|
||||
function isLocale(value: string | null): value is Locale {
|
||||
return value === "zh-CN" || value === "en-US";
|
||||
return value === "zh-CN" || value === "en-US" || value === "pt-BR";
|
||||
}
|
||||
|
||||
export function resolveSystemLocale(
|
||||
@@ -24,6 +24,7 @@ export function resolveSystemLocale(
|
||||
): Locale {
|
||||
const normalized = languages[0]?.toLowerCase() ?? "";
|
||||
if (normalized === "zh" || normalized.startsWith("zh-")) return "zh-CN";
|
||||
if (normalized === "pt" || normalized.startsWith("pt-")) return "pt-BR";
|
||||
if (normalized === "en" || normalized.startsWith("en-")) return "en-US";
|
||||
return "en-US";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AdRuntime } from "../shell/ads/types";
|
||||
import type { Locale } from "../i18n/runtime";
|
||||
import type { CommitPromptLocale, 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -110,15 +113,19 @@ export interface PortSettings {
|
||||
service_port: number;
|
||||
}
|
||||
|
||||
export interface ExternalApiSettings {
|
||||
enabled: boolean;
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
export interface StatisticsStorage {
|
||||
bytes: number;
|
||||
call_count: number;
|
||||
trace_count: number;
|
||||
}
|
||||
|
||||
export type StatisticsStorageScope = "details" | "all";
|
||||
|
||||
export type ProxyMode = "system" | "custom";
|
||||
export type ProxyMode = "default" | "custom";
|
||||
|
||||
export interface ProxySettings {
|
||||
mode: ProxyMode;
|
||||
@@ -148,6 +155,23 @@ export interface DesktopSettings {
|
||||
show_dock_icon: boolean;
|
||||
}
|
||||
|
||||
export interface CommitSettings {
|
||||
model_id: string;
|
||||
prompt: string;
|
||||
prompt_locale: CommitPromptLocale;
|
||||
}
|
||||
|
||||
export interface CommitSettingsView extends CommitSettings {
|
||||
default_prompt: string;
|
||||
}
|
||||
|
||||
export interface TokenPricingSettings {
|
||||
input_per_million: number;
|
||||
output_per_million: number;
|
||||
cache_read_per_million: number;
|
||||
cache_write_per_million: number;
|
||||
}
|
||||
|
||||
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
|
||||
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
|
||||
|
||||
@@ -200,10 +224,11 @@ export interface PluginResourceView {
|
||||
}
|
||||
|
||||
export interface PluginAddMethod {
|
||||
type: "oauth2.0";
|
||||
type: "oauth2.0" | "oauth2.authorization-code";
|
||||
id: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
callback?: { port: number | null; path: string | null };
|
||||
}
|
||||
|
||||
export interface PluginImportDescriptor {
|
||||
@@ -213,6 +238,35 @@ export interface PluginImportDescriptor {
|
||||
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;
|
||||
@@ -220,6 +274,7 @@ export interface PluginResourceDescriptor {
|
||||
import: PluginImportDescriptor | null;
|
||||
canRefresh: boolean;
|
||||
canRemove: boolean;
|
||||
actions: PluginResourceAction[];
|
||||
resources: PluginResourceView[];
|
||||
}
|
||||
|
||||
@@ -233,10 +288,9 @@ export interface PluginModelDescriptor {
|
||||
description: string | null;
|
||||
icon: string;
|
||||
providerType: string;
|
||||
contextWindowTokens: number | null;
|
||||
maxOutputTokens: number | null;
|
||||
thinking: boolean;
|
||||
images: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PluginProviderDescriptor {
|
||||
@@ -263,7 +317,7 @@ export interface PluginDescriptor {
|
||||
|
||||
export interface PluginOAuthBegin {
|
||||
sessionId: string;
|
||||
userCode: string;
|
||||
userCode: string | null;
|
||||
verificationUrl: string;
|
||||
verificationUrlComplete: string | null;
|
||||
expiresAtMs: number;
|
||||
@@ -288,20 +342,9 @@ export interface PluginImportResult {
|
||||
modelSyncError: string | null;
|
||||
}
|
||||
|
||||
export type ConfiguredModel =
|
||||
| { kind: "builtin"; id: string; name: string; builtin: Model }
|
||||
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
|
||||
|
||||
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
||||
return plugins.flatMap((plugin) =>
|
||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
|
||||
}
|
||||
|
||||
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
|
||||
return [
|
||||
...models.map((model): ConfiguredModel => ({ kind: "builtin", id: model.model_hash, name: model.display_name, builtin: model })),
|
||||
...configuredPluginModels(plugins).map((model): ConfiguredModel => ({ kind: "plugin", id: model.id, name: model.displayName, plugin: model })),
|
||||
];
|
||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models.filter((model) => model.enabled) : []));
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
@@ -435,14 +478,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 }) }),
|
||||
@@ -453,12 +496,13 @@ export const api = {
|
||||
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
|
||||
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}` : ""}`);
|
||||
@@ -470,8 +514,10 @@ export const api = {
|
||||
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"),
|
||||
@@ -500,6 +546,8 @@ export const api = {
|
||||
setObservability: (detailed: boolean) => request<{ detailed: boolean }>("/settings/observability", { method: "PUT", body: JSON.stringify({ detailed }) }),
|
||||
ports: () => request<PortSettings>("/settings/ports"),
|
||||
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
externalApiSettings: () => request<ExternalApiSettings>("/settings/external-api"),
|
||||
setExternalApiSettings: (settings: ExternalApiSettings) => request<ExternalApiSettings>("/settings/external-api", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
|
||||
clearStatisticsStorage: (scope: StatisticsStorageScope) => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE", body: JSON.stringify({ scope }) }),
|
||||
proxySettings: () => request<ProxySettings>("/settings/proxy"),
|
||||
@@ -508,4 +556,8 @@ export const api = {
|
||||
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) }),
|
||||
pricingSettings: () => request<TokenPricingSettings>("/settings/pricing"),
|
||||
setPricingSettings: (settings: TokenPricingSettings) => request<TokenPricingSettings>("/settings/pricing", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getVersion, setDockVisibility } from "@tauri-apps/api/app";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
@@ -46,11 +46,39 @@ export async function writeDockIconVisibility(visible: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdate(): Promise<Update | null> {
|
||||
return check();
|
||||
export type AppUpdate = {
|
||||
version: string;
|
||||
install(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type PortableUpdateInfo = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
export async function checkForUpdate(): Promise<AppUpdate | null> {
|
||||
if (desktopPlatform() === "windows") {
|
||||
const update = await invoke<PortableUpdateInfo | null>("check_portable_update");
|
||||
if (!update) return null;
|
||||
return {
|
||||
version: update.version,
|
||||
install: () => invoke("install_portable_update", { expectedVersion: update.version }),
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const update: Update | null = await check();
|
||||
if (!update) return null;
|
||||
return {
|
||||
version: update.version,
|
||||
install: async () => {
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
},
|
||||
close: () => update.close(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installUpdate(update: Update): Promise<void> {
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
export async function installUpdate(update: AppUpdate): Promise<void> {
|
||||
await update.install();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings, type TokenPricingSettings } from "../api";
|
||||
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
||||
|
||||
export const DEFAULT_TOKEN_PRICING: TokenPricingSettings = {
|
||||
input_per_million: 5.0,
|
||||
output_per_million: 25.0,
|
||||
cache_read_per_million: 0.5,
|
||||
cache_write_per_million: 6.25,
|
||||
};
|
||||
|
||||
export type AppSnapshot = {
|
||||
models: Model[];
|
||||
calls: LlmCall[];
|
||||
overview: Overview;
|
||||
detailed: boolean;
|
||||
ports: PortSettings;
|
||||
pricing: TokenPricingSettings;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
theme: ThemeId;
|
||||
@@ -42,6 +50,7 @@ let snapshot: AppSnapshot = {
|
||||
},
|
||||
detailed: false,
|
||||
ports: { proxy_port: 0, service_port: 0 },
|
||||
pricing: DEFAULT_TOKEN_PRICING,
|
||||
busy: false,
|
||||
error: null,
|
||||
theme: savedTheme(),
|
||||
@@ -77,17 +86,18 @@ export const appStore = {
|
||||
async refresh() {
|
||||
update({ busy: true, error: null });
|
||||
try {
|
||||
const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime, plugins] = await Promise.all([
|
||||
const [models, calls, overview, settings, ports, pricing, cursorHarness, pluginRuntime, plugins] = await Promise.all([
|
||||
api.models(),
|
||||
api.calls(),
|
||||
api.overview(),
|
||||
api.observability(),
|
||||
api.ports(),
|
||||
api.pricingSettings(),
|
||||
api.cursorHarness(),
|
||||
api.pluginRuntime(),
|
||||
api.plugins(),
|
||||
]);
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime, plugins });
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, pricing, cursorHarness, pluginRuntime, plugins });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
} finally {
|
||||
@@ -256,6 +266,16 @@ export const appStore = {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async updatePricingSettings(pricing: TokenPricingSettings) {
|
||||
try {
|
||||
update({ error: null });
|
||||
update({ pricing: await api.setPricingSettings(pricing) });
|
||||
return true;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
selectTheme(theme: ThemeId) {
|
||||
localStorage.setItem("cursor-byok.theme", theme);
|
||||
applyTheme(theme);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import {
|
||||
checkForUpdate,
|
||||
hasNativeAppLifecycle,
|
||||
installUpdate,
|
||||
type AppUpdate,
|
||||
} from "../native/appLifecycle";
|
||||
|
||||
export type UpdateSnapshot = {
|
||||
@@ -17,7 +17,7 @@ let snapshot: UpdateSnapshot = {
|
||||
checking: false,
|
||||
installing: false,
|
||||
};
|
||||
let availableUpdate: Update | null = null;
|
||||
let availableUpdate: AppUpdate | null = null;
|
||||
let pendingCheck: Promise<string | null> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -26,7 +26,7 @@ function update(patch: Partial<UpdateSnapshot>) {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
async function replaceAvailableUpdate(next: Update | null) {
|
||||
async function replaceAvailableUpdate(next: AppUpdate | null) {
|
||||
const previous = availableUpdate;
|
||||
availableUpdate = next;
|
||||
update({ availableVersion: next?.version ?? null });
|
||||
|
||||
@@ -11,18 +11,22 @@
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
button,
|
||||
.textItem {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
display: block;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
@@ -34,6 +38,11 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.textItem {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.openIcon {
|
||||
|
||||
@@ -6,12 +6,19 @@ import { Icon } from "./Icon";
|
||||
import { chevronDownIcon } from "./icons";
|
||||
import styles from "./ActionMenu.module.scss";
|
||||
|
||||
export type ActionMenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
export type ActionMenuItem =
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: "action";
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "text";
|
||||
};
|
||||
|
||||
/** 触发器 + 动作列表的下拉菜单,用于容纳卡片上的次要操作。 */
|
||||
export function ActionMenu({ label, items, disabled }: {
|
||||
@@ -80,18 +87,24 @@ export function ActionMenu({ label, items, disabled }: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{items.map((item) => <button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>)}
|
||||
{items.map((item) => item.type === "text" ? (
|
||||
<span key={item.id} className={styles.textItem}>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.compact {
|
||||
width: min(560px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.fullHeight {
|
||||
height: calc(100vh - var(--window-header-height) - 24px);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type ModalProps = {
|
||||
banner?: ReactNode;
|
||||
busy?: boolean;
|
||||
wide?: boolean;
|
||||
compact?: boolean;
|
||||
fullHeight?: boolean;
|
||||
role?: "dialog" | "alertdialog";
|
||||
ariaDescribedBy?: string;
|
||||
@@ -38,7 +39,7 @@ function focusableElements(root: HTMLElement) {
|
||||
.filter((element) => element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存"), submitDisabled = false }: ModalProps) {
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, compact, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存"), submitDisabled = false }: ModalProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const submitButton = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
@@ -89,7 +90,7 @@ export function Modal({ id, open, title, children, banner, busy, wide, fullHeigh
|
||||
if (!open) return null;
|
||||
return createPortal(<div className={styles.mask}>
|
||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide, fullHeight && styles.fullHeight].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
|
||||
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide, compact && styles.compact, fullHeight && styles.fullHeight].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
|
||||
<header id={titleId}>{title}</header>
|
||||
{banner && <div className={styles.banner}>{banner}</div>}
|
||||
<ScrollableContent alwaysShowVertical className={styles.body} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
|
||||
|
||||
+105
-30
@@ -12,6 +12,7 @@
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] { border-color: var(--vscode-focusBorder); }
|
||||
&[data-disabled] { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.trigger {
|
||||
@@ -64,35 +65,6 @@
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"]:hover {
|
||||
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-activeSelectionBackground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listFrame,
|
||||
@@ -106,6 +78,101 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group,
|
||||
.groupLabel {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group {
|
||||
gap: 8px;
|
||||
padding: 7px 8px 3px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:has(input:focus-visible) .checkbox {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
padding: 7px 8px 3px;
|
||||
}
|
||||
|
||||
.optionRow {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px 5px 32px;
|
||||
box-sizing: border-box;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--vscode-checkbox-foreground, var(--vscode-button-foreground));
|
||||
background: var(--vscode-checkbox-background, var(--vscode-input-background));
|
||||
border: 1px solid var(--vscode-checkbox-border, var(--vscode-input-border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.optionRow:has(input:checked) .checkbox,
|
||||
.group:has(input:checked) .checkbox,
|
||||
.checkbox[data-indeterminate] {
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.checkbox[data-indeterminate]::after {
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -119,7 +186,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.check { width: 1.1em; flex: 0 0 1.1em; }
|
||||
.empty { padding: 8px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
|
||||
|
||||
.footer {
|
||||
@@ -133,7 +199,16 @@
|
||||
button {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover { background: var(--vscode-list-hoverBackground); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, 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 "./ModelSelect.module.scss";
|
||||
|
||||
export type ModelSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
group: string;
|
||||
icon?: IconProps["icon"];
|
||||
iconSrc?: string;
|
||||
};
|
||||
|
||||
type ModelSelectProps = {
|
||||
label: string;
|
||||
options: ModelSelectOption[];
|
||||
disabled?: boolean;
|
||||
} & (
|
||||
| { mode: "single"; value: string; onChange: (value: string) => void }
|
||||
| { mode: "multiple"; value: string[]; onChange: (value: string[]) => void }
|
||||
);
|
||||
|
||||
type MenuItem =
|
||||
| { kind: "group"; key: string; label: string; options: ModelSelectOption[] }
|
||||
| { kind: "option"; key: string; option: ModelSelectOption };
|
||||
|
||||
function groupedItems(options: ModelSelectOption[]): MenuItem[] {
|
||||
const groups = new Map<string, ModelSelectOption[]>();
|
||||
for (const option of options) {
|
||||
const group = groups.get(option.group);
|
||||
if (group) group.push(option);
|
||||
else groups.set(option.group, [option]);
|
||||
}
|
||||
return [...groups].flatMap(([group, groupOptions]) => [
|
||||
{ kind: "group" as const, key: `group:${group}`, label: group, options: groupOptions },
|
||||
...groupOptions.map((option) => ({ kind: "option" as const, key: `option:${option.value}`, option })),
|
||||
]);
|
||||
}
|
||||
|
||||
function GroupCheckbox({ label, options, selected, onChange }: {
|
||||
label: string;
|
||||
options: ModelSelectOption[];
|
||||
selected: Set<string>;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const selectedCount = options.filter((option) => selected.has(option.value)).length;
|
||||
const checked = selectedCount === options.length;
|
||||
const indeterminate = selectedCount > 0 && !checked;
|
||||
|
||||
useEffect(() => {
|
||||
if (input.current) input.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
|
||||
return <label className={styles.group}>
|
||||
<input
|
||||
ref={input}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
aria-checked={indeterminate ? "mixed" : checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className={styles.checkbox} data-indeterminate={indeterminate || undefined} aria-hidden="true">
|
||||
{checked && <Icon icon={checkIcon} size="0.875em" />}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</label>;
|
||||
}
|
||||
|
||||
export function ModelSelect(props: ModelSelectProps) {
|
||||
const { label, options, disabled, mode } = props;
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const firstOption = useRef<HTMLLabelElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
|
||||
const items = useMemo(() => groupedItems(options), [options]);
|
||||
const selected = useMemo(
|
||||
() => new Set(mode === "multiple" ? props.value : [props.value]),
|
||||
[mode, props.value],
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
};
|
||||
|
||||
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)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [open]);
|
||||
|
||||
const summary = mode === "multiple"
|
||||
? props.value.length === 0 ? t("全部") : t("已选 {count} 项", { count: props.value.length })
|
||||
: options.find((option) => option.value === props.value)?.label ?? props.value;
|
||||
|
||||
const choose = (option: ModelSelectOption) => {
|
||||
if (mode === "single") {
|
||||
props.onChange(option.value);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
props.onChange(selected.has(option.value)
|
||||
? props.value.filter((item) => item !== option.value)
|
||||
: [...props.value, option.value]);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupOptions: ModelSelectOption[], checked: boolean) => {
|
||||
if (mode !== "multiple") return;
|
||||
const groupValues = new Set(groupOptions.map((option) => option.value));
|
||||
props.onChange(checked
|
||||
? [...props.value, ...groupOptions.filter((option) => !selected.has(option.value)).map((option) => option.value)]
|
||||
: props.value.filter((value) => !groupValues.has(value)));
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled) return;
|
||||
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} data-disabled={disabled || undefined}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
disabled={disabled}
|
||||
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>
|
||||
{mode === "multiple" && props.value.length > 0 && <button
|
||||
type="button"
|
||||
className={styles.clear}
|
||||
aria-label={t("清除{label}筛选", { label })}
|
||||
onClick={() => props.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={mode === "multiple" ? "true" : undefined}
|
||||
style={{ height: Math.min(items.length * 32, Math.max(32, position.maxHeight - (mode === "multiple" ? 45 : 0))) }}
|
||||
><VirtualList
|
||||
items={items}
|
||||
itemKey="key"
|
||||
className={styles.list}
|
||||
style={{ height: "100%" }}
|
||||
estimatedItemHeight={32}
|
||||
overscan={4}
|
||||
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
|
||||
>{(item, index) => item.kind === "group"
|
||||
? mode === "multiple"
|
||||
? <GroupCheckbox
|
||||
label={item.label}
|
||||
options={item.options}
|
||||
selected={selected}
|
||||
onChange={(checked) => toggleGroup(item.options, checked)}
|
||||
/>
|
||||
: <div className={styles.groupLabel}>{item.label}</div>
|
||||
: <label
|
||||
ref={index === 1 ? firstOption : undefined}
|
||||
className={styles.optionRow}
|
||||
role="option"
|
||||
aria-selected={selected.has(item.option.value)}
|
||||
tabIndex={index === 1 ? 0 : -1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
choose(item.option);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.option.value)}
|
||||
tabIndex={-1}
|
||||
onChange={() => choose(item.option)}
|
||||
/>
|
||||
<span className={styles.checkbox} aria-hidden="true">
|
||||
{selected.has(item.option.value) && <Icon icon={checkIcon} size="0.875em" />}
|
||||
</span>
|
||||
<span className={styles.option}>
|
||||
{(item.option.icon || item.option.iconSrc) && <Icon icon={item.option.icon} src={item.option.iconSrc} />}
|
||||
<span>{item.option.label}</span>
|
||||
</span>
|
||||
</label>}</VirtualList></div>
|
||||
{mode === "multiple" && options.length > 0 && <div className={styles.footer}>
|
||||
<button type="button" onClick={() => props.onChange(options.map((option) => option.value))}>{t("全选")}</button>
|
||||
<button type="button" onClick={() => props.onChange([])}>{t("全不选")}</button>
|
||||
</div>}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -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)}
|
||||
</>;
|
||||
}
|
||||
@@ -62,55 +62,6 @@
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiCombo {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiValues {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
|
||||
> input {
|
||||
min-width: 120px;
|
||||
min-height: 24px;
|
||||
flex: 1;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.multiCount {
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.combo input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -172,23 +123,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu .multiOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import type { VirtualListApi } from "../virtual/virtualTypes";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon } from "./icons";
|
||||
import { chevronDownIcon } from "./icons";
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string };
|
||||
@@ -57,19 +57,44 @@ export function Select({ value, options, disabled, ariaLabel, onChange }: { valu
|
||||
</>;
|
||||
}
|
||||
|
||||
export function Combobox({ value, options = [], placeholder, disabled, append, onChange }: { value: string; options?: string[]; placeholder?: string; disabled?: boolean; append?: ReactNode; onChange: (value: string) => void }) {
|
||||
export type ComboboxHandle = {
|
||||
openAll: () => void;
|
||||
};
|
||||
|
||||
type ComboboxProps = {
|
||||
value: string;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
append?: ReactNode;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox({ value, options = [], placeholder, disabled, append, onChange }, ref) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const closeTimer = useRef<number | null>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filtering, setFiltering] = useState(true);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const filtered = useMemo(() => {
|
||||
if (!filtering) return options;
|
||||
const query = value.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !query || option.toLocaleLowerCase().includes(query));
|
||||
}, [options, value]);
|
||||
}, [filtering, options, value]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openAll: () => {
|
||||
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
|
||||
setFiltering(false);
|
||||
setActive(0);
|
||||
setOpen(true);
|
||||
},
|
||||
}), []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
@@ -79,6 +104,9 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open, filtered.length]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); };
|
||||
@@ -91,8 +119,11 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled || options.length === 0) return;
|
||||
setFiltering(true);
|
||||
setOpen(true);
|
||||
const selected = filtered.indexOf(value);
|
||||
const query = value.trim().toLocaleLowerCase();
|
||||
const matchingOptions = options.filter((option) => !query || option.toLocaleLowerCase().includes(query));
|
||||
const selected = matchingOptions.indexOf(value);
|
||||
setActive(selected >= 0 ? selected : 0);
|
||||
};
|
||||
const choose = (option: string) => { onChange(option); setOpen(false); input.current?.focus(); };
|
||||
@@ -102,7 +133,7 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
setActive((index) => (index < 0 ? 0 : (index + step + filtered.length) % filtered.length));
|
||||
};
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.combo}>
|
||||
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) openMenu(); }} onBlur={() => window.setTimeout(() => setOpen(false), 100)} onChange={(event) => { onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) openMenu(); }} onBlur={() => { closeTimer.current = window.setTimeout(() => setOpen(false), 100); }} onChange={(event) => { setFiltering(true); onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); choose(filtered[active]); }
|
||||
@@ -115,88 +146,5 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export function MultiCombobox({ value, options = [], placeholder, disabled, append, onChange }: {
|
||||
value: string[];
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
append?: ReactNode;
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !normalized || option.toLocaleLowerCase().includes(normalized));
|
||||
}, [options, query]);
|
||||
|
||||
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(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [filtered.length, open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
|
||||
const add = (modelId: string) => {
|
||||
const normalized = modelId.trim();
|
||||
if (normalized && !selected.has(normalized)) onChange([...value, normalized]);
|
||||
setQuery("");
|
||||
};
|
||||
const toggle = (modelId: string) => {
|
||||
onChange(selected.has(modelId) ? value.filter((item) => item !== modelId) : [...value, modelId]);
|
||||
setQuery("");
|
||||
input.current?.focus();
|
||||
};
|
||||
const move = (step: number) => {
|
||||
if (!filtered.length) return;
|
||||
setOpen(true);
|
||||
setActive((index) => (index + step + filtered.length) % filtered.length);
|
||||
};
|
||||
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
|
||||
<div className={styles.multiValues}>
|
||||
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
|
||||
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onBlur={() => add(query)} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const normalized = query.trim();
|
||||
const exact = filtered.find((option) => option.toLocaleLowerCase() === normalized.toLocaleLowerCase());
|
||||
if (normalized && !exact) add(normalized);
|
||||
else if (open && filtered[active]) toggle(filtered[active]);
|
||||
}
|
||||
if (event.key === "Backspace" && !query && value.length) onChange(value.slice(0, -1));
|
||||
if (event.key === "Escape") { setOpen(false); input.current?.focus(); }
|
||||
}} />
|
||||
</div>
|
||||
<button type="button" className={styles.comboToggle} disabled={disabled || !options.length} aria-label={t("打开模型列表")} aria-expanded={open} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => { setOpen(!open); input.current?.focus(); }}><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" aria-multiselectable="true" style={{ left: position.left, top: position.top, width: position.width }} onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); input.current?.focus(); } }}>
|
||||
{filtered.length ? <VirtualList items={filtered} itemKey={(option) => option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" className={styles.multiOption} role="option" aria-selected={selected.has(option)} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => toggle(option)}><span>{option}</span>{selected.has(option) && <Icon icon={checkIcon} size="1.1em" />}</button>}
|
||||
</VirtualList> : <div className={styles.empty}>{t("输入模型名称后按 Enter 添加")}</div>}
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import styles from "./Switch.module.scss";
|
||||
|
||||
export function Switch({ checked, disabled, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
type SwitchProps = {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children" | "onChange" | "role">;
|
||||
|
||||
export function Switch({ checked, disabled, label, onChange, onClick, ...props }: SwitchProps) {
|
||||
return <button
|
||||
{...props}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className={styles.root}
|
||||
className={[styles.root, props.className].filter(Boolean).join(" ")}
|
||||
data-checked={checked || undefined}
|
||||
onClick={() => onChange(!checked)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) onChange(!checked);
|
||||
}}
|
||||
><span /></button>;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
position: fixed;
|
||||
z-index: 15000;
|
||||
max-width: 420px;
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
|
||||
background: var(--vscode-editorHoverWidget-background);
|
||||
@@ -13,4 +15,24 @@
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.25)) transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.25));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--vscode-scrollbarSlider-hoverBackground, rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,92 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, type VirtualElement } from "@floating-ui/dom";
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size, type VirtualElement } from "@floating-ui/dom";
|
||||
import { createContext, useContext, useLayoutEffect, useMemo, 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 }) {
|
||||
type TooltipContextValue = {
|
||||
show: (anchor: TooltipAnchor, id: string | undefined, content: ReactNode) => void;
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
const TooltipContext = createContext<TooltipContextValue | null>(null);
|
||||
|
||||
export function useTooltip() {
|
||||
const context = useContext(TooltipContext);
|
||||
if (!context) throw new Error("useTooltip must be used within TooltipProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
export function TooltipProvider({ children }: { children: ReactNode }) {
|
||||
const [active, setActive] = useState<{ anchor: TooltipAnchor; id?: string; content: ReactNode } | null>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
const clearHide = () => {
|
||||
if (hideTimer.current !== null) {
|
||||
window.clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
};
|
||||
const context = useMemo(() => ({
|
||||
show: (anchor: TooltipAnchor, id: string | undefined, content: ReactNode) => {
|
||||
clearHide();
|
||||
setActive({ anchor, id, content });
|
||||
},
|
||||
hide: () => {
|
||||
clearHide();
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
setActive(null);
|
||||
hideTimer.current = null;
|
||||
}, 150);
|
||||
},
|
||||
}), []);
|
||||
|
||||
return <TooltipContext.Provider value={context}>
|
||||
{children}
|
||||
<Tooltip
|
||||
id={active?.id}
|
||||
anchor={active?.anchor ?? null}
|
||||
onPointerEnter={clearHide}
|
||||
onPointerLeave={context.hide}
|
||||
>{active?.content}</Tooltip>
|
||||
</TooltipContext.Provider>;
|
||||
}
|
||||
|
||||
export function Tooltip({ id, anchor, children, onPointerEnter, onPointerLeave }: {
|
||||
id?: string;
|
||||
anchor: TooltipAnchor | null;
|
||||
children: ReactNode;
|
||||
onPointerEnter?: () => void;
|
||||
onPointerLeave?: () => void;
|
||||
}) {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
const [availableHeight, setAvailableHeight] = useState<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!anchor || !tooltip) {
|
||||
setPosition(null);
|
||||
setAvailableHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition(null);
|
||||
setAvailableHeight(null);
|
||||
const updatePosition = () => {
|
||||
void computePosition(anchor, tooltip, {
|
||||
placement: "top",
|
||||
middleware: [offset(8), flip({ padding: 12 }), shift({ padding: 12 })],
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ padding: 12 }),
|
||||
shift({ padding: 12 }),
|
||||
size({
|
||||
padding: 12,
|
||||
apply({ availableHeight }) {
|
||||
setAvailableHeight((current) => current === availableHeight ? current : availableHeight);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => setPosition({ left: x, top: y }));
|
||||
};
|
||||
|
||||
@@ -35,7 +102,14 @@ export function Tooltip({ id, anchor, children }: { id?: string; anchor: Tooltip
|
||||
id={id}
|
||||
className={styles.root}
|
||||
role="tooltip"
|
||||
style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }}
|
||||
style={{
|
||||
left: position?.left ?? 0,
|
||||
top: position?.top ?? 0,
|
||||
maxHeight: availableHeight ? `${availableHeight}px` : undefined,
|
||||
visibility: position ? "visible" : "hidden",
|
||||
}}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cloneElement, useEffect, useId, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react";
|
||||
import { Tooltip, type TooltipAnchor } from "./Tooltip";
|
||||
import { cloneElement, useCallback, useEffect, useId, useRef, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react";
|
||||
import { useTooltip, type TooltipAnchor } from "./Tooltip";
|
||||
|
||||
|
||||
function anchorFor(element: HTMLElement): TooltipAnchor {
|
||||
return { contextElement: element, getBoundingClientRect: () => element.getBoundingClientRect() };
|
||||
@@ -15,43 +16,71 @@ type TriggerProps = {
|
||||
};
|
||||
|
||||
export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) {
|
||||
const { show: showTooltip, hide: hideTooltip } = useTooltip();
|
||||
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
|
||||
const tooltipId = useId();
|
||||
const timerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const hide = useCallback(() => {
|
||||
clearTimer();
|
||||
setAnchor(null);
|
||||
hideTooltip();
|
||||
}, [clearTimer, hideTooltip]);
|
||||
const scheduleHide = useCallback(() => {
|
||||
clearTimer();
|
||||
setAnchor(null);
|
||||
hideTooltip();
|
||||
}, [clearTimer, hideTooltip]);
|
||||
|
||||
useEffect(() => () => clearTimer(), [clearTimer]);
|
||||
useEffect(() => {
|
||||
if (!anchor) return;
|
||||
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setAnchor(null); };
|
||||
const close = (event: KeyboardEvent) => { if (event.key === "Escape") hide(); };
|
||||
document.addEventListener("keydown", close);
|
||||
return () => document.removeEventListener("keydown", close);
|
||||
}, [anchor]);
|
||||
}, [anchor, hide]);
|
||||
|
||||
const trigger = cloneElement(children, {
|
||||
"aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined,
|
||||
onPointerMove: (event) => {
|
||||
children.props.onPointerMove?.(event);
|
||||
if (event.pointerType !== "touch" && event.buttons === 0) {
|
||||
const element = event.currentTarget;
|
||||
setAnchor((current) => current ?? anchorFor(element));
|
||||
if (event.pointerType !== "touch" && event.buttons === 0 && !anchor) {
|
||||
const nextAnchor = anchorFor(event.currentTarget);
|
||||
setAnchor(nextAnchor);
|
||||
showTooltip(nextAnchor, tooltipId, label);
|
||||
}
|
||||
},
|
||||
onPointerLeave: (event) => {
|
||||
children.props.onPointerLeave?.(event);
|
||||
setAnchor(null);
|
||||
scheduleHide();
|
||||
},
|
||||
onPointerDown: (event) => {
|
||||
children.props.onPointerDown?.(event);
|
||||
setAnchor(null);
|
||||
hide();
|
||||
},
|
||||
onFocus: (event) => {
|
||||
children.props.onFocus?.(event);
|
||||
setAnchor(event.currentTarget.matches(":focus-visible") ? anchorFor(event.currentTarget) : null);
|
||||
clearTimer();
|
||||
if (event.currentTarget.matches(":focus-visible")) {
|
||||
const nextAnchor = anchorFor(event.currentTarget);
|
||||
setAnchor(nextAnchor);
|
||||
showTooltip(nextAnchor, tooltipId, label);
|
||||
} else {
|
||||
setAnchor(null);
|
||||
}
|
||||
},
|
||||
onBlur: (event) => {
|
||||
children.props.onBlur?.(event);
|
||||
setAnchor(null);
|
||||
scheduleHide();
|
||||
},
|
||||
});
|
||||
|
||||
return <>
|
||||
{trigger}
|
||||
<Tooltip id={tooltipId} anchor={anchor}>{label}</Tooltip>
|
||||
</>;
|
||||
return trigger;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Model } from "../api";
|
||||
|
||||
export function modelProviderName(model: Pick<Model, "group_name" | "base_url">): string {
|
||||
const configuredName = model.group_name?.trim();
|
||||
if (configuredName) return configuredName;
|
||||
|
||||
const value = model.base_url.trim();
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${value}`).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menuStatusTag {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
padding: 1px 5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-descriptionForeground) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 28%, transparent);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
line-height: 1.4;
|
||||
|
||||
&[data-taken] {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.menuIndicatorDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
@@ -151,7 +169,11 @@
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-app-region: drag;
|
||||
// Page actions (e.g. the overview time-range bar and the floating refresh
|
||||
// button) must not act as a window drag handle; the dedicated drag layer in
|
||||
// AppHeader owns window dragging. Otherwise these controls get dragged and
|
||||
// double-click maximizes the window.
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -6,6 +6,7 @@ import cursorIconUrl from "../shared/assets/icons/cursor.svg";
|
||||
import { api } from "../shared/api";
|
||||
import { AdMenu } from "./ads/AdMenu";
|
||||
import { FloatingAd } from "./ads/FloatingAd";
|
||||
import { loadCachedAds, saveCachedAds } from "./ads/cache";
|
||||
import { AdActionType, type AdAction, type AdSlot } from "./ads/types";
|
||||
import { PageLayout } from "./layout/PageLayout";
|
||||
import { Card } from "../shared/ui/Card";
|
||||
@@ -43,14 +44,14 @@ function loadStoredAdIds(key: string): Set<string> {
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const { busy } = useAppStore();
|
||||
const { busy, cursorHarness } = useAppStore();
|
||||
const { availableVersion } = useUpdateStore();
|
||||
const { locale } = useI18n();
|
||||
const message = useMessage();
|
||||
const location = useLocation();
|
||||
const [leftActionTarget, setLeftActionTarget] = useState<HTMLDivElement | null>(null);
|
||||
const [rightActionTarget, setRightActionTarget] = useState<HTMLDivElement | null>(null);
|
||||
const [ads, setAds] = useState<AdSlot[]>([]);
|
||||
const [ads, setAds] = useState<AdSlot[]>(() => loadCachedAds());
|
||||
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
|
||||
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
|
||||
const [dismissReason, setDismissReason] = useState("");
|
||||
@@ -98,6 +99,8 @@ export function AppLayout() {
|
||||
let disposed = false;
|
||||
let pending = false;
|
||||
let lastRequestedAt = 0;
|
||||
setAds(loadCachedAds());
|
||||
setActiveAd(null);
|
||||
const refreshAds = () => {
|
||||
const now = Date.now();
|
||||
if (pending || now - lastRequestedAt < 10_000) return;
|
||||
@@ -105,6 +108,7 @@ export function AppLayout() {
|
||||
lastRequestedAt = now;
|
||||
void api.ads(dismissedAdIdsRef.current, locale)
|
||||
.then((runtime) => {
|
||||
saveCachedAds(runtime.slots);
|
||||
if (disposed) return;
|
||||
setAds(runtime.slots);
|
||||
setActiveAd((current) => current
|
||||
@@ -112,9 +116,7 @@ export function AppLayout() {
|
||||
: null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
setAds([]);
|
||||
setActiveAd(null);
|
||||
// Keep the current ads and local cache available when refreshing fails.
|
||||
})
|
||||
.finally(() => { pending = false; });
|
||||
};
|
||||
@@ -215,6 +217,12 @@ export function AppLayout() {
|
||||
? <Icon src={item.icon} size="1.3em" />
|
||||
: <Icon icon={item.icon} size="1.3em" />}
|
||||
<span>{item.label}</span>
|
||||
{item.path === "/harness/cursor" && cursorHarness && <span
|
||||
className={styles.menuStatusTag}
|
||||
data-taken={cursorHarness.settings_applied || undefined}
|
||||
>
|
||||
{cursorHarness.settings_applied ? t("已接管") : t("未接管")}
|
||||
</span>}
|
||||
{item.path === "/settings" && availableVersion && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
|
||||
</NavLink>
|
||||
</div>}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
import { Marquee } from "react-css-marquee";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { windowCloseIcon } from "../../shared/ui/icons";
|
||||
import type { AdSlot } from "./types";
|
||||
@@ -14,6 +15,49 @@ type AdMenuProps = {
|
||||
onDismiss: (ad: AdSlot) => void;
|
||||
};
|
||||
|
||||
type OverflowTextProps = {
|
||||
children: string;
|
||||
className: string;
|
||||
};
|
||||
|
||||
function OverflowText({ children, className }: OverflowTextProps) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const text = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewport = container.current;
|
||||
if (!viewport) return;
|
||||
|
||||
const update = () => {
|
||||
const element = text.current;
|
||||
if (!element) return;
|
||||
setOverflowing(element.scrollWidth > viewport.clientWidth + 1);
|
||||
};
|
||||
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(viewport);
|
||||
if (text.current) observer.observe(text.current);
|
||||
let disposed = false;
|
||||
void document.fonts?.ready.then(() => {
|
||||
if (!disposed) update();
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [children, overflowing]);
|
||||
|
||||
return <div ref={container} className={styles.menuOverflow}>
|
||||
{overflowing
|
||||
? <Marquee className={styles.menuMarquee} repeat loop={0} speed={12} gap={24}>
|
||||
<span ref={text} className={className}>{children}</span>
|
||||
</Marquee>
|
||||
: <span ref={text} className={className}>{children}</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
|
||||
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
|
||||
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
|
||||
@@ -33,10 +77,10 @@ export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs
|
||||
>
|
||||
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
|
||||
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
|
||||
<span className={styles.menuCopy}>
|
||||
<span className={styles.menuTitle}>{ad.target.title}</span>
|
||||
<span className={styles.menuSubtitle}>{ad.target.description}</span>
|
||||
</span>
|
||||
<div className={styles.menuCopy}>
|
||||
<OverflowText className={styles.menuTitle}>{ad.target.title}</OverflowText>
|
||||
<OverflowText className={styles.menuSubtitle}>{ad.target.description}</OverflowText>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -24,19 +24,14 @@
|
||||
padding: 0 32px 0 8px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 4%, transparent);
|
||||
border: 2px solid #be8d180d;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 9%, transparent);
|
||||
border-color: #be8d180d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +42,7 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
|
||||
@@ -66,6 +61,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menuSlotContainer:hover .menuDismiss {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.unreadDot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
@@ -85,7 +84,9 @@
|
||||
}
|
||||
|
||||
.menuCopy {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
@@ -93,11 +94,43 @@
|
||||
|
||||
.menuTitle,
|
||||
.menuSubtitle {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menuOverflow {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menuMarquee {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menuMarquee .menuTitle,
|
||||
.menuMarquee .menuSubtitle {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.menuMarquee [data-rcm-t] {
|
||||
animation-play-state: running !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menuTitle {
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 500;
|
||||
@@ -121,9 +154,9 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
background: color-mix(in srgb, var(--vscode-editorWidget-background) 92%, var(--vscode-foreground) 8%);
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
|
||||
animation: ad-enter 180ms ease-out;
|
||||
|
||||
@@ -132,6 +165,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
:global(:root[data-theme="default-light"]) .floatingAd {
|
||||
background: var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex: 0 0 112px;
|
||||
position: relative;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AdSlot } from "./types";
|
||||
|
||||
const adCacheStorageKey = "cursor-byok:ad-runtime";
|
||||
|
||||
export function loadCachedAds(): AdSlot[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(adCacheStorageKey) ?? "null");
|
||||
if (!value || typeof value !== "object" || !Array.isArray((value as { slots?: unknown }).slots)) return [];
|
||||
return (value as { slots: AdSlot[] }).slots;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedAds(slots: AdSlot[]): void {
|
||||
try {
|
||||
localStorage.setItem(adCacheStorageKey, JSON.stringify({ slots }));
|
||||
} catch {
|
||||
// The current session still uses the fetched ads when storage is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
# deps
|
||||
/node_modules
|
||||
|
||||
# generated desktop product demo
|
||||
/public/product-demo
|
||||
|
||||
# generated content
|
||||
.source
|
||||
|
||||
# test & build
|
||||
/coverage
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
*.tsbuildinfo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.pnp
|
||||
.pnp.js
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# others
|
||||
.env*.local
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
|
||||
# cloudflare / opennext
|
||||
/.open-next/
|
||||
/.wrangler/
|
||||
cloudflare-env.d.ts
|
||||
@@ -1,9 +0,0 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -1,48 +0,0 @@
|
||||
# cursor-byok 文档站
|
||||
|
||||
基于 [Fumadocs](https://fumadocs.dev) 和 Next.js 的独立内容应用。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
apps/docs/
|
||||
├── app/
|
||||
│ ├── (home)/ # Hero 首页与开发者博客
|
||||
│ └── docs/ # 用户文档布局和页面
|
||||
├── components/ # MDX 组件
|
||||
├── content/
|
||||
│ ├── docs/ # 用户文档
|
||||
│ └── blog/ # 开发者博客
|
||||
└── lib/ # 内容源、站点信息与布局配置
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
先安装依赖:
|
||||
|
||||
```bash
|
||||
npm --prefix apps/docs install
|
||||
```
|
||||
|
||||
从仓库根目录启动:
|
||||
|
||||
```bash
|
||||
make dev-docs
|
||||
```
|
||||
|
||||
浏览器打开 <http://localhost:3000>。
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 在 `content/docs` 中维护面向使用者的 `.mdx` 文档。
|
||||
- 在 `content/docs/meta.json` 中维护文档侧边栏标题与页面顺序。
|
||||
- 在 `content/blog` 中维护开发者文章,文件名以 `YYYY-MM-DD-` 开头用于排序和展示日期。
|
||||
- 在 `lib/shared.ts` 中维护站点名称、仓库和发布地址。
|
||||
|
||||
## 检查与构建
|
||||
|
||||
```bash
|
||||
npm --prefix apps/docs run check
|
||||
```
|
||||
|
||||
生产构建输出由 Next.js 管理。部署环境可通过 `NEXT_PUBLIC_SITE_URL` 设置站点公开地址;未设置时使用 `https://docs.leokun.cn`。
|
||||
@@ -1,79 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
||||
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import { blogSource, formatBlogDate } from '@/lib/blog';
|
||||
import { i18n, isLanguage, type Language } from '@/lib/i18n';
|
||||
|
||||
const copy: Record<Language, { back: string; team: string }> = {
|
||||
zh: { back: '返回开发者博客', team: 'Cursor Byok 开发团队' },
|
||||
en: { back: 'Back to Developer Blog', team: 'The Cursor Byok team' },
|
||||
};
|
||||
|
||||
export default async function BlogPostPage(props: PageProps<'/[lang]/blog/[slug]'>) {
|
||||
const { lang, slug } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
const page = blogSource.getPage([slug], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const t = copy[lang];
|
||||
const prefix = `/${lang}`;
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl px-6 py-12 sm:py-20">
|
||||
<article>
|
||||
<Link
|
||||
href={`${prefix}/blog`}
|
||||
className="mb-10 inline-flex items-center gap-2 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
{t.back}
|
||||
</Link>
|
||||
|
||||
<header className="border-b pb-8">
|
||||
<time className="text-sm text-fd-muted-foreground">
|
||||
{formatBlogDate(page.path, lang)}
|
||||
</time>
|
||||
<h1 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">{page.data.title}</h1>
|
||||
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{page.data.description}</p>
|
||||
<p className="mt-5 text-sm font-medium">{t.team}</p>
|
||||
</header>
|
||||
|
||||
<div className="prose mt-10 min-w-0">
|
||||
<InlineTOC items={page.data.toc} />
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
a: createRelativeLink(blogSource, page),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.flatMap((lang) =>
|
||||
blogSource.getPages(lang).map((page) => ({ lang, slug: page.slugs[0] })),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<'/[lang]/blog/[slug]'>,
|
||||
): Promise<Metadata> {
|
||||
const { lang, slug } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
const page = blogSource.getPage([slug], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
|
||||
import { isLanguage, type Language } from '@/lib/i18n';
|
||||
|
||||
const copy: Record<Language, { title: string; description: string; intro: string; empty: string }> = {
|
||||
zh: {
|
||||
title: '开发者博客',
|
||||
description: 'Cursor Byok 的架构决策、协议实现与开发进展。',
|
||||
intro: '记录 Cursor Byok 的架构决策、协议实现和开发进展。',
|
||||
empty: '文章正在准备中。',
|
||||
},
|
||||
en: {
|
||||
title: 'Developer Blog',
|
||||
description: 'Architecture decisions, protocol work, and development progress of Cursor Byok.',
|
||||
intro: 'Notes on architecture decisions, protocol work, and development progress of Cursor Byok.',
|
||||
empty: 'Articles are being prepared.',
|
||||
},
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: PageProps<'/[lang]/blog'>): Promise<Metadata> {
|
||||
const { lang } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
return {
|
||||
title: copy[lang].title,
|
||||
description: copy[lang].description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPage(props: PageProps<'/[lang]/blog'>) {
|
||||
const { lang } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
const t = copy[lang];
|
||||
const posts = sortBlogPages(blogSource.getPages(lang));
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-16 sm:py-24">
|
||||
<div className="max-w-2xl">
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
|
||||
<h1 className="mt-4 text-4xl font-bold tracking-tight">{t.title}</h1>
|
||||
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{t.intro}</p>
|
||||
</div>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<div className="mt-12 divide-y border-y">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
className="group grid gap-3 py-7 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
|
||||
>
|
||||
<time className="text-sm text-fd-muted-foreground">
|
||||
{formatBlogDate(post.path, lang)}
|
||||
</time>
|
||||
<div>
|
||||
<h2 className="font-semibold">{post.data.title}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-fd-muted-foreground">
|
||||
{post.data.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-12 border-y py-8 text-fd-muted-foreground">{t.empty}</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { isLanguage, i18n } from '@/lib/i18n';
|
||||
|
||||
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
|
||||
const { lang } = await params;
|
||||
const base = baseOptions(isLanguage(lang) ? lang : i18n.defaultLanguage);
|
||||
|
||||
return (
|
||||
<HomeLayout
|
||||
{...base}
|
||||
nav={{ ...base.nav, transparentMode: 'always' }}
|
||||
className="home-fullbleed-nav"
|
||||
>
|
||||
{children}
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Settings2,
|
||||
Star,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { DesktopDemo } from '@/components/hero/DesktopDemo';
|
||||
import { DownloadButton } from '@/components/hero/DownloadButton';
|
||||
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
|
||||
import { formatStars, getRepoStats } from '@/lib/github';
|
||||
import { isLanguage, type Language } from '@/lib/i18n';
|
||||
import { releaseUrl, repositoryUrl } from '@/lib/shared';
|
||||
|
||||
const copy: Record<
|
||||
Language,
|
||||
{
|
||||
pillReleased: (version: string) => string;
|
||||
pillFallback: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
readDocs: string;
|
||||
flow: [string, string, string];
|
||||
docsEyebrow: string;
|
||||
docsTitle: string;
|
||||
viewAll: string;
|
||||
docs: { icon: typeof BookOpen; title: string; description: string; href: string }[];
|
||||
blogEyebrow: string;
|
||||
blogTitle: string;
|
||||
}
|
||||
> = {
|
||||
zh: {
|
||||
pillReleased: (version) => `${version} 已发布 · 开源 · 本地运行`,
|
||||
pillFallback: '开源 · 本地运行 · 自由接入',
|
||||
title: 'Cursor 服务端的开源替代',
|
||||
subtitle:
|
||||
'在本机运行自己的模型网关,用自己的 API Key 接入 OpenAI、Anthropic 兼容服务或自定义端点,完整保留 Cursor Agent 的工具调用、Skills 和 MCP。',
|
||||
readDocs: '阅读文档',
|
||||
flow: ['Cursor 客户端', 'Cursor Byok 本地服务', '你的模型 API(OpenAI / Anthropic 兼容)'],
|
||||
docsEyebrow: 'DOCUMENTATION',
|
||||
docsTitle: '文档',
|
||||
viewAll: '查看全部',
|
||||
docs: [
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: '快速开始',
|
||||
description: '完成安装、初始化和第一次模型调用。',
|
||||
href: '/docs',
|
||||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
title: '模型配置',
|
||||
description: '配置协议、服务地址、凭据和生成参数。',
|
||||
href: '/docs/model-configuration',
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: '常见问题',
|
||||
description: '回答常见问题。',
|
||||
href: '/docs/faq',
|
||||
},
|
||||
],
|
||||
blogEyebrow: 'DEVELOPER BLOG',
|
||||
blogTitle: '开发者博客',
|
||||
},
|
||||
en: {
|
||||
pillReleased: (version) => `${version} released · Open source · Runs locally`,
|
||||
pillFallback: 'Open source · Runs locally · Any provider',
|
||||
title: "The open-source alternative to Cursor's backend",
|
||||
subtitle:
|
||||
'Run your own model gateway locally, connect OpenAI- and Anthropic-compatible services or custom endpoints with your own API keys, and keep Cursor Agent tool calling, Skills, and MCP intact.',
|
||||
readDocs: 'Read the docs',
|
||||
flow: ['Cursor client', 'Cursor Byok local service', 'Your model API (OpenAI / Anthropic compatible)'],
|
||||
docsEyebrow: 'DOCUMENTATION',
|
||||
docsTitle: 'Documentation',
|
||||
viewAll: 'View all',
|
||||
docs: [
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'Quick Start',
|
||||
description: 'Install, initialize, and make your first model call.',
|
||||
href: '/docs',
|
||||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
title: 'Model Configuration',
|
||||
description: 'Configure protocols, server addresses, credentials, and parameters.',
|
||||
href: '/docs/model-configuration',
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: 'Frequently Asked Questions',
|
||||
description: 'Answer common questions.',
|
||||
href: '/docs/faq',
|
||||
},
|
||||
],
|
||||
blogEyebrow: 'DEVELOPER BLOG',
|
||||
blogTitle: 'Developer Blog',
|
||||
},
|
||||
};
|
||||
|
||||
export default async function HomePage(props: PageProps<'/[lang]'>) {
|
||||
const { lang } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
const t = copy[lang];
|
||||
const prefix = `/${lang}`;
|
||||
const posts = sortBlogPages(blogSource.getPages(lang)).slice(0, 3);
|
||||
const { stars, version } = await getRepoStats();
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col">
|
||||
<section className="relative -mt-14 overflow-hidden border-b px-4 pb-16 pt-34 sm:px-6 sm:pb-24 sm:pt-42">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-fd-border)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-fd-border)_1px,transparent_1px)] bg-[size:56px_56px] opacity-40 [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,#000_20%,transparent_75%)]" />
|
||||
<div className="absolute left-1/2 top-[-14rem] h-[26rem] w-[44rem] -translate-x-1/2 rounded-full bg-fd-primary/10 blur-3xl" />
|
||||
</div>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<a
|
||||
href={releaseUrl}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-fd-card px-4 py-1.5 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||
>
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-fd-primary opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-fd-primary" />
|
||||
</span>
|
||||
{version ? t.pillReleased(version) : t.pillFallback}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</a>
|
||||
<h1 className="mt-6 text-4xl font-bold tracking-tight sm:text-6xl">{t.title}</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
|
||||
{t.subtitle}
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<DownloadButton lang={lang} />
|
||||
<Link
|
||||
href={`${prefix}/docs`}
|
||||
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
{t.readDocs}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
<a
|
||||
href={repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<GitHubIcon className="size-4" />
|
||||
GitHub
|
||||
{stars !== null ? (
|
||||
<span className="flex items-center gap-1 border-l pl-2.5 text-sm text-fd-muted-foreground">
|
||||
<Star className="size-3.5" />
|
||||
{formatStars(stars)}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopDemo lang={lang} />
|
||||
<div className="mx-auto mt-10 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 font-mono text-xs text-fd-muted-foreground sm:text-sm">
|
||||
<span>{t.flow[0]}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0" />
|
||||
<span className="font-medium text-fd-foreground">{t.flow[1]}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0" />
|
||||
<span>{t.flow[2]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b px-6 py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">{t.docsEyebrow}</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">{t.docsTitle}</h2>
|
||||
</div>
|
||||
<Link
|
||||
href={`${prefix}/docs`}
|
||||
className="hidden items-center gap-2 text-sm font-medium sm:flex"
|
||||
>
|
||||
{t.viewAll}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||
{t.docs.map(({ icon: Icon, title, description, href }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={`${prefix}${href}`}
|
||||
className="group rounded-xl border bg-fd-card p-6 transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<Icon className="mb-5 size-5 text-fd-primary" />
|
||||
<h3 className="flex items-center justify-between font-semibold">
|
||||
{title}
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-fd-muted-foreground">{description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<section className="px-6 py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">{t.blogEyebrow}</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">{t.blogTitle}</h2>
|
||||
</div>
|
||||
<Link href={`${prefix}/blog`} className="flex items-center gap-2 text-sm font-medium">
|
||||
{t.viewAll}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 divide-y border-y">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
className="group grid gap-3 py-6 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
|
||||
>
|
||||
<time className="text-sm text-fd-muted-foreground">
|
||||
{formatBlogDate(post.path, lang)}
|
||||
</time>
|
||||
<div>
|
||||
<h3 className="font-semibold">{post.data.title}</h3>
|
||||
<p className="mt-1 text-sm text-fd-muted-foreground">
|
||||
{post.data.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { getPageImageUrl, getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
} from 'fumadocs-ui/layouts/docs/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import type { Metadata } from 'next';
|
||||
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||
import { gitConfig } from '@/lib/shared';
|
||||
|
||||
export default async function Page(props: PageProps<'/[lang]/docs/[[...slug]]'>) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const markdownUrl = getPageMarkdownUrl(page).url;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full} tableOfContent={{ style: 'clerk' }}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
|
||||
<div className="flex flex-row gap-2 items-center border-b pb-6">
|
||||
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||
<ViewOptionsPopover
|
||||
markdownUrl={markdownUrl}
|
||||
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
|
||||
/>
|
||||
</div>
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
// this allows you to link to other pages with relative file paths
|
||||
a: createRelativeLink(source, page),
|
||||
})}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<'/[lang]/docs/[[...slug]]'>,
|
||||
): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
openGraph: {
|
||||
images: getPageImageUrl(page).url,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { i18n, isLanguage } from '@/lib/i18n';
|
||||
|
||||
export default async function Layout({ params, children }: LayoutProps<'/[lang]/docs'>) {
|
||||
const { lang } = await params;
|
||||
const language = isLanguage(lang) ? lang : i18n.defaultLanguage;
|
||||
|
||||
return (
|
||||
<DocsLayout tree={source.getPageTree(language)} {...baseOptions(language)}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import { i18nProvider } from 'fumadocs-ui/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { htmlLang, i18n, isLanguage } from '@/lib/i18n';
|
||||
import { translations } from '@/lib/layout.shared';
|
||||
import '../global.css';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn';
|
||||
|
||||
export async function generateMetadata(props: LayoutProps<'/[lang]'>): Promise<Metadata> {
|
||||
const { lang } = await props.params;
|
||||
|
||||
if (lang === 'en') {
|
||||
return {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: 'Cursor Byok Docs',
|
||||
template: '%s | Cursor Byok',
|
||||
},
|
||||
description: 'Installation, model configuration, and frequently asked questions for Cursor Byok.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: 'Cursor Byok 文档',
|
||||
template: '%s | Cursor Byok',
|
||||
},
|
||||
description: '安装、模型配置与常见问题指南。',
|
||||
};
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
|
||||
const { lang } = await params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
return (
|
||||
<html lang={htmlLang[lang]} suppressHydrationWarning>
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<RootProvider i18n={i18nProvider(translations, lang)}>{children}</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: RouteContext<'/[lang]/llms.mdx/docs/[[...slug]]'>,
|
||||
) {
|
||||
const { lang, slug } = await params;
|
||||
const page = source.getPage(slug?.slice(0, -1), lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return new Response(await getLLMText(page), {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.flatMap((lang) =>
|
||||
source.getPages(lang).map((page) => ({
|
||||
lang,
|
||||
slug: getPageMarkdownUrl(page).segments,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { getPageImageUrl, source } from '@/lib/source';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { generate as DefaultImage } from 'fumadocs-ui/og';
|
||||
import { appName } from '@/lib/shared';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(_req: Request, { params }: RouteContext<'/[lang]/og/docs/[...slug]'>) {
|
||||
const { lang, slug } = await params;
|
||||
const page = source.getPage(slug.slice(0, -1), lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return new ImageResponse(
|
||||
<DefaultImage title={page.data.title} description={page.data.description} site={appName} />,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.flatMap((lang) =>
|
||||
source.getPages(lang).map((page) => ({
|
||||
lang,
|
||||
slug: getPageImageUrl(page).segments,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
@@ -1,40 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
html > body[data-scroll-locked] {
|
||||
margin-right: 0 !important;
|
||||
--removed-body-scroll-bar-size: 0px !important;
|
||||
}
|
||||
|
||||
/* 文档页目录:隐藏默认折线,改用单条直线导轨 */
|
||||
#nd-toc a[href^='#'] > svg,
|
||||
#nd-toc div.absolute:has(> svg) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#nd-toc a[href^='#'] {
|
||||
border-inline-start: 1px solid var(--color-fd-border);
|
||||
}
|
||||
|
||||
#nd-toc a[href^='#'][data-active='true'] {
|
||||
border-inline-start-color: var(--color-fd-primary);
|
||||
}
|
||||
|
||||
/* 首页导航:通铺整行、无背景无分隔线 */
|
||||
.home-fullbleed-nav {
|
||||
--fd-layout-width: 100%;
|
||||
}
|
||||
|
||||
.home-fullbleed-nav #nd-nav > * {
|
||||
border-bottom-color: transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB |
@@ -1,10 +0,0 @@
|
||||
import { getLLMText, source } from '@/lib/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const scan = source.getPages().map(getLLMText);
|
||||
const scanned = await Promise.all(scan);
|
||||
|
||||
return new Response(scanned.join('\n\n'));
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { llms } from 'fumadocs-core/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET() {
|
||||
return new Response(llms(source).index());
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 64px auto 0;
|
||||
}
|
||||
|
||||
.halo {
|
||||
position: absolute;
|
||||
inset: -10% -14%;
|
||||
background: radial-gradient(
|
||||
50% 55% at 50% 45%,
|
||||
color-mix(in srgb, var(--color-fd-primary) 9%, transparent),
|
||||
transparent 72%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 960 / 620;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid var(--color-fd-border);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 42px 90px -34px rgb(0 0 0 / 35%),
|
||||
0 12px 32px -16px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.viewport[data-dark] {
|
||||
background: #141414;
|
||||
box-shadow:
|
||||
0 42px 90px -34px rgb(0 0 0 / 50%),
|
||||
0 12px 32px -16px rgb(0 0 0 / 42%);
|
||||
}
|
||||
|
||||
.demo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.poster {
|
||||
object-fit: cover;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
.posterHidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.stage {
|
||||
margin-top: 42px;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { demoLocale, type Language } from '@/lib/i18n';
|
||||
import styles from './DesktopDemo.module.css';
|
||||
|
||||
// 小屏或触屏设备内嵌交互体验差,只展示 poster 静态图
|
||||
const embedQuery = '(min-width: 680px) and (hover: hover)';
|
||||
|
||||
const alt: Record<Language, string> = {
|
||||
zh: 'Cursor Byok 桌面端数据概览界面',
|
||||
en: 'Cursor Byok desktop overview dashboard',
|
||||
};
|
||||
|
||||
function subscribeEmbedQuery(callback: () => void) {
|
||||
const media = window.matchMedia(embedQuery);
|
||||
media.addEventListener('change', callback);
|
||||
return () => media.removeEventListener('change', callback);
|
||||
}
|
||||
|
||||
function subscribeTheme(callback: () => void) {
|
||||
const observer = new MutationObserver(callback);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
export function DesktopDemo({ lang }: { lang: Language }) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const embeddable = useSyncExternalStore(
|
||||
subscribeEmbedQuery,
|
||||
() => window.matchMedia(embedQuery).matches,
|
||||
() => true,
|
||||
);
|
||||
const dark = useSyncExternalStore(
|
||||
subscribeTheme,
|
||||
() => document.documentElement.classList.contains('dark'),
|
||||
() => true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embeddable) return;
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setMounted(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '240px' },
|
||||
);
|
||||
observer.observe(stage);
|
||||
return () => observer.disconnect();
|
||||
}, [embeddable]);
|
||||
|
||||
return (
|
||||
<div ref={stageRef} className={styles.stage}>
|
||||
<div className={styles.halo} aria-hidden />
|
||||
<DemoViewport
|
||||
// key 含挂载状态:视口变小卸载 iframe 时整体重置,poster 重新显示
|
||||
key={`${dark ? 'dark' : 'light'}-${lang}-${embeddable && mounted ? 'live' : 'poster'}`}
|
||||
dark={dark}
|
||||
lang={lang}
|
||||
mounted={embeddable && mounted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoViewport({
|
||||
dark,
|
||||
lang,
|
||||
mounted,
|
||||
}: {
|
||||
dark: boolean;
|
||||
lang: Language;
|
||||
mounted: boolean;
|
||||
}) {
|
||||
const [frameLoaded, setFrameLoaded] = useState(false);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const theme = dark ? 'default-dark' : 'default-light';
|
||||
const demoUrl = `/product-demo/demo/index.html?theme=${theme}&locale=${demoLocale[lang]}`;
|
||||
const posterUrl = `/images/product-demo-${dark ? 'dark' : 'light'}-${lang}.png`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameLoaded) return;
|
||||
// iframe onLoad 只代表文档加载完成,等内部应用渲染后再揭开 poster
|
||||
const timer = setTimeout(() => setRevealed(true), 900);
|
||||
return () => clearTimeout(timer);
|
||||
}, [frameLoaded]);
|
||||
|
||||
return (
|
||||
<div className={styles.viewport} data-dark={dark || undefined}>
|
||||
{mounted ? (
|
||||
<iframe
|
||||
title={alt[lang]}
|
||||
src={demoUrl}
|
||||
className={styles.demo}
|
||||
onLoad={() => setFrameLoaded(true)}
|
||||
/>
|
||||
) : null}
|
||||
<Image
|
||||
src={posterUrl}
|
||||
alt={alt[lang]}
|
||||
fill
|
||||
priority
|
||||
unoptimized
|
||||
className={revealed ? `${styles.poster} ${styles.posterHidden}` : styles.poster}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import type { Language } from '@/lib/i18n';
|
||||
import { releaseUrl } from '@/lib/shared';
|
||||
|
||||
function detectPlatform(): string | null {
|
||||
const ua = navigator.userAgent;
|
||||
if (/Mac/i.test(ua)) return 'macOS';
|
||||
if (/Win/i.test(ua)) return 'Windows';
|
||||
if (/Linux|X11/i.test(ua)) return 'Linux';
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels: Record<Language, { platform: (platform: string) => string; fallback: string }> = {
|
||||
zh: { platform: (platform) => `下载 ${platform} 版`, fallback: '下载最新版' },
|
||||
en: { platform: (platform) => `Download for ${platform}`, fallback: 'Download' },
|
||||
};
|
||||
|
||||
const subscribeNoop = () => () => {};
|
||||
|
||||
export function DownloadButton({ lang }: { lang: Language }) {
|
||||
// 平台只在客户端可知;SSR 用通用文案,水合后替换为平台文案
|
||||
const platform = useSyncExternalStore(subscribeNoop, detectPlatform, () => null);
|
||||
const label = labels[lang];
|
||||
|
||||
return (
|
||||
<a
|
||||
href={releaseUrl}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-3 font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{platform ? label.platform(platform) : label.fallback}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
|
||||
export function getMDXComponents(components?: MDXComponents) {
|
||||
return {
|
||||
...defaultMdxComponents,
|
||||
Step,
|
||||
Steps,
|
||||
...components,
|
||||
} satisfies MDXComponents;
|
||||
}
|
||||
|
||||
export const useMDXComponents = getMDXComponents;
|
||||
|
||||
declare global {
|
||||
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
title: Frequently Asked Questions
|
||||
description: Answers to common Cursor Byok questions about setup, accounts, models, and TAB completion.
|
||||
icon: Wrench
|
||||
---
|
||||
|
||||
## Q: Why does Cursor show “not signed in,” omit the model, or show `An unexpected error occurred` after an upgrade or first setup?
|
||||
|
||||
**A:** After upgrading Cursor or configuring a model for the first time, Cursor needs to establish a new connection. If you see the following error, run the complete restart sequence instead of continuing in the current conversation:
|
||||
|
||||
```text
|
||||
An unexpected error occurred. Request ID: 3871795e-2a93-4628-8308-c641047e9e43
|
||||
```
|
||||
|
||||
Complete these steps in order:
|
||||
|
||||
1. Keep Cursor Byok running.
|
||||
2. Quit Cursor completely, confirm the process has stopped, and restart it.
|
||||
3. Start a new conversation instead of continuing one opened before setup or the switch.
|
||||
4. Select your configured model from the model list.
|
||||
|
||||
The same sequence is required after a first-time Cursor upgrade or model setup. Conversations created with the current version stay synchronized with official Cursor conversations and can continue with official models.
|
||||
|
||||
## Q: Do I need to sign in to my own Cursor account when switching to the current version?
|
||||
|
||||
**A:** No. You can use your own models without a Cursor account. Signing in to any Cursor account is recommended because it provides better access to official plugins, MCP, Skills, codebase indexing, and official models. Switching Cursor accounts does not require quitting Cursor Byok.
|
||||
|
||||
## Q: Can I continue a conversation that was opened with the old version?
|
||||
|
||||
**A:** Conversations created by the old assistant cannot be continued directly in the current version; start a new conversation. Conversations created with the current version synchronize with Cursor and can switch between your models and official models.
|
||||
|
||||
## Q: Does the current version support official Cursor models?
|
||||
|
||||
**A:** Yes. Official Cursor models and your configured local models can coexist. Auto, Composer, and other official models continue to use Cursor's official service, while your configured models are routed through Cursor Byok.
|
||||
|
||||
## Q: Why does selecting Auto show a quota or account error?
|
||||
|
||||
**A:** Auto uses only official Cursor models and does not automatically use your configured models. If your account has no official quota, select your own model manually from the model list. With official quota, you can switch freely between official and configured models.
|
||||
|
||||
## Q: Can I still use Cursor plugins, MCP, Skills, and codebase indexing?
|
||||
|
||||
**A:** Yes. The current version coexists with Cursor's official service. After signing in to any Cursor account, you can continue using official plugins, MCP, Skills, Auto, and codebase indexing. Codebase indexing uses Cursor's official indexing service, so upgrade Cursor to the latest version.
|
||||
|
||||
## Q: Why is the first semantic search slow?
|
||||
|
||||
**A:** The first semantic search call automatically downloads the embedding model. Later searches use the local model. Keep the network connection available and wait for the initial download to finish.
|
||||
|
||||
## Q: Where do I configure TAB completion?
|
||||
|
||||
**A:** Go to **System Settings → TAB Settings** in Cursor Byok and choose one of these modes:
|
||||
|
||||
- **Direct connection**: connects to the official TAB service for the account signed in to Cursor.
|
||||
- **Community service**: uses the free TAB service provided by the author.
|
||||
- **Custom**: connects to a TAB completion service that you deploy separately.
|
||||
|
||||
These TAB completion capabilities all come from Cursor's official service. Custom BYOK models are generally not suitable for code completion and are not used for TAB completion. After changing this setting, restart Cursor and start a new conversation.
|
||||
|
||||
## Q: Why do Agent requests fail even though the model test passes?
|
||||
|
||||
**A:** The model test verifies basic connectivity only. Agent requests also include longer context, tool definitions, and streaming responses. Check:
|
||||
|
||||
- Whether the upstream model supports tool calling.
|
||||
- Whether the context window and maximum output tokens fit the model's limits.
|
||||
- Whether the model type, request protocol, and server address match.
|
||||
- Whether custom headers and extra parameters follow the upstream protocol.
|
||||
- The upstream status code and response body in **Call Statistics** or call details.
|
||||
|
||||
## Q: What is the local CA, and what should I do if initialization fails?
|
||||
|
||||
**A:** The local CA (certificate authority) allows Cursor Byok to inspect HTTPS requests from Cursor. Its files stay on your machine. Go to **Cursor Settings** and click **Initialize CA**. When the system asks for authorization, follow the app's terminal instructions to install and trust it, then return to the app and click **I have initialized, refresh**.
|
||||
|
||||
If the status does not update, quit and restart Cursor Byok completely, then open Cursor Settings again.
|
||||
|
||||
## Q: What should I do if the model connectivity test fails?
|
||||
|
||||
**A:** Check the error category:
|
||||
|
||||
- **Authentication error**: confirm the API key is valid and the account can access the target model.
|
||||
- **Endpoint not found**: confirm the model type, request protocol, and server address match.
|
||||
- **Model not found**: use **Fetch Models** to check the model identifiers returned by the upstream.
|
||||
- **Invalid parameters**: temporarily remove custom headers and extra parameters, then test again.
|
||||
- **Connection timeout**: check your network, system proxy, and upstream service status.
|
||||
|
||||
## Q: What should I do about a fake account generated by the old version?
|
||||
|
||||
**A:** Sign out of the fake account in Cursor, then sign in to any Cursor account, or remain signed out while using your own models. The current version does not require a fake account or a “stop service” workflow to switch between official and configured models.
|
||||
|
||||
## Q: What information should I include when reporting another issue?
|
||||
|
||||
**A:** Open a report on [GitHub Issues](https://github.com/leookun/cursor-byok/issues) with your operating system, Cursor Byok version, model type, request protocol, redacted server address, error message, and reproduction steps. Never share API keys or other credentials.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user