mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 04:07:36 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e670e1d5 |
@@ -1,86 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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/);
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
function readOptions(args) {
|
|
||||||
const options = new Map();
|
|
||||||
for (let index = 0; index < args.length; index += 2) {
|
|
||||||
const name = args[index];
|
|
||||||
const value = args[index + 1];
|
|
||||||
if (!name?.startsWith("--") || value === undefined) {
|
|
||||||
throw new Error(`invalid argument near ${name ?? "end of command"}`);
|
|
||||||
}
|
|
||||||
options.set(name.slice(2), value);
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
function required(options, name) {
|
|
||||||
const value = options.get(name)?.trim();
|
|
||||||
if (!value) throw new Error(`--${name} is required`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeTauriUpdate(manifest, release, repository, version) {
|
|
||||||
if (manifest.version !== version) {
|
|
||||||
throw new Error(
|
|
||||||
`updater manifest version ${manifest.version ?? "is missing"}; expected ${version}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (release.tag_name !== `v${version}`) {
|
|
||||||
throw new Error(
|
|
||||||
`GitHub release tag ${release.tag_name ?? "is missing"}; expected v${version}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!manifest.platforms || typeof manifest.platforms !== "object") {
|
|
||||||
throw new Error("updater manifest has no platforms");
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetsBySourceUrl = new Map();
|
|
||||||
const publicAssetUrls = new Set();
|
|
||||||
for (const asset of release.assets ?? []) {
|
|
||||||
if (!asset?.id || !asset?.name) continue;
|
|
||||||
const publicUrl = `https://github.com/${repository}/releases/download/v${version}/${encodeURIComponent(asset.name)}`;
|
|
||||||
assetsBySourceUrl.set(
|
|
||||||
`https://api.github.com/repos/${repository}/releases/assets/${asset.id}`,
|
|
||||||
publicUrl,
|
|
||||||
);
|
|
||||||
if (asset.browser_download_url) {
|
|
||||||
assetsBySourceUrl.set(asset.browser_download_url, publicUrl);
|
|
||||||
}
|
|
||||||
publicAssetUrls.add(publicUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [platform, entry] of Object.entries(manifest.platforms)) {
|
|
||||||
if (!entry?.signature || !entry?.url) {
|
|
||||||
throw new Error(`updater platform ${platform} is missing its URL or signature`);
|
|
||||||
}
|
|
||||||
const publicUrl = assetsBySourceUrl.get(entry.url) ?? entry.url;
|
|
||||||
if (!publicAssetUrls.has(publicUrl)) {
|
|
||||||
throw new Error(`updater platform ${platform} references an unknown release asset`);
|
|
||||||
}
|
|
||||||
entry.url = publicUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const options = readOptions(process.argv.slice(2));
|
|
||||||
const manifestPath = resolve(required(options, "manifest"));
|
|
||||||
const releasePath = resolve(required(options, "release"));
|
|
||||||
const repository = required(options, "repository");
|
|
||||||
const version = required(options, "version").replace(/^v/, "");
|
|
||||||
|
|
||||||
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
|
|
||||||
throw new Error(`invalid GitHub repository: ${repository}`);
|
|
||||||
}
|
|
||||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
||||||
throw new Error(`invalid semantic version: ${version}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
||||||
const release = JSON.parse(await readFile(releasePath, "utf8"));
|
|
||||||
const normalized = normalizeTauriUpdate(
|
|
||||||
manifest,
|
|
||||||
release,
|
|
||||||
repository,
|
|
||||||
version,
|
|
||||||
);
|
|
||||||
await writeFile(manifestPath, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
process.argv[1] &&
|
|
||||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
||||||
) {
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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,27 +158,14 @@ jobs:
|
|||||||
mkdir -p legacy-update
|
mkdir -p legacy-update
|
||||||
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
|
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
|
||||||
|
|
||||||
- name: Package and sign legacy Windows updater asset
|
- name: Package legacy Windows updater asset
|
||||||
if: matrix.platform == 'windows-x86_64'
|
if: matrix.platform == 'windows-x86_64'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.prepare.outputs.version }}
|
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: |
|
run: |
|
||||||
New-Item -ItemType Directory -Force legacy-update | Out-Null
|
New-Item -ItemType Directory -Force legacy-update | Out-Null
|
||||||
$asset = "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
|
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "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
|
- name: Package legacy macOS updater asset
|
||||||
if: contains(matrix.platform, 'macos')
|
if: contains(matrix.platform, 'macos')
|
||||||
@@ -219,57 +206,13 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.prepare.outputs.version }}
|
VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
node .github/scripts/generate-legacy-update.mjs \
|
node scripts/release/generate-legacy-update.mjs \
|
||||||
--version "${VERSION}" \
|
--version "${VERSION}" \
|
||||||
--repository "${GITHUB_REPOSITORY}" \
|
--repository "${GITHUB_REPOSITORY}" \
|
||||||
--assets-dir legacy-update \
|
--assets-dir legacy-update \
|
||||||
--output legacy-update/update.json \
|
--output legacy-update/update.json \
|
||||||
--notes "Cursor BYOK v${VERSION}"
|
--notes "Cursor BYOK v${VERSION}"
|
||||||
|
|
||||||
- name: Generate signed Windows portable update manifest
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.prepare.outputs.version }}
|
|
||||||
run: |
|
|
||||||
asset="cursor-byok-${VERSION}-windows-amd64.zip"
|
|
||||||
test -f "legacy-update/${asset}"
|
|
||||||
test -f "legacy-update/${asset}.sig"
|
|
||||||
node .github/scripts/generate-portable-update.mjs \
|
|
||||||
--version "${VERSION}" \
|
|
||||||
--repository "${GITHUB_REPOSITORY}" \
|
|
||||||
--asset "${asset}" \
|
|
||||||
--signature "legacy-update/${asset}.sig" \
|
|
||||||
--output legacy-update/portable-latest.json
|
|
||||||
|
|
||||||
- name: Normalize Tauri updater download URLs
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
VERSION: ${{ needs.prepare.outputs.version }}
|
|
||||||
run: |
|
|
||||||
mkdir -p tauri-update
|
|
||||||
release_id=$(
|
|
||||||
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
|
||||||
--jq ".[] | select(.tag_name == \"v${VERSION}\") | .id"
|
|
||||||
)
|
|
||||||
test -n "${release_id}"
|
|
||||||
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > tauri-update/release.json
|
|
||||||
asset_id=$(node -p 'require("./tauri-update/release.json").assets.find(({ name }) => name === "latest.json")?.id ?? ""')
|
|
||||||
test -n "${asset_id}"
|
|
||||||
gh api \
|
|
||||||
-H "Accept: application/octet-stream" \
|
|
||||||
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
|
||||||
> tauri-update/latest.json
|
|
||||||
node .github/scripts/normalize-tauri-update.mjs \
|
|
||||||
--manifest tauri-update/latest.json \
|
|
||||||
--release tauri-update/release.json \
|
|
||||||
--repository "${GITHUB_REPOSITORY}" \
|
|
||||||
--version "${VERSION}"
|
|
||||||
|
|
||||||
- name: Upload normalized Tauri updater manifest
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
VERSION: ${{ needs.prepare.outputs.version }}
|
|
||||||
run: gh release upload "v${VERSION}" tauri-update/latest.json --clobber
|
|
||||||
|
|
||||||
- name: Upload legacy updater assets
|
- name: Upload legacy updater assets
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -7,19 +7,3 @@
|
|||||||
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
|
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
|
||||||
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
|
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
|
||||||
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
|
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
|
||||||
|
|
||||||
## Communication
|
|
||||||
|
|
||||||
- Lead with the conclusion. Keep responses direct and omit filler, repeated context, generic explanations, and narration of obvious steps.
|
|
||||||
- Match the level requested by the user: discuss architecture as architecture, behavior as behavior, and code details only when they materially support the answer.
|
|
||||||
- For architecture, refactoring, and module-boundary discussions, communicate primarily with annotated directory trees and ASCII architecture/data-flow diagrams instead of long prose.
|
|
||||||
- In directory trees, annotate every relevant directory and file with its single responsibility. When discussing code size, include line counts in the comments:
|
|
||||||
- use measured line counts for existing code;
|
|
||||||
- use clearly marked approximate targets such as `≈300 lines` for proposed code;
|
|
||||||
- state whether tests, generated code, and blank lines are excluded.
|
|
||||||
- Show the complete main execution path, including inputs, ownership boundaries, runtime loops, persistence, outputs, and extension points. Make the direction of data and control flow explicit.
|
|
||||||
- Clearly separate the current structure from the proposed structure. Explicitly list modules that move, merge, split, or are deleted.
|
|
||||||
- Use the user's vocabulary consistently. Do not introduce new domain terms when existing plain-language terms are sufficient; when an implementation name must be mentioned, distinguish it from the architecture concept.
|
|
||||||
- For stateful or concurrent behavior, show timing, ownership, state boundaries, and before/during/after behavior explicitly. Distinguish continuation within the same lifecycle from creation of a new lifecycle.
|
|
||||||
- Prefer compact trees, flow diagrams, state matrices, and mappings when they communicate the structure more clearly than paragraphs.
|
|
||||||
- Tie recommendations to the actual repository structure and code. Do not present a speculative target architecture as if it already exists.
|
|
||||||
|
|||||||
Generated
+3
-280
@@ -97,28 +97,6 @@ dependencies = [
|
|||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ashpd"
|
|
||||||
version = "0.11.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
|
|
||||||
dependencies = [
|
|
||||||
"async-fs",
|
|
||||||
"async-net",
|
|
||||||
"enumflags2",
|
|
||||||
"futures-channel",
|
|
||||||
"futures-util",
|
|
||||||
"rand 0.9.5",
|
|
||||||
"raw-window-handle",
|
|
||||||
"serde",
|
|
||||||
"serde_repr",
|
|
||||||
"url",
|
|
||||||
"wayland-backend",
|
|
||||||
"wayland-client",
|
|
||||||
"wayland-protocols",
|
|
||||||
"zbus",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -208,17 +186,6 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-fs"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
|
|
||||||
dependencies = [
|
|
||||||
"async-lock",
|
|
||||||
"blocking",
|
|
||||||
"futures-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-io"
|
name = "async-io"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
@@ -248,17 +215,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-net"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
|
|
||||||
dependencies = [
|
|
||||||
"async-io",
|
|
||||||
"blocking",
|
|
||||||
"futures-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-process"
|
name = "async-process"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -970,7 +926,7 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1172,13 +1128,10 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cursor-byok-desktop"
|
name = "cursor-byok-desktop"
|
||||||
version = "1.0.1"
|
version = "0.1.0-beta.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"cursor-server",
|
"cursor-server",
|
||||||
"libc",
|
|
||||||
"mimalloc",
|
|
||||||
"rfd",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -1189,15 +1142,11 @@ dependencies = [
|
|||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tempfile",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"windows-sys 0.61.2",
|
|
||||||
"zip",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1234,7 +1183,6 @@ dependencies = [
|
|||||||
"semble-core",
|
"semble-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
|
||||||
"sha1 0.10.7",
|
"sha1 0.10.7",
|
||||||
"sha2",
|
"sha2",
|
||||||
"similar",
|
"similar",
|
||||||
@@ -1253,7 +1201,6 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
"zip",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1572,15 +1519,6 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dlib"
|
|
||||||
version = "0.5.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
|
||||||
dependencies = [
|
|
||||||
"libloading",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dlopen2"
|
name = "dlopen2"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -1935,7 +1873,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
"zlib-rs",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1967,15 +1904,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foreign-types"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
|
||||||
dependencies = [
|
|
||||||
"foreign-types-shared 0.1.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1983,7 +1911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared 0.3.1",
|
"foreign-types-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1997,12 +1925,6 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foreign-types-shared"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2804,22 +2726,6 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.9",
|
"webpki-roots 1.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyper-tls"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"native-tls",
|
|
||||||
"tokio",
|
|
||||||
"tokio-native-tls",
|
|
||||||
"tower-service",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tungstenite"
|
name = "hyper-tungstenite"
|
||||||
version = "0.30.0"
|
version = "0.30.0"
|
||||||
@@ -3418,15 +3324,6 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
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]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
@@ -3614,15 +3511,6 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mimalloc"
|
|
||||||
version = "0.1.52"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
|
||||||
dependencies = [
|
|
||||||
"libmimalloc-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -3751,23 +3639,6 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "native-tls"
|
|
||||||
version = "0.2.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"openssl",
|
|
||||||
"openssl-probe",
|
|
||||||
"openssl-sys",
|
|
||||||
"schannel",
|
|
||||||
"security-framework",
|
|
||||||
"security-framework-sys",
|
|
||||||
"tempfile",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -4166,49 +4037,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl"
|
|
||||||
version = "0.10.81"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"cfg-if",
|
|
||||||
"foreign-types 0.3.2",
|
|
||||||
"libc",
|
|
||||||
"openssl-macros",
|
|
||||||
"openssl-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl-macros"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl-sys"
|
|
||||||
version = "0.9.117"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
"pkg-config",
|
|
||||||
"vcpkg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -4635,12 +4469,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pollster"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -5214,11 +5042,9 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"native-tls",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -5229,7 +5055,6 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -5282,30 +5107,6 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rfd"
|
|
||||||
version = "0.15.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
|
|
||||||
dependencies = [
|
|
||||||
"ashpd",
|
|
||||||
"block2",
|
|
||||||
"dispatch2",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"objc2",
|
|
||||||
"objc2-app-kit",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-foundation",
|
|
||||||
"pollster",
|
|
||||||
"raw-window-handle",
|
|
||||||
"urlencoding",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
"windows-sys 0.59.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -5860,19 +5661,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_yaml"
|
|
||||||
version = "0.9.34+deprecated"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
|
||||||
dependencies = [
|
|
||||||
"indexmap 2.14.0",
|
|
||||||
"itoa",
|
|
||||||
"ryu",
|
|
||||||
"serde",
|
|
||||||
"unsafe-libyaml",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -6399,12 +6187,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "symlink"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -7128,16 +6910,6 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-native-tls"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
|
||||||
dependencies = [
|
|
||||||
"native-tls",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -7378,19 +7150,6 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-appender"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-channel",
|
|
||||||
"symlink",
|
|
||||||
"thiserror 2.0.20",
|
|
||||||
"time",
|
|
||||||
"tracing-subscriber",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.31"
|
version = "0.1.31"
|
||||||
@@ -7728,12 +7487,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unsafe-libyaml"
|
|
||||||
version = "0.2.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -7765,12 +7518,6 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urlencoding"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -7987,7 +7734,6 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
"rustix",
|
"rustix",
|
||||||
"scoped-tls",
|
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"wayland-sys",
|
"wayland-sys",
|
||||||
]
|
]
|
||||||
@@ -8046,8 +7792,6 @@ version = "0.31.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dlib",
|
|
||||||
"log",
|
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -9104,36 +8848,16 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"flate2",
|
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"memchr",
|
"memchr",
|
||||||
"zopfli",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zlib-rs"
|
|
||||||
version = "0.6.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zopfli"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"crc32fast",
|
|
||||||
"log",
|
|
||||||
"simd-adler32",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zstd"
|
name = "zstd"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
@@ -9186,7 +8910,6 @@ dependencies = [
|
|||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
"url",
|
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
"zcheapstr",
|
"zcheapstr",
|
||||||
"zvariant_derive",
|
"zvariant_derive",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ members = [
|
|||||||
"server",
|
"server",
|
||||||
"apps/desktop/src-tauri",
|
"apps/desktop/src-tauri",
|
||||||
"crates/semble-core",
|
"crates/semble-core",
|
||||||
"support/benchmarks/semble",
|
"benchmarks/semble",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ COPY apps/desktop/src-tauri/src/ apps/desktop/src-tauri/src/
|
|||||||
COPY apps/desktop/src-tauri/capabilities/ apps/desktop/src-tauri/capabilities/
|
COPY apps/desktop/src-tauri/capabilities/ apps/desktop/src-tauri/capabilities/
|
||||||
COPY apps/desktop/src-tauri/icons/ apps/desktop/src-tauri/icons/
|
COPY apps/desktop/src-tauri/icons/ apps/desktop/src-tauri/icons/
|
||||||
COPY apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/tauri.conf.json
|
COPY apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/tauri.conf.json
|
||||||
COPY protocols/cursor/ protocols/cursor/
|
COPY scripts/cursor-proto/proto/ scripts/cursor-proto/proto/
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,target=/src/target \
|
--mount=type=cache,target=/src/target \
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ build-web:
|
|||||||
build-server:
|
build-server:
|
||||||
cargo build --release --package cursor-server --bin cursor-server
|
cargo build --release --package cursor-server --bin cursor-server
|
||||||
|
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
$(LOCAL_TAURI_SIGNING_KEY):
|
|
||||||
@powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '$(dir $@)' | Out-Null; & '$(CURDIR)/apps/desktop/node_modules/.bin/tauri.cmd' signer generate --ci --write-keys '$@'"
|
|
||||||
|
|
||||||
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
|
|
||||||
@node -e "const { spawnSync } = require('node:child_process'); const result = spawnSync(process.execPath, ['node_modules/@tauri-apps/cli/tauri.js', 'build', '--bundles', 'nsis'], { cwd: 'apps/desktop', stdio: 'inherit', env: { ...process.env, TAURI_SIGNING_PRIVATE_KEY: process.argv[1], TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' } }); process.exit(result.status ?? 1)" "$(LOCAL_TAURI_SIGNING_KEY)"
|
|
||||||
else
|
|
||||||
$(LOCAL_TAURI_SIGNING_KEY):
|
$(LOCAL_TAURI_SIGNING_KEY):
|
||||||
@install -d -m 700 "$(dir $@)"
|
@install -d -m 700 "$(dir $@)"
|
||||||
@apps/desktop/node_modules/.bin/tauri signer generate --ci --write-keys "$@" >/dev/null
|
@apps/desktop/node_modules/.bin/tauri signer generate --ci --write-keys "$@" >/dev/null
|
||||||
@@ -37,7 +30,6 @@ $(LOCAL_TAURI_SIGNING_KEY):
|
|||||||
|
|
||||||
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
|
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
|
||||||
TAURI_SIGNING_PRIVATE_KEY="$(LOCAL_TAURI_SIGNING_KEY)" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" npm --prefix apps/desktop run tauri:build
|
TAURI_SIGNING_PRIVATE_KEY="$(LOCAL_TAURI_SIGNING_KEY)" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" npm --prefix apps/desktop run tauri:build
|
||||||
endif
|
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
docker build --tag cursor-byok:local .
|
docker build --tag cursor-byok:local .
|
||||||
|
|||||||
-223
@@ -1,223 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# cursor-byok
|
|
||||||
|
|
||||||
cursor-byok 是一个运行在本机的 Cursor 模型网关,帮助你在 Cursor 中使用自己配置的模型服务。
|
|
||||||
|
|
||||||
[English README](./README.md) · [使用指南](https://docs.leokun.cn) · [下载](https://github.com/leookun/cursor-byok/releases/latest) · [提交问题](https://github.com/leookun/cursor-byok/issues)
|
|
||||||
|
|
||||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
|
||||||
[](https://github.com/leookun/cursor-byok/releases)
|
|
||||||
[](./LICENSE)
|
|
||||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 项目简介
|
|
||||||
|
|
||||||
cursor-byok 是一个开源的本地模型网关。它在你的设备上运行服务,接收 Cursor 发出的 Agent 请求,将请求转发到你配置的模型服务,并尽可能保留 Cursor Agent 的工具调用、Skills、MCP 和多轮对话能力。
|
|
||||||
|
|
||||||
你可以连接兼容 OpenAI 或 Anthropic 协议的服务,自定义服务地址、模型 ID、API Key 和请求参数,也可以使用 Cursor 平台默认选项之外的模型通道。
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> cursor-byok 免费且开源,但你连接的模型服务商可能会按用量收费。本项目是独立项目,与 Cursor 或其开发者没有关联,也未获得其认可。
|
|
||||||
|
|
||||||
## 主要功能
|
|
||||||
|
|
||||||
- **自定义模型通道**:配置自己的 API 地址、凭据和模型 ID。
|
|
||||||
- **多种 API 协议**:支持 OpenAI Responses API、OpenAI Chat Completions API 和 Anthropic Messages API 兼容服务。
|
|
||||||
- **模型管理**:添加、复制、编辑、排序模型配置,并批量测试连接。
|
|
||||||
- **连接性能测试**:查看首字延迟、生成速度、总耗时和原始服务商响应。
|
|
||||||
- **Agent 工作流**:继续使用工具调用、Skills、MCP 和多轮对话。
|
|
||||||
- **会话指标**:查看 Token 用量、缓存命中率、对话轮次和估算价值。
|
|
||||||
- **TAB 补全服务**:在公益服务、官方直连和自定义服务之间选择连接方式。
|
|
||||||
- **跨平台运行**:支持 macOS、Windows 和 Linux。
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载适合你操作系统的最新版本。
|
|
||||||
2. 启动 cursor-byok,打开 **Cursor 配置**,按提示初始化本地 CA(证书颁发机构)。
|
|
||||||
3. 在模型设置中添加模型,填写服务地址、API Key 和模型名称,然后保存并运行 **测试**。
|
|
||||||
4. 确认测试通过后,保持 cursor-byok 运行。
|
|
||||||
5. **首次升级 Cursor 或首次配置模型后,完全退出并重新启动 Cursor,然后新开一个对话**。在模型列表中选择已配置的模型,开始使用 Agent。
|
|
||||||
|
|
||||||
完整的安装步骤、配置说明和常见问题,请参阅[中文使用指南](https://docs.leokun.cn/zh/docs)。
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> 首次升级 Cursor 或首次完成配置后,必须完全退出并重新启动 Cursor,再新开一个对话。配置前已经打开的对话不会加载新连接;使用自定义模型时,请在模型列表中手动选择该模型,不要选择 **Auto**。
|
|
||||||
|
|
||||||
## 模型配置
|
|
||||||
|
|
||||||
每个模型配置都是独立的上游通道,可以单独设置服务商、协议、凭据和生成参数。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 类型与协议选择
|
|
||||||
|
|
||||||
| 模型系列 | 模型类型 | 请求协议 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Claude 系列 | **Anthropic** | Messages API |
|
|
||||||
| GPT / OpenAI 系列 | **OpenAI** | **Responses API** |
|
|
||||||
| 其他模型 | **OpenAI** | **Chat Completions API** |
|
|
||||||
|
|
||||||
GPT 系列建议使用 **Responses API**。如果使用 Chat Completions,可能无法保留提示词缓存,导致速度变慢和费用增加。
|
|
||||||
|
|
||||||
### 常用字段
|
|
||||||
|
|
||||||
- **模型类型**:选择 OpenAI 或 Anthropic,决定上游接口格式。
|
|
||||||
- **请求协议**:OpenAI 类型需要继续选择 Responses API 或 Chat Completions API。
|
|
||||||
- **服务器地址**:可以填写服务商基础地址,让应用按协议追加标准端点,也可以填写完整请求 URL 并原样使用。
|
|
||||||
- **API Key**:填写上游服务要求的访问密钥。密钥保存在本机,用于发送模型请求。
|
|
||||||
- **模型名称**:填写服务商接口接受的模型标识,也可以使用 **获取模型** 读取服务商返回的模型列表。
|
|
||||||
- **显示名称**:Cursor 模型列表中显示的名称,不会改变发送给上游的模型标识。
|
|
||||||
- **备注**:显示在 Cursor 的模型说明中。
|
|
||||||
|
|
||||||
还可以根据模型能力设置上下文窗口 Token、最大输出 Token、推理或思考强度、自定义 Headers,以及 OpenAI 或 Anthropic 的额外参数。自定义 Headers 和额外参数必须是 JSON 对象,只应填写服务商明确支持的字段。
|
|
||||||
|
|
||||||
保存配置后运行 **测试**,确认地址、协议、API Key、模型标识和流式响应都正常,再在 Cursor 中使用该模型。
|
|
||||||
|
|
||||||
## TAB 补全服务
|
|
||||||
|
|
||||||
Cursor 的 Tab 补全由独立的 TAB 服务处理,不经过模型通道。你可以在 **系统设置 → TAB 设置** 中选择以下模式:
|
|
||||||
|
|
||||||
- **使用公益服务(默认)**:使用项目作者部署的公共服务,无需额外配置。
|
|
||||||
- **直连**:直接连接当前 Cursor 账号对应的官方 TAB 服务,适合账号拥有官方额度的情况。
|
|
||||||
- **自定义**:自行部署 [`cursor-tab-server`](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server),然后填写 TAB 服务地址。
|
|
||||||
|
|
||||||
修改 TAB 设置后,建议重启 Cursor 并新开一个对话,确保新的连接方式生效。
|
|
||||||
|
|
||||||
## 与官方账号并存
|
|
||||||
|
|
||||||
新版设计支持 cursor-byok 与 Cursor 官方服务并存:
|
|
||||||
|
|
||||||
- 直接在 Cursor 中登录自己的账号。如果之前使用旧版生成的 fake 账户,请先退出该账户,再登录自己的账号。
|
|
||||||
- 账号拥有官方额度时,官方模型和本地模型可以随时切换混用。
|
|
||||||
- **Auto 只使用官方模型**,不会自动使用你配置的本地模型。账号没有官方额度时,请手动选择自己配置的模型。
|
|
||||||
- 插件、代码库索引等 Cursor 功能可以继续使用。
|
|
||||||
|
|
||||||
## 数据流转
|
|
||||||
|
|
||||||
```text
|
|
||||||
Cursor 客户端
|
|
||||||
│
|
|
||||||
│ Agent 请求与工具结果
|
|
||||||
▼
|
|
||||||
cursor-byok 本地服务
|
|
||||||
│
|
|
||||||
│ OpenAI / Anthropic 兼容请求
|
|
||||||
▼
|
|
||||||
你配置的模型 API
|
|
||||||
```
|
|
||||||
|
|
||||||
API Key、模型配置和应用设置保存在本机。模型请求仍会发送到你选择的上游服务商,请根据对应服务商的隐私政策和计费规则使用。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
cursor-byok/
|
|
||||||
├── apps/
|
|
||||||
│ ├── desktop/
|
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ ├── features/ # 首页、模型、调用记录与设置
|
|
||||||
│ │ │ ├── shell/ # 窗口框架、页面布局与广告外壳
|
|
||||||
│ │ │ ├── shared/ # UI、虚拟列表、状态、API 与平台能力
|
|
||||||
│ │ │ ├── i18n/ # 本地化运行时与语言目录
|
|
||||||
│ │ │ └── styles/ # 全局主题与排版令牌
|
|
||||||
│ │ └── src-tauri/ # Tauri 桌面生命周期
|
|
||||||
├── server/
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── cursor/ # Cursor 协议适配
|
|
||||||
│ │ ├── run/ # 通用 Agent Runtime 与内部 Port
|
|
||||||
│ │ ├── provider/ # 模型供应商适配
|
|
||||||
│ │ ├── model/ # 聚合领域模型
|
|
||||||
│ │ ├── store/ # SQLite Repository
|
|
||||||
│ │ ├── control/ # 管理面 API
|
|
||||||
│ │ ├── harness/ # Cursor 本地集成
|
|
||||||
│ │ └── search/ # Web 与 Semble 搜索接入
|
|
||||||
│ ├── prompt/
|
|
||||||
│ └── migrations/
|
|
||||||
├── crates/
|
|
||||||
│ └── semble-core/ # 本地代码索引与搜索核心库
|
|
||||||
├── protocols/
|
|
||||||
│ └── cursor/ # Cursor 协议定义的唯一来源
|
|
||||||
├── support/ # 仓库辅助设施
|
|
||||||
│ ├── cursor-protocol-extractor/ # Cursor 协议提取与 Go 代码生成工具
|
|
||||||
│ ├── cursor-capture/ # Cursor 协议抓取和调试工具
|
|
||||||
│ └── benchmarks/ # 代码搜索和索引基准测试
|
|
||||||
├── images/ # README 展示图片
|
|
||||||
├── Cargo.toml # Rust 工作区配置
|
|
||||||
└── Makefile # 常用开发、检查和构建命令
|
|
||||||
```
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
### 环境要求
|
|
||||||
|
|
||||||
- Rust 工具链和 Cargo
|
|
||||||
- Node.js 与 npm
|
|
||||||
- Tauri 2 的系统构建依赖
|
|
||||||
- Docker(仅在构建 Docker 镜像时需要)
|
|
||||||
|
|
||||||
### 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm --prefix apps/desktop install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 启动开发环境
|
|
||||||
|
|
||||||
启动桌面前端:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-web
|
|
||||||
```
|
|
||||||
|
|
||||||
启动桌面应用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
### 检查与构建
|
|
||||||
|
|
||||||
运行完整检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make check
|
|
||||||
```
|
|
||||||
|
|
||||||
分别构建各部分:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build-web # 构建桌面前端
|
|
||||||
make build-server # 构建 Rust 本地服务
|
|
||||||
make build-desktop # 构建 Tauri 桌面安装包
|
|
||||||
make build-docker # 构建 Docker 镜像
|
|
||||||
```
|
|
||||||
|
|
||||||
## 路线图
|
|
||||||
|
|
||||||
项目将继续改进模型兼容性、Agent 工具、本地运行稳定性和自托管体验,并探索支持更多 IDE、聊天和 Agent 工作流。
|
|
||||||
|
|
||||||
计划与进展请参阅[发布路线图](https://github.com/leookun/cursor-byok/discussions/32)。
|
|
||||||
|
|
||||||
## 社区与反馈
|
|
||||||
|
|
||||||
- [中文使用指南](https://docs.leokun.cn/zh/docs)
|
|
||||||
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
|
|
||||||
- [Telegram 社区](https://t.me/cursor_byok)
|
|
||||||
- QQ 群:`1095916242`、`1094411438`、`1095918002`、`1094419321`
|
|
||||||
|
|
||||||
提交问题时,请附上操作系统、cursor-byok 版本、模型类型、请求协议、已脱敏的服务地址、错误信息和复现步骤。请勿公开 API Key 或其他凭据。
|
|
||||||
|
|
||||||
## 参与贡献
|
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request。提交代码前请先阅读项目中的开发说明,并运行 `make check` 确认格式、测试和前端构建检查通过。
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
本项目采用 [MIT License](./LICENSE) 开源。
|
|
||||||
@@ -17,9 +17,9 @@ cursor-byok is a local implementation of Cursor's backend.
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
@@ -45,16 +45,15 @@ You can connect OpenAI- and Anthropic-compatible services, customize endpoints,
|
|||||||
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
|
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
|
||||||
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
|
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
|
||||||
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
|
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
|
||||||
4. Test the model configuration. Once it passes, return to the dashboard and start the service.
|
4. Open Cursor, select the configured model, and start using Agent.
|
||||||
5. After upgrading Cursor or configuring a model for the first time, quit Cursor completely and restart it, then start a new conversation and select the configured model.
|
|
||||||
|
|
||||||
For complete installation steps, system configuration, and Frequently Asked Questions, see the [User Guide](https://docs.leokun.cn).
|
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
|
||||||
|
|
||||||
## Model Management
|
## Model Management
|
||||||
|
|
||||||
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
|
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<style>
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
background: var(--boot-bg, #141414);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
// 在应用样式加载前按主题参数铺底色,避免切换 poster 时闪出错误颜色
|
|
||||||
(function () {
|
|
||||||
var light = new URLSearchParams(location.search).get("theme") === "default-light";
|
|
||||||
document.documentElement.style.setProperty("--boot-bg", light ? "#f5f5f5" : "#141414");
|
|
||||||
var meta = document.createElement("meta");
|
|
||||||
meta.name = "theme-color";
|
|
||||||
meta.content = light ? "#f5f5f5" : "#141414";
|
|
||||||
document.head.appendChild(meta);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<title>Cursor BYOK 产品演示</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script src="/src/demo/index.tsx" type="module"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Generated
+28
-23
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "1.0.1",
|
"version": "0.1.0-beta.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "1.0.1",
|
"version": "0.1.0-beta.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.8.0",
|
"@floating-ui/dom": "^1.8.0",
|
||||||
@@ -18,15 +18,15 @@
|
|||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"keepalive-for-react": "^5.0.11",
|
"keepalive-for-react": "^5.0.11",
|
||||||
"keepalive-for-react-router": "^5.0.7",
|
"keepalive-for-react-router": "^5.0.7",
|
||||||
"monaco-editor": "0.56.0",
|
"monaco-editor": "0.56.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-css-marquee": "^1.0.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"sortablejs": "^1.15.7",
|
|
||||||
"zrender": "^6.1.0"
|
"zrender": "^6.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@types/sortablejs": "^1.15.9",
|
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"code-inspector-plugin": "^1.6.6",
|
"code-inspector-plugin": "^1.6.6",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
@@ -426,6 +425,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.142.0",
|
"version": "0.142.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
|
||||||
@@ -1288,13 +1293,6 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/sortablejs": {
|
|
||||||
"version": "1.15.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
|
|
||||||
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -1810,6 +1808,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -2979,13 +2989,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-css-marquee": {
|
"node_modules/react-chartjs-2": {
|
||||||
"version": "1.0.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-css-marquee/-/react-css-marquee-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
||||||
"integrity": "sha512-jPHydXILF3oIP2gOvabfbEY3PUdbkHtDVA4mqYQiYvEdLWk9oqNJkUwDBxHK1RtltvQY3zkyuxTaGnw+G8x48w==",
|
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8 || ^17 || ^18 || ^19"
|
"chart.js": "^4.1.1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
@@ -3174,12 +3185,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sortablejs": {
|
|
||||||
"version": "1.15.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
|
||||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cursor-byok-desktop",
|
"name": "cursor-byok-desktop",
|
||||||
"version": "1.0.1",
|
"version": "0.1.0-beta.10",
|
||||||
"description": "Cursor BYOK desktop management application",
|
"description": "Cursor BYOK desktop management application",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,9 +8,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||||
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
|
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
||||||
"build": "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",
|
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||||
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
|
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
@@ -29,15 +28,15 @@
|
|||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"keepalive-for-react": "^5.0.11",
|
"keepalive-for-react": "^5.0.11",
|
||||||
"keepalive-for-react-router": "^5.0.7",
|
"keepalive-for-react-router": "^5.0.7",
|
||||||
"monaco-editor": "0.56.0",
|
"monaco-editor": "0.56.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-css-marquee": "^1.0.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"sortablejs": "^1.15.7",
|
|
||||||
"zrender": "^6.1.0"
|
"zrender": "^6.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@types/sortablejs": "^1.15.9",
|
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"code-inspector-plugin": "^1.6.6",
|
"code-inspector-plugin": "^1.6.6",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { normalizePath, type Plugin } from "vite"
|
|||||||
const traverse = traverseModule.default ?? traverseModule
|
const traverse = traverseModule.default ?? traverseModule
|
||||||
|
|
||||||
const SOURCE_LOCALE = "zh-CN"
|
const SOURCE_LOCALE = "zh-CN"
|
||||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "pt-BR"]
|
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
|
||||||
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
|
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
|
||||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
||||||
const AUTO_IMPORT_NAME = "__staticI18nT"
|
const AUTO_IMPORT_NAME = "__staticI18nT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cursor-byok-desktop"
|
name = "cursor-byok-desktop"
|
||||||
version = "1.0.1"
|
version = "0.1.0-beta.10"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -14,9 +14,6 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
cursor-server = { path = "../../../server" }
|
cursor-server = { path = "../../../server" }
|
||||||
libc = "0.2"
|
|
||||||
mimalloc = "0.1"
|
|
||||||
rfd = "0.15"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = ["tray-icon"] }
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
@@ -26,14 +23,8 @@ tauri-plugin-opener = "2"
|
|||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tempfile = "3"
|
|
||||||
tokio = { version = "1", features = ["time"] }
|
tokio = { version = "1", features = ["time"] }
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-appender = "0.2"
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
url = "2"
|
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,9 +1,5 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
let manifest = tauri_build::AppManifest::new().commands(&[
|
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
|
||||||
"open_terminal_with_command",
|
|
||||||
"check_portable_update",
|
|
||||||
"install_portable_update",
|
|
||||||
]);
|
|
||||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
|
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
|
||||||
.expect("failed to build Tauri application")
|
.expect("failed to build Tauri application")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,7 @@
|
|||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:app:allow-set-dock-visibility",
|
|
||||||
"allow-open-terminal-with-command",
|
"allow-open-terminal-with-command",
|
||||||
"allow-check-portable-update",
|
|
||||||
"allow-install-portable-update",
|
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
"autostart:default",
|
"autostart:default",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
process::{Command, ExitCode},
|
process::Command,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Mutex,
|
Mutex,
|
||||||
@@ -15,10 +15,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
|
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
|
||||||
WebviewWindow, WebviewWindowBuilder,
|
WebviewWindowBuilder,
|
||||||
};
|
};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
#[cfg(dev)]
|
#[cfg(dev)]
|
||||||
use cursor_server::config::ConsoleSource;
|
use cursor_server::config::ConsoleSource;
|
||||||
@@ -26,17 +27,14 @@ use cursor_server::{App, Config, Result};
|
|||||||
|
|
||||||
#[cfg(not(dev))]
|
#[cfg(not(dev))]
|
||||||
use crate::frontend;
|
use crate::frontend;
|
||||||
use crate::startup::{self, StartupDiagnostics};
|
|
||||||
use crate::tray;
|
use crate::tray;
|
||||||
|
|
||||||
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
|
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
|
||||||
const AUTOSTART_ARG: &str = "--autostart";
|
|
||||||
|
|
||||||
struct DesktopRuntime {
|
struct DesktopRuntime {
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||||
exiting: AtomicBool,
|
exiting: AtomicBool,
|
||||||
server_addr: std::net::SocketAddr,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -54,30 +52,12 @@ fn open_terminal_with_command(command: String) -> tauri::Result<()> {
|
|||||||
.spawn()?;
|
.spawn()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
{
|
{
|
||||||
const TERMINALS: &[(&str, &[&str])] = &[
|
let _ = command;
|
||||||
("x-terminal-emulator", &["-e"]),
|
|
||||||
("gnome-terminal", &["--"]),
|
|
||||||
("konsole", &["-e"]),
|
|
||||||
("xfce4-terminal", &["--execute"]),
|
|
||||||
("alacritty", &["-e"]),
|
|
||||||
("kitty", &[]),
|
|
||||||
];
|
|
||||||
let script = format!("{command}; exec bash");
|
|
||||||
for (terminal, separator) in TERMINALS {
|
|
||||||
let mut process = Command::new(terminal);
|
|
||||||
process.args(*separator);
|
|
||||||
process.arg("bash").arg("-c").arg(&script);
|
|
||||||
match process.spawn() {
|
|
||||||
Ok(_) => return Ok(()),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
|
||||||
Err(error) => return Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tauri::Error::from(std::io::Error::new(
|
Err(tauri::Error::from(std::io::Error::new(
|
||||||
std::io::ErrorKind::NotFound,
|
std::io::ErrorKind::Unsupported,
|
||||||
"no supported terminal emulator found",
|
"terminal guidance is unsupported on this platform",
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,10 +96,7 @@ fn desktop_api_router(app: AppHandle) -> Router {
|
|||||||
.layer(Extension(app))
|
.layer(Extension(app))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_main_window(
|
fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> {
|
||||||
app: &AppHandle,
|
|
||||||
address: std::net::SocketAddr,
|
|
||||||
) -> tauri::Result<WebviewWindow> {
|
|
||||||
let url = format!("http://{address}/__byok-api__/")
|
let url = format!("http://{address}/__byok-api__/")
|
||||||
.parse()
|
.parse()
|
||||||
.expect("local frontend URL");
|
.expect("local frontend URL");
|
||||||
@@ -131,93 +108,41 @@ fn create_main_window(
|
|||||||
.background_color(Color(20, 20, 20, 255))
|
.background_color(Color(20, 20, 20, 255))
|
||||||
.decorations(cfg!(target_os = "macos"))
|
.decorations(cfg!(target_os = "macos"))
|
||||||
.shadow(true)
|
.shadow(true)
|
||||||
.resizable(true)
|
.resizable(true);
|
||||||
.visible(false);
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let builder = builder
|
let builder = builder
|
||||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||||
.hidden_title(true);
|
.hidden_title(true);
|
||||||
|
|
||||||
builder.build()
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() -> ExitCode {
|
pub fn run() {
|
||||||
let diagnostics = match StartupDiagnostics::initialize() {
|
tracing_subscriber::registry()
|
||||||
Ok(diagnostics) => diagnostics,
|
.with(
|
||||||
Err(error) => {
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
startup::report_logging_failure(error.as_ref());
|
.unwrap_or_else(|_| "cursor_server=info".into()),
|
||||||
return ExitCode::FAILURE;
|
)
|
||||||
}
|
.with(tracing_subscriber::fmt::layer())
|
||||||
};
|
.init();
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let open_file_limit = match crate::resource_limits::raise_open_file_limit() {
|
|
||||||
Ok(limit) => limit,
|
|
||||||
Err(error) => {
|
|
||||||
diagnostics.report_fatal(&error);
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tracing::info!(
|
|
||||||
requested = crate::resource_limits::REQUESTED_OPEN_FILE_LIMIT,
|
|
||||||
previous = open_file_limit.previous,
|
|
||||||
effective = open_file_limit.effective,
|
|
||||||
hard = open_file_limit.hard,
|
|
||||||
"open file limit configured"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
version = env!("CARGO_PKG_VERSION"),
|
|
||||||
os = std::env::consts::OS,
|
|
||||||
architecture = std::env::consts::ARCH,
|
|
||||||
log_directory = %diagnostics.log_directory().display(),
|
|
||||||
"desktop starting"
|
|
||||||
);
|
|
||||||
|
|
||||||
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
|
||||||
|
|
||||||
let app = tauri::Builder::default()
|
let app = tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||||
open_terminal_with_command,
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||||
crate::update::check_portable_update,
|
tray::show_main_window(app);
|
||||||
crate::update::install_portable_update,
|
|
||||||
])
|
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
|
||||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
|
||||||
let _ = open_main_window(app);
|
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.setup(move |app| {
|
.setup(|app| {
|
||||||
app.handle().plugin(tauri_plugin_autostart::init(
|
app.handle().plugin(tauri_plugin_autostart::init(
|
||||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||||
Some(vec![AUTOSTART_ARG]),
|
None,
|
||||||
))?;
|
))?;
|
||||||
let config = {
|
let config = Config::desktop()?;
|
||||||
let mut config = Config::desktop()?;
|
|
||||||
// 插件的 minAppVersion 按桌面应用版本判定,而不是内嵌 server 库的版本。
|
|
||||||
config.app_version = env!("CARGO_PKG_VERSION").into();
|
|
||||||
config
|
|
||||||
};
|
|
||||||
#[cfg(dev)]
|
#[cfg(dev)]
|
||||||
let config = {
|
let config = {
|
||||||
let mut config = config;
|
let mut config = config;
|
||||||
@@ -235,12 +160,6 @@ pub fn run() -> ExitCode {
|
|||||||
let listener = tauri::async_runtime::block_on(server.bind())?;
|
let listener = tauri::async_runtime::block_on(server.bind())?;
|
||||||
let address = listener.local_addr()?;
|
let address = listener.local_addr()?;
|
||||||
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
|
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
|
||||||
let desktop_settings =
|
|
||||||
tauri::async_runtime::block_on(server.store().desktop_settings())
|
|
||||||
.unwrap_or_default();
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
app.handle()
|
|
||||||
.set_dock_visibility(desktop_settings.show_dock_icon)?;
|
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
let server_shutdown = shutdown.clone();
|
let server_shutdown = shutdown.clone();
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
@@ -256,65 +175,55 @@ pub fn run() -> ExitCode {
|
|||||||
shutdown,
|
shutdown,
|
||||||
server: Mutex::new(Some(task)),
|
server: Mutex::new(Some(task)),
|
||||||
exiting: AtomicBool::new(false),
|
exiting: AtomicBool::new(false),
|
||||||
server_addr: address,
|
|
||||||
});
|
});
|
||||||
if desktop_settings.silent_start && started_by_autostart {
|
create_main_window(app.handle(), address)?;
|
||||||
tracing::info!("silent autostart enabled; starting without the main window");
|
|
||||||
} else {
|
|
||||||
open_main_window(app.handle())?;
|
|
||||||
}
|
|
||||||
tray::create(app)?;
|
tray::create(app)?;
|
||||||
crate::update::signal_ready_if_requested()?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.build(tauri::generate_context!());
|
.build(tauri::generate_context!())
|
||||||
let app = match app {
|
.expect("failed to build Cursor BYOK desktop app");
|
||||||
Ok(app) => app,
|
|
||||||
Err(error) => {
|
|
||||||
diagnostics.report_fatal(&error);
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
app.run(|app, event| match event {
|
app.run(|app, event| match event {
|
||||||
// code 为 None 表示所有窗口已被关闭(轻量模式),阻止退出,
|
RunEvent::WindowEvent {
|
||||||
// 转发服务继续在托盘后台运行;code 为 Some 时是显式退出请求。
|
label,
|
||||||
RunEvent::ExitRequested { code, api, .. } => match code {
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
None => api.prevent_exit(),
|
..
|
||||||
Some(_) => {
|
} if label == MAIN_WINDOW_LABEL => {
|
||||||
let runtime = app.state::<DesktopRuntime>();
|
let runtime = app.state::<DesktopRuntime>();
|
||||||
if !runtime.exiting.swap(true, Ordering::AcqRel) {
|
if !runtime.exiting.load(Ordering::Acquire) {
|
||||||
api.prevent_exit();
|
api.prevent_close();
|
||||||
runtime.shutdown.cancel();
|
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||||
let server = runtime.server.lock().expect("server lock poisoned").take();
|
let _ = window.hide();
|
||||||
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")]
|
#[cfg(target_os = "macos")]
|
||||||
RunEvent::Reopen {
|
RunEvent::Reopen {
|
||||||
has_visible_windows: false,
|
has_visible_windows: false,
|
||||||
..
|
..
|
||||||
} => {
|
} => tray::show_main_window(app),
|
||||||
let _ = open_main_window(app);
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
mod desktop;
|
mod desktop;
|
||||||
#[cfg(not(dev))]
|
#[cfg(not(dev))]
|
||||||
mod frontend;
|
mod frontend;
|
||||||
mod resource_limits;
|
|
||||||
mod startup;
|
|
||||||
mod tray;
|
mod tray;
|
||||||
mod update;
|
|
||||||
|
|
||||||
// mimalloc 在释放时主动向操作系统归还内存,避免 glibc 保留页导致
|
pub use desktop::run;
|
||||||
// 关闭窗口后 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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() -> std::process::ExitCode {
|
fn main() {
|
||||||
cursor_byok_desktop::run()
|
cursor_byok_desktop::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
//! 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,139 +0,0 @@
|
|||||||
//! Captures startup diagnostics before the database and desktop runtime initialize.
|
|
||||||
use std::{error::Error, path::PathBuf};
|
|
||||||
|
|
||||||
use rfd::{MessageButtons, MessageDialog, MessageLevel};
|
|
||||||
use tracing_appender::{
|
|
||||||
non_blocking::WorkerGuard,
|
|
||||||
rolling::{RollingFileAppender, Rotation},
|
|
||||||
};
|
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|
||||||
|
|
||||||
const LOG_DIRECTORY_NAME: &str = "logs";
|
|
||||||
const LOG_FILE_PREFIX: &str = "cursor-byok";
|
|
||||||
const LOG_FILE_SUFFIX: &str = "log";
|
|
||||||
const RETAINED_LOG_FILES: usize = 15;
|
|
||||||
|
|
||||||
type BoxError = Box<dyn Error + Send + Sync>;
|
|
||||||
|
|
||||||
pub(crate) struct StartupDiagnostics {
|
|
||||||
log_directory: PathBuf,
|
|
||||||
_writer_guard: WorkerGuard,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StartupDiagnostics {
|
|
||||||
pub(crate) fn initialize() -> Result<Self, BoxError> {
|
|
||||||
let log_directory = cursor_server::config::managed_data_dir()?.join(LOG_DIRECTORY_NAME);
|
|
||||||
std::fs::create_dir_all(&log_directory)?;
|
|
||||||
|
|
||||||
let file_appender = RollingFileAppender::builder()
|
|
||||||
.rotation(Rotation::DAILY)
|
|
||||||
.filename_prefix(LOG_FILE_PREFIX)
|
|
||||||
.filename_suffix(LOG_FILE_SUFFIX)
|
|
||||||
.max_log_files(RETAINED_LOG_FILES)
|
|
||||||
.build(&log_directory)?;
|
|
||||||
let (file_writer, writer_guard) = tracing_appender::non_blocking(file_appender);
|
|
||||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| "cursor_byok_desktop=info,cursor_server=info".into());
|
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(filter)
|
|
||||||
.with(tracing_subscriber::fmt::layer())
|
|
||||||
.with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.with_ansi(false)
|
|
||||||
.with_writer(file_writer),
|
|
||||||
)
|
|
||||||
.try_init()?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
log_directory,
|
|
||||||
_writer_guard: writer_guard,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn log_directory(&self) -> &std::path::Path {
|
|
||||||
&self.log_directory
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn report_fatal(&self, error: &(dyn Error + 'static)) {
|
|
||||||
let details = error_chain(error);
|
|
||||||
tracing::error!(
|
|
||||||
error = %details,
|
|
||||||
log_directory = %self.log_directory.display(),
|
|
||||||
"desktop failed to start"
|
|
||||||
);
|
|
||||||
show_fatal_dialog(&details, Some(&self.log_directory));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn report_logging_failure(error: &(dyn Error + 'static)) {
|
|
||||||
let details = error_chain(error);
|
|
||||||
eprintln!("Cursor BYOK failed to initialize logging: {details}");
|
|
||||||
show_fatal_dialog(&details, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_fatal_dialog(details: &str, log_directory: Option<&std::path::Path>) {
|
|
||||||
let log_guidance = match log_directory {
|
|
||||||
Some(directory) => format!(
|
|
||||||
"日志目录 / Log directory:\n{}\n\n请将最新的日志文件发送给开发者。\nPlease send the latest log file to the developer.",
|
|
||||||
directory.display()
|
|
||||||
),
|
|
||||||
None => "日志系统也未能启动,因此没有生成日志文件。\nLogging also failed to initialize, so no log file was created."
|
|
||||||
.to_owned(),
|
|
||||||
};
|
|
||||||
let description = format!(
|
|
||||||
"Cursor BYOK 无法启动 / failed to start.\n\n错误 / Error:\n{details}\n\n{log_guidance}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = MessageDialog::new()
|
|
||||||
.set_level(MessageLevel::Error)
|
|
||||||
.set_title("Cursor BYOK 启动失败 / Startup Error")
|
|
||||||
.set_description(description)
|
|
||||||
.set_buttons(MessageButtons::Ok)
|
|
||||||
.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn error_chain(error: &(dyn Error + 'static)) -> String {
|
|
||||||
let mut details = error.to_string();
|
|
||||||
let mut source = error.source();
|
|
||||||
while let Some(cause) = source {
|
|
||||||
details.push_str("\nCaused by: ");
|
|
||||||
details.push_str(&cause.to_string());
|
|
||||||
source = cause.source();
|
|
||||||
}
|
|
||||||
details
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct OuterError(std::io::Error);
|
|
||||||
|
|
||||||
impl fmt::Display for OuterError {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter.write_str("database initialization failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error for OuterError {
|
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
||||||
Some(&self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fatal_report_includes_the_complete_error_chain() {
|
|
||||||
let error = OuterError(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::PermissionDenied,
|
|
||||||
"database file is read-only",
|
|
||||||
));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
error_chain(&error),
|
|
||||||
"database initialization failed\nCaused by: database file is read-only"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||||
tray::TrayIconBuilder,
|
tray::TrayIconBuilder,
|
||||||
App,
|
App, AppHandle, Manager,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
|
||||||
|
|
||||||
use crate::desktop::open_main_window;
|
use crate::desktop::MAIN_WINDOW_LABEL;
|
||||||
|
|
||||||
const OPEN_MENU_ID: &str = "tray-open";
|
const OPEN_MENU_ID: &str = "tray-open";
|
||||||
const QUIT_MENU_ID: &str = "tray-quit";
|
const QUIT_MENU_ID: &str = "tray-quit";
|
||||||
@@ -24,9 +24,7 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
|||||||
.menu(&menu)
|
.menu(&menu)
|
||||||
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
||||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||||
OPEN_MENU_ID => {
|
OPEN_MENU_ID => show_main_window(app),
|
||||||
let _ = open_main_window(app);
|
|
||||||
}
|
|
||||||
QUIT_MENU_ID => app.exit(0),
|
QUIT_MENU_ID => app.exit(0),
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
@@ -38,7 +36,7 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
|||||||
..
|
..
|
||||||
} = event
|
} = event
|
||||||
{
|
{
|
||||||
let _ = open_main_window(tray.app_handle());
|
show_main_window(tray.app_handle());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
@@ -47,3 +45,11 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
|
|||||||
.build(app)?;
|
.build(app)?;
|
||||||
Ok(())
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
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",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Cursor BYOK",
|
"productName": "Cursor BYOK",
|
||||||
"version": "1.0.1",
|
"version": "0.1.0-beta.10",
|
||||||
"identifier": "dev.cursorbyok.desktop",
|
"identifier": "dev.cursorbyok.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [],
|
"windows": [],
|
||||||
"security": {
|
"security": {
|
||||||
"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:*",
|
"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:*",
|
||||||
"dangerousDisableAssetCspModification": [
|
"dangerousDisableAssetCspModification": [
|
||||||
"style-src"
|
"style-src"
|
||||||
]
|
]
|
||||||
|
|||||||
+21
-49
@@ -1,33 +1,30 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { TooltipProvider } from "./shared/ui/Tooltip";
|
import { MessageProvider } from "./components/ui/MessageProvider";
|
||||||
import { MessageProvider } from "./shared/ui/MessageProvider";
|
import { useMessage } from "./components/ui/message";
|
||||||
import { useMessage } from "./shared/ui/message";
|
import { AppFrame } from "./layouts/AppFrame";
|
||||||
import { AppFrame } from "./shell/AppFrame";
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
import { AppLayout } from "./shell/AppLayout";
|
import { CallsPage } from "./pages/CallsPage";
|
||||||
import { CallsPage } from "./features/calls/CallsPage";
|
import { CallDetailsPage } from "./pages/CallDetailsPage";
|
||||||
import { CallDetailsPage } from "./features/calls/CallDetailsPage";
|
import { CursorSettingsPage } from "./pages/CursorSettingsPage";
|
||||||
import { CursorSettingsPage } from "./features/models/CursorSettingsPage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
import { HomePage } from "./features/home/HomePage";
|
import { ProvidersPage } from "./pages/ProvidersPage";
|
||||||
import { PluginManagementPage } from "./features/plugins/PluginManagementPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { SettingsPage } from "./features/settings/SettingsPage";
|
import { useAppStore } from "./store/appStore";
|
||||||
import { useAppStore } from "./shared/store/appStore";
|
import { updateStore } from "./store/updateStore";
|
||||||
import { updateStore } from "./shared/store/updateStore";
|
|
||||||
|
|
||||||
const AUTO_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="calls/:callId" element={<CallDetailsPage />} />
|
<Route path="calls/:callId" element={<CallDetailsPage />} />
|
||||||
<Route element={<AppFrame />}>
|
<Route element={<AppFrame />}>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
|
<Route path="providers" element={<ProvidersPage />} />
|
||||||
<Route path="calls" element={<CallsPage />} />
|
<Route path="calls" element={<CallsPage />} />
|
||||||
<Route path="harness/cursor" element={<CursorSettingsPage />} />
|
<Route path="harness/cursor" element={<CursorSettingsPage />} />
|
||||||
<Route path="plugins" element={<PluginManagementPage />} />
|
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
@@ -35,14 +32,13 @@ export function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
<AppMessages />
|
<AppMessages />
|
||||||
</TooltipProvider>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppMessages() {
|
function AppMessages() {
|
||||||
const { error } = useAppStore();
|
const { error } = useAppStore();
|
||||||
const previousError = useRef<string | null>(null);
|
const previousError = useRef<string | null>(null);
|
||||||
const lastAutomaticUpdateCheckAt = useRef(0);
|
|
||||||
const showMessage = useMessage();
|
const showMessage = useMessage();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,36 +47,12 @@ function AppMessages() {
|
|||||||
}, [error, showMessage]);
|
}, [error, showMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
void updateStore.check().then((version) => {
|
||||||
const checkAutomatically = () => {
|
if (!version) return;
|
||||||
const now = Date.now();
|
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
|
||||||
if (now - lastAutomaticUpdateCheckAt.current < AUTO_UPDATE_CHECK_INTERVAL_MS) return;
|
}).catch(() => {
|
||||||
lastAutomaticUpdateCheckAt.current = now;
|
// Startup checks are best-effort; manual checks in Settings report errors.
|
||||||
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]);
|
}, [showMessage]);
|
||||||
|
|
||||||
return <MessageProvider />;
|
return <MessageProvider />;
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import type { AdRuntime } from "./components/ads/types";
|
||||||
|
import type { Locale } from "./i18n/runtime";
|
||||||
|
|
||||||
|
export type ProviderType = "openai-chat" | "openai-responses" | "anthropic";
|
||||||
|
|
||||||
|
export interface Provider {
|
||||||
|
provider_id: number;
|
||||||
|
name: string;
|
||||||
|
provider_type: ProviderType;
|
||||||
|
base_url: string;
|
||||||
|
api_key?: string;
|
||||||
|
has_api_key: boolean;
|
||||||
|
custom_headers: Record<string, string | null>;
|
||||||
|
extra_params: Record<string, unknown>;
|
||||||
|
created_at_ms: number;
|
||||||
|
updated_at_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderInput {
|
||||||
|
name: string;
|
||||||
|
provider_type: ProviderType;
|
||||||
|
base_url: string;
|
||||||
|
api_key?: string;
|
||||||
|
custom_headers: Record<string, string | null>;
|
||||||
|
extra_params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Model {
|
||||||
|
model_hash: string;
|
||||||
|
provider_id: number;
|
||||||
|
model_id: string;
|
||||||
|
display_name: string;
|
||||||
|
endpoint_type: ProviderType;
|
||||||
|
request_url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
context_window_tokens: number | null;
|
||||||
|
max_output_tokens: number | null;
|
||||||
|
reasoning_enabled: boolean;
|
||||||
|
reasoning_effort: string | null;
|
||||||
|
supports_image_generation: boolean;
|
||||||
|
created_at_ms: number;
|
||||||
|
updated_at_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
model_id: string;
|
||||||
|
display_name: string;
|
||||||
|
endpoint_type: ProviderType;
|
||||||
|
request_url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
context_window_tokens: number | null;
|
||||||
|
max_output_tokens: number | null;
|
||||||
|
reasoning_enabled: boolean;
|
||||||
|
reasoning_effort: string | null;
|
||||||
|
supports_image_generation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelConnectivityResult {
|
||||||
|
duration_ms: number;
|
||||||
|
first_text_ms: number | null;
|
||||||
|
output_tokens: number;
|
||||||
|
tokens_per_second: number;
|
||||||
|
tokens_estimated: boolean;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported";
|
||||||
|
export type IntegrationState = "disabled" | "enabled" | "degraded";
|
||||||
|
export interface CursorHarnessStatus {
|
||||||
|
platform: string;
|
||||||
|
ca: CaState;
|
||||||
|
configured_models: number;
|
||||||
|
enabled_models: number;
|
||||||
|
integration: IntegrationState;
|
||||||
|
proxy_url: string | null;
|
||||||
|
ca_install_command: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortSettings {
|
||||||
|
proxy_port: number;
|
||||||
|
service_port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatisticsStorage {
|
||||||
|
bytes: number;
|
||||||
|
call_count: number;
|
||||||
|
trace_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyMode = "system" | "custom";
|
||||||
|
|
||||||
|
export interface ProxySettings {
|
||||||
|
mode: ProxyMode;
|
||||||
|
address: string;
|
||||||
|
auth_enabled: boolean;
|
||||||
|
username: string;
|
||||||
|
has_password: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxySettingsInput {
|
||||||
|
mode: ProxyMode;
|
||||||
|
address: string;
|
||||||
|
auth_enabled: boolean;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabMode = "public" | "direct" | "custom";
|
||||||
|
|
||||||
|
export interface TabSettings {
|
||||||
|
mode: TabMode;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewMetrics {
|
||||||
|
llm_calls: number;
|
||||||
|
successful_calls: number;
|
||||||
|
failed_calls: number;
|
||||||
|
token_usage: number;
|
||||||
|
prompt_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
cache_read_tokens: number;
|
||||||
|
cache_write_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenUsageGranularity = "minute" | "hour" | "day";
|
||||||
|
|
||||||
|
export interface OverviewTokenUsageBucket {
|
||||||
|
bucket_start_ms: number;
|
||||||
|
input_tokens: number;
|
||||||
|
cache_read_tokens: number;
|
||||||
|
cache_write_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Overview {
|
||||||
|
metrics: OverviewMetrics;
|
||||||
|
token_usage_granularity: TokenUsageGranularity;
|
||||||
|
token_usage_series: OverviewTokenUsageBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderSelection =
|
||||||
|
| { kind: "existing"; provider_id: number }
|
||||||
|
| { kind: "new"; input: ProviderInput };
|
||||||
|
|
||||||
|
export interface LlmCall {
|
||||||
|
call_kind: "provider_llm" | "cursor_official";
|
||||||
|
route: "local_byok" | "cursor_official";
|
||||||
|
call_id: string;
|
||||||
|
run_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
provider_call_index: number;
|
||||||
|
model_hash: string | null;
|
||||||
|
provider_type: string;
|
||||||
|
provider_url: string;
|
||||||
|
request_type: string;
|
||||||
|
request_url: string;
|
||||||
|
model_id: string;
|
||||||
|
display_name: string;
|
||||||
|
reasoning_effort: string | null;
|
||||||
|
fast: boolean | null;
|
||||||
|
status: string;
|
||||||
|
finish_reason: string | null;
|
||||||
|
created_at_ms: number;
|
||||||
|
ttfb_ms: number | null;
|
||||||
|
ttft_ms: number | null;
|
||||||
|
duration_ms: number | null;
|
||||||
|
input_tokens: number | null;
|
||||||
|
output_tokens: number | null;
|
||||||
|
total_tokens: number | null;
|
||||||
|
cache_read_tokens: number | null;
|
||||||
|
cache_write_tokens: number | null;
|
||||||
|
reasoning_tokens: number | null;
|
||||||
|
message_count: number;
|
||||||
|
tool_count: number;
|
||||||
|
http_status: number | null;
|
||||||
|
error_kind: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
detailed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallDetail {
|
||||||
|
call: LlmCall;
|
||||||
|
request: { headers: unknown; body: unknown; byte_count: number } | null;
|
||||||
|
response_chunks: Array<{ seq: number; received_offset_ms: number; data: string; byte_count: number }>;
|
||||||
|
cursor_trace: {
|
||||||
|
trace: {
|
||||||
|
request_id: string;
|
||||||
|
conversation_id: string | null;
|
||||||
|
route: "local_byok" | "cursor_official";
|
||||||
|
model_id: string | null;
|
||||||
|
status: string;
|
||||||
|
request_bytes: number;
|
||||||
|
response_bytes: number;
|
||||||
|
response_event_count: number;
|
||||||
|
http_status: number | null;
|
||||||
|
received_at_ms: number;
|
||||||
|
first_response_at_ms: number | null;
|
||||||
|
finished_at_ms: number | null;
|
||||||
|
error_message: string | null;
|
||||||
|
};
|
||||||
|
artifacts: Array<{
|
||||||
|
seq: number;
|
||||||
|
artifact_type: string;
|
||||||
|
source: string;
|
||||||
|
metadata: unknown;
|
||||||
|
created_at_ms: number;
|
||||||
|
byte_count: number;
|
||||||
|
encoding: "utf8" | "base64";
|
||||||
|
data: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packagedDesktop = "__TAURI_INTERNALS__" in window
|
||||||
|
|| window.location.protocol === "tauri:"
|
||||||
|
|| window.location.hostname === "tauri.localhost";
|
||||||
|
const API_ROOT = "/__byok-api__/api";
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_ROOT}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: init?.body ? { "content-type": "application/json", ...init.headers } : init?.headers,
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
throw new Error(t("无法连接本地管理服务"), { cause });
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
let message = body;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as { message?: unknown };
|
||||||
|
if (typeof parsed.message === "string") message = parsed.message;
|
||||||
|
} catch {
|
||||||
|
// Plain-text errors are already suitable for display.
|
||||||
|
}
|
||||||
|
throw new Error(message || `${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
ads: (disabledAdIds: Iterable<string>, locale: Locale) => {
|
||||||
|
const value = [...disabledAdIds].join(",");
|
||||||
|
return request<AdRuntime>("/ads", {
|
||||||
|
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 }) }),
|
||||||
|
providers: () => request<Provider[]>("/providers"),
|
||||||
|
createProvider: (input: ProviderInput) => request<Provider>("/providers", { method: "POST", body: JSON.stringify(input) }),
|
||||||
|
updateProvider: (id: number, input: ProviderInput) => request<Provider>(`/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||||
|
deleteProvider: (id: number) => request<void>(`/providers/${id}`, { method: "DELETE" }),
|
||||||
|
discoverModels: (id: number) => request<{ models: string[] }>(`/providers/${id}/models/discover`, { method: "POST" }),
|
||||||
|
saveModels: (id: number, models: ModelInput[]) => request<Model[]>(`/providers/${id}/models`, { method: "POST", body: JSON.stringify({ models }) }),
|
||||||
|
models: () => request<Model[]>("/models"),
|
||||||
|
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
|
||||||
|
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
|
||||||
|
testModel: (hash: string) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
|
||||||
|
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: 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.providerIds?.length) params.set("provider_ids", JSON.stringify(filter.providerIds));
|
||||||
|
}
|
||||||
|
const query = params.size ? `?${params}` : "";
|
||||||
|
return request<Overview>(`/overview${query}`);
|
||||||
|
},
|
||||||
|
createCursorModels: (provider: ProviderSelection, models: ModelInput[]) => request<{ provider: Provider; models: Model[] }>("/harness/cursor/models", { method: "POST", body: JSON.stringify({ provider, models }) }),
|
||||||
|
discoverCursorModels: (provider: ProviderSelection) => request<{ models: string[] }>("/harness/cursor/models/discover", { method: "POST", body: JSON.stringify({ provider }) }),
|
||||||
|
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||||
|
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||||
|
openCursorCaInstallTerminal: async (command: string) => {
|
||||||
|
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
await invoke("open_terminal_with_command", { command });
|
||||||
|
},
|
||||||
|
copyCursorText: async (text: string) => {
|
||||||
|
if (!packagedDesktop) throw new Error(t("请在桌面应用中复制到系统剪贴板"));
|
||||||
|
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");
|
||||||
|
await writeText(text);
|
||||||
|
},
|
||||||
|
setCursorEnabled: (enabled: boolean) => request<CursorHarnessStatus>("/harness/cursor/enabled", { method: "PUT", body: JSON.stringify({ enabled }) }),
|
||||||
|
calls: () => request<LlmCall[]>("/llm-calls?limit=200"),
|
||||||
|
call: (id: string) => request<CallDetail>(`/llm-calls/${encodeURIComponent(id)}`),
|
||||||
|
openCallDetails: async (id: string) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.hash = `/calls/${encodeURIComponent(id)}`;
|
||||||
|
await request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url: url.toString() }) });
|
||||||
|
},
|
||||||
|
openExternalUrl: (url: string) => request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url }) }),
|
||||||
|
observability: () => request<{ detailed: boolean }>("/settings/observability"),
|
||||||
|
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) }),
|
||||||
|
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
|
||||||
|
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
|
||||||
|
proxySettings: () => request<ProxySettings>("/settings/proxy"),
|
||||||
|
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
|
tabSettings: () => request<TabSettings>("/settings/tab"),
|
||||||
|
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
|
||||||
|
};
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@use "../../styles/typography" as type;
|
@use "../styles/typography" as type;
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
section { min-width: 0; }
|
section { min-width: 0; }
|
||||||
+3
-4
@@ -1,6 +1,6 @@
|
|||||||
import type { CallDetail } from "../../shared/api";
|
import type { CallDetail } from "../api";
|
||||||
import { JsonEditor } from "../../shared/ui/JsonEditor";
|
import { JsonEditor } from "./ui/JsonEditor";
|
||||||
import { Tabs, type TabItem } from "../../shared/ui/Tabs";
|
import { Tabs, type TabItem } from "./ui/Tabs";
|
||||||
import styles from "./CallDetails.module.scss";
|
import styles from "./CallDetails.module.scss";
|
||||||
|
|
||||||
const show = (value: string | number | null) => value ?? "-";
|
const show = (value: string | number | null) => value ?? "-";
|
||||||
@@ -32,7 +32,6 @@ export function CallDetails({ detail }: { detail: CallDetail }) {
|
|||||||
["Created At", `${call.created_at_ms} · ${new Date(call.created_at_ms).toLocaleString()}`],
|
["Created At", `${call.created_at_ms} · ${new Date(call.created_at_ms).toLocaleString()}`],
|
||||||
[t("耗时"), timing(call.duration_ms)],
|
[t("耗时"), timing(call.duration_ms)],
|
||||||
["TTFB", timing(call.ttfb_ms)],
|
["TTFB", timing(call.ttfb_ms)],
|
||||||
["TTFR", timing(call.ttfr_ms)],
|
|
||||||
["TTFT", timing(call.ttft_ms)],
|
["TTFT", timing(call.ttft_ms)],
|
||||||
["Input Token", show(call.input_tokens)],
|
["Input Token", show(call.input_tokens)],
|
||||||
["Output Token", show(call.output_tokens)],
|
["Output Token", show(call.output_tokens)],
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@use "../../styles/typography" as type;
|
@use "../styles/typography" as type;
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
+6
-11
@@ -1,9 +1,9 @@
|
|||||||
import type { LlmCall } from "../../shared/api";
|
import type { LlmCall } from "../api";
|
||||||
import controls from "../../shared/ui/Controls.module.scss";
|
import controls from "./ui/Controls.module.scss";
|
||||||
import { DataTable, type DataTableColumn } from "../../shared/ui/DataTable";
|
import { DataTable, type DataTableColumn } from "./ui/DataTable";
|
||||||
import { Icon } from "../../shared/ui/Icon";
|
import { Icon } from "./ui/Icon";
|
||||||
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
|
import { TooltipTrigger } from "./ui/TooltipTrigger";
|
||||||
import { eyeIcon } from "../../shared/ui/icons";
|
import { eyeIcon } from "./ui/icons";
|
||||||
import styles from "./CallTable.module.scss";
|
import styles from "./CallTable.module.scss";
|
||||||
|
|
||||||
const value = (input: string | number | null) => input ?? "-";
|
const value = (input: string | number | null) => input ?? "-";
|
||||||
@@ -87,11 +87,6 @@ export function CallTable({ calls, onDetails }: { calls: LlmCall[]; onDetails: (
|
|||||||
header: "TTFB",
|
header: "TTFB",
|
||||||
render: (call) => milliseconds(call.ttfb_ms),
|
render: (call) => milliseconds(call.ttfb_ms),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "ttfr",
|
|
||||||
header: "TTFR",
|
|
||||||
render: (call) => milliseconds(call.ttfr_ms),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "ttft",
|
key: "ttft",
|
||||||
header: "TTFT",
|
header: "TTFT",
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.form { grid-template-columns: 1fr; }
|
||||||
|
.fullWidth { grid-column: auto; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ProviderInput, ProviderType } from "../api";
|
||||||
|
import { FormField, SecretTextInput, TextInput } from "./ui/FormControls";
|
||||||
|
import { JsonEditor } from "./ui/JsonEditor";
|
||||||
|
import { Select } from "./ui/Select";
|
||||||
|
import { claudeIcon, openAiIcon } from "./ui/icons";
|
||||||
|
import styles from "./ProviderEditor.module.scss";
|
||||||
|
|
||||||
|
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
|
||||||
|
value: ProviderInput;
|
||||||
|
headersText: string;
|
||||||
|
extraText: string;
|
||||||
|
editing: boolean;
|
||||||
|
onChange: (value: ProviderInput) => void;
|
||||||
|
onHeadersChange: (value: string) => void;
|
||||||
|
onExtraChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
|
||||||
|
return <div className={styles.form}>
|
||||||
|
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
|
||||||
|
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
|
||||||
|
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
|
||||||
|
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
|
||||||
|
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||||
|
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
|
||||||
|
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
|
||||||
|
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><SecretTextInput autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
|
||||||
|
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
|
||||||
|
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@use "../styles/typography" as type;
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Provider } from "../api";
|
||||||
|
import controls from "./ui/Controls.module.scss";
|
||||||
|
import { DataTable, type DataTableColumn } from "./ui/DataTable";
|
||||||
|
import { Icon } from "./ui/Icon";
|
||||||
|
import { TooltipTrigger } from "./ui/TooltipTrigger";
|
||||||
|
import { editIcon, trashIcon } from "./ui/icons";
|
||||||
|
import styles from "./ProviderTable.module.scss";
|
||||||
|
|
||||||
|
const json = (value: unknown) => JSON.stringify(value);
|
||||||
|
|
||||||
|
export function ProviderTable({ providers, onEdit, onDelete }: {
|
||||||
|
providers: Provider[];
|
||||||
|
onEdit: (provider: Provider) => void;
|
||||||
|
onDelete: (provider: Provider) => void;
|
||||||
|
}) {
|
||||||
|
const columns: DataTableColumn<Provider>[] = [
|
||||||
|
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
|
||||||
|
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
|
||||||
|
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
|
||||||
|
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
|
||||||
|
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
|
||||||
|
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
|
||||||
|
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
|
||||||
|
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
|
||||||
|
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
|
||||||
|
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
|
||||||
|
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
|
||||||
|
</div> },
|
||||||
|
];
|
||||||
|
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { Marquee } from "react-css-marquee";
|
import { Icon } from "../ui/Icon";
|
||||||
import { Icon } from "../../shared/ui/Icon";
|
import { windowCloseIcon } from "../ui/icons";
|
||||||
import { windowCloseIcon } from "../../shared/ui/icons";
|
|
||||||
import type { AdSlot } from "./types";
|
import type { AdSlot } from "./types";
|
||||||
import styles from "./Ads.module.scss";
|
import styles from "./Ads.module.scss";
|
||||||
|
|
||||||
@@ -15,49 +14,6 @@ type AdMenuProps = {
|
|||||||
onDismiss: (ad: AdSlot) => void;
|
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) {
|
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
|
||||||
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
|
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
|
||||||
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
|
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
|
||||||
@@ -77,10 +33,10 @@ export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs
|
|||||||
>
|
>
|
||||||
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
|
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
|
||||||
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
|
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
|
||||||
<div className={styles.menuCopy}>
|
<span className={styles.menuCopy}>
|
||||||
<OverflowText className={styles.menuTitle}>{ad.target.title}</OverflowText>
|
<span className={styles.menuTitle}>{ad.target.title}</span>
|
||||||
<OverflowText className={styles.menuSubtitle}>{ad.target.description}</OverflowText>
|
<span className={styles.menuSubtitle}>{ad.target.description}</span>
|
||||||
</div>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
+12
-49
@@ -24,14 +24,19 @@
|
|||||||
padding: 0 32px 0 8px;
|
padding: 0 32px 0 8px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: color-mix(in srgb, var(--vscode-foreground) 4%, transparent);
|
background: transparent;
|
||||||
border: 2px solid #be8d180d;
|
border: 1px solid var(--vscode-sideBar-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&[aria-expanded="true"] {
|
&[aria-expanded="true"] {
|
||||||
background: color-mix(in srgb, var(--vscode-foreground) 9%, transparent);
|
background: var(--vscode-list-hoverBackground);
|
||||||
border-color: #be8d180d;
|
border: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder);
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: none;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
|
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
|
||||||
@@ -61,10 +66,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuSlotContainer:hover .menuDismiss {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unreadDot {
|
.unreadDot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5px;
|
top: 5px;
|
||||||
@@ -84,9 +85,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menuCopy {
|
.menuCopy {
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
@@ -94,43 +93,11 @@
|
|||||||
|
|
||||||
.menuTitle,
|
.menuTitle,
|
||||||
.menuSubtitle {
|
.menuSubtitle {
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.menuTitle {
|
||||||
font-size: type.$font-size-xs;
|
font-size: type.$font-size-xs;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -154,9 +121,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--vscode-foreground);
|
color: var(--vscode-foreground);
|
||||||
background: color-mix(in srgb, var(--vscode-editorWidget-background) 92%, var(--vscode-foreground) 8%);
|
background: var(--vscode-editorWidget-background);
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
border: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
border: none;
|
||||||
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
|
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
|
||||||
animation: ad-enter 180ms ease-out;
|
animation: ad-enter 180ms ease-out;
|
||||||
|
|
||||||
@@ -165,10 +132,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(:root[data-theme="default-light"]) .floatingAd {
|
|
||||||
background: var(--vscode-editorWidget-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
flex: 0 0 112px;
|
flex: 0 0 112px;
|
||||||
position: relative;
|
position: relative;
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Icon } from "../../shared/ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { windowCloseIcon } from "../../shared/ui/icons";
|
import { windowCloseIcon } from "../ui/icons";
|
||||||
import { VirtualList } from "../../shared/virtual/VirtualList";
|
import { VirtualList } from "../virtual/VirtualList";
|
||||||
import type { AdAction, AdSlot } from "./types";
|
import type { AdAction, AdSlot } from "./types";
|
||||||
import styles from "./Ads.module.scss";
|
import styles from "./Ads.module.scss";
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@use "../../../styles/typography" as type;
|
@use "../../styles/typography" as type;
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
+22
-16
@@ -1,8 +1,6 @@
|
|||||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { init, Rect, type ElementEvent } from "zrender";
|
import { init, Rect, type ElementEvent } from "zrender";
|
||||||
import type { Locale } from "../../../i18n/runtime";
|
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||||
import { useI18n } from "../../../i18n/store";
|
|
||||||
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
|
|
||||||
import styles from "./ContributionCalendarChart.module.scss";
|
import styles from "./ContributionCalendarChart.module.scss";
|
||||||
|
|
||||||
export type ContributionDay = {
|
export type ContributionDay = {
|
||||||
@@ -28,6 +26,12 @@ type CellExtra = CalendarCell & {
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TooltipState = {
|
||||||
|
date: string;
|
||||||
|
tokens: number;
|
||||||
|
anchor: TooltipAnchor;
|
||||||
|
};
|
||||||
|
|
||||||
type AxisLabel = {
|
type AxisLabel = {
|
||||||
key: string;
|
key: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -50,6 +54,8 @@ const CALENDAR_CONFIG = {
|
|||||||
axisLabelGap: 8,
|
axisLabelGap: 8,
|
||||||
axisLabelWidth: 28,
|
axisLabelWidth: 28,
|
||||||
} as const;
|
} as const;
|
||||||
|
const tokenFormatter = new Intl.NumberFormat("zh-CN");
|
||||||
|
|
||||||
function parseDate(date: string) {
|
function parseDate(date: string) {
|
||||||
return new Date(`${date}T00:00:00Z`);
|
return new Date(`${date}T00:00:00Z`);
|
||||||
}
|
}
|
||||||
@@ -66,10 +72,9 @@ function isCellExtra(value: unknown): value is CellExtra {
|
|||||||
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
|
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
|
function buildCalendarLayout(data: ContributionDay[]) {
|
||||||
if (data.length === 0) return null;
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
|
|
||||||
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
|
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
|
||||||
const firstDate = parseDate(data[0].date);
|
const firstDate = parseDate(data[0].date);
|
||||||
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
|
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
|
||||||
@@ -83,22 +88,20 @@ function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
|
|||||||
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
|
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
|
||||||
const date = parseDate(cell.date);
|
const date = parseDate(cell.date);
|
||||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
|
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
|
||||||
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column });
|
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
|
||||||
return ticks;
|
return ticks;
|
||||||
}, []);
|
}, []);
|
||||||
return { cells, columnCount, monthTicks };
|
return { cells, columnCount, monthTicks };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
|
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
|
||||||
const { locale } = useI18n();
|
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
|
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
|
||||||
const scheduleDrawRef = useRef<() => void>(() => undefined);
|
const scheduleDrawRef = useRef<() => void>(() => undefined);
|
||||||
const { show: showTooltip, hide: hideTooltip } = useTooltip();
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||||
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
|
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
|
||||||
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
|
const layout = useMemo(() => buildCalendarLayout(data), [data]);
|
||||||
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
|
||||||
layoutRef.current = layout;
|
layoutRef.current = layout;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -123,13 +126,10 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
|
|||||||
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
|
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
showTooltip(anchor, undefined, <div className={styles.tooltipContent}>
|
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
|
||||||
<strong>{extra.date}</strong>
|
|
||||||
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(extra.tokens) })}</span>
|
|
||||||
</div>);
|
|
||||||
};
|
};
|
||||||
const handleMouseOut = (event: ElementEvent) => {
|
const handleMouseOut = (event: ElementEvent) => {
|
||||||
if (isCellExtra(event.target?.extra)) hideTooltip();
|
if (isCellExtra(event.target?.extra)) setTooltip(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
chart.on("mouseover", handleMouseOver);
|
chart.on("mouseover", handleMouseOver);
|
||||||
@@ -223,7 +223,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
|
|||||||
cellRects.set(cell.date, rect);
|
cellRects.set(cell.date, rect);
|
||||||
chart.add(rect);
|
chart.add(rect);
|
||||||
}
|
}
|
||||||
hideTooltip();
|
setTooltip(null);
|
||||||
};
|
};
|
||||||
const scheduleDraw = () => {
|
const scheduleDraw = () => {
|
||||||
window.cancelAnimationFrame(drawFrame);
|
window.cancelAnimationFrame(drawFrame);
|
||||||
@@ -261,6 +261,12 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
|
|||||||
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
|
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@use "../../../styles/typography" as type;
|
@use "../../styles/typography" as type;
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
|
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import type { EChartsCoreOption } from "echarts/core";
|
import type { EChartsCoreOption } from "echarts/core";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { TokenUsageGranularity } from "../../../shared/api";
|
import type { TokenUsageGranularity } from "../../api";
|
||||||
import { formatCompactInteger } from "../../../shared/utils/numberFormat";
|
import { formatCompactInteger } from "../../utils/numberFormat";
|
||||||
import { EChart } from "./EChart";
|
import { EChart } from "./EChart";
|
||||||
import styles from "./DailyTokenUsageChart.module.scss";
|
import styles from "./DailyTokenUsageChart.module.scss";
|
||||||
|
|
||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
import { BarChart, GaugeChart, LineChart } from "echarts/charts";
|
import { BarChart, LineChart } from "echarts/charts";
|
||||||
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
|
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
|
||||||
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
|
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import { useEffect, useRef, type MouseEventHandler } from "react";
|
import { useEffect, useRef, type MouseEventHandler } from "react";
|
||||||
import styles from "./EChart.module.scss";
|
import styles from "./EChart.module.scss";
|
||||||
|
|
||||||
use([BarChart, GaugeChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
|
use([BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
|
||||||
|
|
||||||
type EChartProps = {
|
type EChartProps = {
|
||||||
option: EChartsCoreOption;
|
option: EChartsCoreOption;
|
||||||
+2
-3
@@ -1,19 +1,18 @@
|
|||||||
import type { EChartsCoreOption } from "echarts/core";
|
import type { EChartsCoreOption } from "echarts/core";
|
||||||
import type { LlmCall } from "../../../shared/api";
|
import type { LlmCall } from "../../api";
|
||||||
import { EChart } from "./EChart";
|
import { EChart } from "./EChart";
|
||||||
|
|
||||||
export function LatencyChart({ calls }: { calls: LlmCall[] }) {
|
export function LatencyChart({ calls }: { calls: LlmCall[] }) {
|
||||||
const points = calls.slice(0, 20).reverse();
|
const points = calls.slice(0, 20).reverse();
|
||||||
const option: EChartsCoreOption = {
|
const option: EChartsCoreOption = {
|
||||||
animationDuration: 280,
|
animationDuration: 280,
|
||||||
color: ["#d79a62", "#ad84cf", "#72a8d8"],
|
color: ["#d79a62", "#ad84cf"],
|
||||||
grid: { top: 22, right: 18, bottom: 30, left: 48 },
|
grid: { top: 22, right: 18, bottom: 30, left: 48 },
|
||||||
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
|
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
|
||||||
tooltip: { trigger: "axis" },
|
tooltip: { trigger: "axis" },
|
||||||
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
|
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
|
||||||
yAxis: { type: "value", axisLabel: { color: "#888", formatter: "{value} ms" }, splitLine: { lineStyle: { color: "#8882" } } },
|
yAxis: { type: "value", axisLabel: { color: "#888", formatter: "{value} ms" }, splitLine: { lineStyle: { color: "#8882" } } },
|
||||||
series: [
|
series: [
|
||||||
{ name: "TTFR", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttfr_ms ?? 0) },
|
|
||||||
{ name: "TTFT", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttft_ms ?? 0) },
|
{ name: "TTFT", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttft_ms ?? 0) },
|
||||||
{ name: t("总耗时"), type: "line", smooth: true, symbol: "none", data: points.map((call) => call.duration_ms ?? 0) },
|
{ name: t("总耗时"), type: "line", smooth: true, symbol: "none", data: points.map((call) => call.duration_ms ?? 0) },
|
||||||
],
|
],
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import type { EChartsCoreOption } from "echarts/core";
|
import type { EChartsCoreOption } from "echarts/core";
|
||||||
import type { LlmCall } from "../../../shared/api";
|
import type { LlmCall } from "../../api";
|
||||||
import { EChart } from "./EChart";
|
import { EChart } from "./EChart";
|
||||||
|
|
||||||
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
|
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import { useAppStore } from "../../store/appStore";
|
||||||
|
import controls from "../ui/Controls.module.scss";
|
||||||
|
import styles from "./CursorSettings.module.scss";
|
||||||
|
|
||||||
|
const CaReady = createContext(false);
|
||||||
|
const ModelsReady = createContext(false);
|
||||||
|
|
||||||
|
export function CursorCaProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { cursorHarness } = useAppStore();
|
||||||
|
return <CaReady.Provider value={cursorHarness?.ca === "ready"}>{children}</CaReady.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh, children }: { busy: boolean; waitingForRefresh: boolean; onInitialize: () => void; onRefresh: () => void; children: ReactNode }) {
|
||||||
|
const ready = useContext(CaReady);
|
||||||
|
const { cursorHarness } = useAppStore();
|
||||||
|
if (ready) return children;
|
||||||
|
const unsupported = cursorHarness?.ca === "unsupported";
|
||||||
|
const installedLocally = cursorHarness?.ca === "untrusted";
|
||||||
|
return <div className={styles.gate}>
|
||||||
|
<strong>{unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
|
||||||
|
<span>{unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
|
||||||
|
{!unsupported && <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { models } = useAppStore();
|
||||||
|
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) {
|
||||||
|
const ready = useContext(ModelsReady);
|
||||||
|
if (ready) return children;
|
||||||
|
return <div className={styles.gate}>
|
||||||
|
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
|
||||||
|
<span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span>
|
||||||
|
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
|
||||||
|
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
|
||||||
|
import { Checkbox } from "../ui/Checkbox";
|
||||||
|
import { JsonEditor } from "../ui/JsonEditor";
|
||||||
|
import { Combobox, MultiCombobox, Select } from "../ui/Select";
|
||||||
|
import { Switch } from "../ui/Switch";
|
||||||
|
import controls from "../ui/Controls.module.scss";
|
||||||
|
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||||
|
import { claudeIcon, openAiIcon } from "../ui/icons";
|
||||||
|
import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
|
||||||
|
import styles from "./CursorSettings.module.scss";
|
||||||
|
|
||||||
|
export type CursorModelDraft = {
|
||||||
|
providerMode: string;
|
||||||
|
provider: ProviderInput;
|
||||||
|
model: ModelInput;
|
||||||
|
modelIds: string[];
|
||||||
|
headersText: string;
|
||||||
|
extraText: string;
|
||||||
|
customRequestUrl: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||||
|
providerMode: "new",
|
||||||
|
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} },
|
||||||
|
model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false },
|
||||||
|
modelIds: [],
|
||||||
|
headersText: defaultCustomHeadersText,
|
||||||
|
extraText: "{}",
|
||||||
|
customRequestUrl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: {
|
||||||
|
draft: CursorModelDraft;
|
||||||
|
providers: Provider[];
|
||||||
|
editing: boolean;
|
||||||
|
modelOptions: string[];
|
||||||
|
discovering: boolean;
|
||||||
|
onChange: (draft: CursorModelDraft) => void;
|
||||||
|
onDiscover: () => void;
|
||||||
|
}) {
|
||||||
|
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
|
||||||
|
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
|
||||||
|
const canDiscover = draft.providerMode !== "new"
|
||||||
|
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim());
|
||||||
|
const selectProvider = (providerMode: string) => {
|
||||||
|
const endpointType = providerMode === "new"
|
||||||
|
? draft.provider.provider_type
|
||||||
|
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
|
||||||
|
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
|
||||||
|
};
|
||||||
|
const setEndpointType = (endpoint_type: ProviderType) => onChange({
|
||||||
|
...draft,
|
||||||
|
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
|
||||||
|
model: { ...draft.model, endpoint_type },
|
||||||
|
});
|
||||||
|
const setModelIds = (modelIds: string[]) => onChange({
|
||||||
|
...draft,
|
||||||
|
modelIds,
|
||||||
|
model: {
|
||||||
|
...draft.model,
|
||||||
|
model_id: modelIds[0] ?? "",
|
||||||
|
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return <div className={styles.editor}>
|
||||||
|
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[
|
||||||
|
{ value: "new", label: t("新建上游") },
|
||||||
|
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
|
||||||
|
]} onChange={selectProvider} /></FormField>}
|
||||||
|
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{!editing && draft.providerMode === "new" && <>
|
||||||
|
<FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
|
||||||
|
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><SecretTextInput placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
|
||||||
|
</>}
|
||||||
|
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
|
||||||
|
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||||
|
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
|
||||||
|
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
|
||||||
|
<FormField label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
|
||||||
|
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
|
||||||
|
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
|
||||||
|
}</FormField>
|
||||||
|
<FormField label={t("自定义上下文")} hint={t("输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。")}>
|
||||||
|
<TextInput type="number" min={1} step={1} aria-label={t("自定义上下文 tokens")} placeholder={t("例如:272000")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} />
|
||||||
|
</FormField>
|
||||||
|
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
|
||||||
|
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
|
||||||
|
{!editing && draft.providerMode === "new" && <>
|
||||||
|
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
|
||||||
|
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
|
||||||
|
</>}
|
||||||
|
<div className={`${styles.switches} ${styles.fullWidth}`}>
|
||||||
|
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
|
||||||
|
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
|
||||||
|
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ModelConnectivityResult } from "../../api";
|
||||||
|
import { Icon } from "../ui/Icon";
|
||||||
|
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||||
|
import { informationOutlineIcon } from "../ui/icons";
|
||||||
|
import styles from "./CursorSettings.module.scss";
|
||||||
|
|
||||||
|
export type CursorModelTestState =
|
||||||
|
| { status: "success"; result: ModelConnectivityResult }
|
||||||
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
|
export function CursorModelTestResult({ state }: { state: CursorModelTestState }) {
|
||||||
|
const success = state.status === "success";
|
||||||
|
const summary = success
|
||||||
|
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
|
||||||
|
: t("错误:{error}", { error: state.error });
|
||||||
|
const detail = success
|
||||||
|
? t("速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", {
|
||||||
|
speed: formatSpeed(state.result.tokens_per_second),
|
||||||
|
firstText: state.result.first_text_ms ?? "--",
|
||||||
|
duration: state.result.duration_ms,
|
||||||
|
tokens: state.result.output_tokens,
|
||||||
|
estimated: state.result.tokens_estimated ? t("(估算)") : "",
|
||||||
|
output: state.result.output || "--",
|
||||||
|
})
|
||||||
|
: t("测试失败:{error}", { error: state.error });
|
||||||
|
|
||||||
|
return <div className={`${styles.testResult} ${success ? styles.testSuccess : styles.testError}`}>
|
||||||
|
<span className={styles.testResultText}>{summary}</span>
|
||||||
|
<TooltipTrigger label={detail}><span className={styles.testResultHint} tabIndex={0}><Icon icon={informationOutlineIcon} size="1.1em" /></span></TooltipTrigger>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(value: number) {
|
||||||
|
return Number.isFinite(value) ? value.toFixed(1) : "0.0";
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
@use "../../styles/typography" as type;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate {
|
||||||
|
min-height: 250px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--vscode-sideBar-border);
|
||||||
|
border-radius: var(--oa-overlay-radius);
|
||||||
|
strong {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
font-size: type.$font-size-base;
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
max-width: 440px;
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.providerTitle {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.models {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.modelRow {
|
||||||
|
min-height: 54px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
// border-top: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
.modelName {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
padding: 2px 7px;
|
||||||
|
color: var(--vscode-badge-foreground);
|
||||||
|
background: var(--vscode-badge-background);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: type.$font-size-2xs;
|
||||||
|
}
|
||||||
|
.rowActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.testResult {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 220px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
.testSuccess {
|
||||||
|
color: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
.testError {
|
||||||
|
color: var(--vscode-errorForeground, #f48771);
|
||||||
|
}
|
||||||
|
.testResultText {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.testResultHint {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: currentColor;
|
||||||
|
cursor: help;
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.editorTestResult {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.command {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
color: var(--vscode-textLink-foreground);
|
||||||
|
background: var(--vscode-textCodeBlock-background);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.switches {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
label {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.fullWidth {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@use "../../styles/typography" as type;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
min-height: 62px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
border-top: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectControl,
|
||||||
|
.addressInput {
|
||||||
|
width: min(300px, 44%);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectControl,
|
||||||
|
.addressInput {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import cursorIconUrl from "../../assets/icons/cursor.svg";
|
||||||
|
import type { TabMode, TabSettings } from "../../api";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import { TextInput } from "../ui/FormControls";
|
||||||
|
import { Icon } from "../ui/Icon";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
import { TitledCard } from "../ui/TitledCard";
|
||||||
|
import styles from "./TabSettingsCard.module.scss";
|
||||||
|
|
||||||
|
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
|
||||||
|
settings: TabSettings;
|
||||||
|
saving: boolean;
|
||||||
|
onChange: (settings: TabSettings) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}) {
|
||||||
|
return <TitledCard
|
||||||
|
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
|
||||||
|
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
|
||||||
|
>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={styles.description}>
|
||||||
|
<strong>{t("TAB 选择")}</strong>
|
||||||
|
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
|
||||||
|
</div>
|
||||||
|
<div className={styles.selectControl}>
|
||||||
|
<Select
|
||||||
|
value={settings.mode}
|
||||||
|
ariaLabel={t("TAB 选择")}
|
||||||
|
options={[
|
||||||
|
{ value: "public", label: t("使用公益服务") },
|
||||||
|
{ value: "direct", label: t("直连") },
|
||||||
|
{ value: "custom", label: t("自定义") },
|
||||||
|
]}
|
||||||
|
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{settings.mode === "custom" && <div className={styles.row}>
|
||||||
|
<div className={styles.description}>
|
||||||
|
<strong>{t("TAB 服务地址")}</strong>
|
||||||
|
<small>{t("原接口路径会追加到此服务地址。")}</small>
|
||||||
|
</div>
|
||||||
|
<TextInput
|
||||||
|
className={styles.addressInput}
|
||||||
|
value={settings.address}
|
||||||
|
placeholder="https://tab.leokun.cn"
|
||||||
|
aria-label={t("TAB 服务地址")}
|
||||||
|
onChange={(event) => onChange({ ...settings, address: event.target.value })}
|
||||||
|
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
|
||||||
|
/>
|
||||||
|
</div>}
|
||||||
|
</TitledCard>;
|
||||||
|
}
|
||||||
+2
-2
@@ -15,8 +15,8 @@
|
|||||||
top: var(--app-content-top);
|
top: var(--app-content-top);
|
||||||
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
|
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left:0;
|
padding-left: 10px;
|
||||||
font-size: type.$font-size-xxl;
|
font-size: type.$font-size-lg;
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
transform: translateY(-150%);
|
transform: translateY(-150%);
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
+2
-2
@@ -26,8 +26,8 @@
|
|||||||
|
|
||||||
top: 0;
|
top: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 0;
|
padding-left: 10px;
|
||||||
font-size: type.$font-size-xxl;
|
font-size: type.$font-size-lg;
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
transform: translateY(-150%);
|
transform: translateY(-150%);
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, type ReactNode } from "react";
|
import { useCallback, type ReactNode } from "react";
|
||||||
import { VirtualList } from "../../shared/virtual/VirtualListEngine";
|
import { VirtualList } from "../virtual/VirtualListEngine";
|
||||||
import styles from "./VirtualPage.module.scss";
|
import styles from "./VirtualPage.module.scss";
|
||||||
|
|
||||||
export type VirtualPageSection = {
|
export type VirtualPageSection = {
|
||||||
+7
-11
@@ -1,22 +1,18 @@
|
|||||||
@use "../../../styles/typography" as type;
|
@use "../../styles/typography" as type;
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
|
--cache-hit-track-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||||
|
--cache-hit-value-color: var(--vscode-gitDecoration-addedResourceForeground, #4ade80);
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 200px;
|
width: 132px;
|
||||||
max-width: 100%;
|
|
||||||
height: 82px;
|
height: 82px;
|
||||||
overflow: hidden;
|
|
||||||
align-self: center;
|
align-self: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.canvas {
|
||||||
position: absolute;
|
width: 100% !important;
|
||||||
top: 0;
|
height: 100% !important;
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 140px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Doughnut } from "react-chartjs-2";
|
||||||
|
import styles from "./CacheHitRateChart.module.scss";
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip);
|
||||||
|
|
||||||
|
type SegmentRadius = number | {
|
||||||
|
outerStart: number;
|
||||||
|
outerEnd: number;
|
||||||
|
innerStart: number;
|
||||||
|
innerEnd: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function chartColor(context: ScriptableContext<"doughnut">) {
|
||||||
|
const styles = getComputedStyle(context.chart.canvas);
|
||||||
|
const variable = context.dataIndex === 0 ? "--cache-hit-value-color" : "--cache-hit-track-color";
|
||||||
|
return styles.getPropertyValue(variable).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentBorderRadius(percentage: number, dataIndex: number): SegmentRadius {
|
||||||
|
const radius = 5;
|
||||||
|
|
||||||
|
if (percentage <= 0) {
|
||||||
|
return dataIndex === 1
|
||||||
|
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percentage >= 100) {
|
||||||
|
return dataIndex === 0
|
||||||
|
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataIndex === 0
|
||||||
|
? { outerStart: radius, outerEnd: 0, innerStart: radius, innerEnd: 0 }
|
||||||
|
: { outerStart: 0, outerEnd: radius, innerStart: 0, innerEnd: radius };
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: ChartOptions<"doughnut"> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: "82%",
|
||||||
|
rotation: -90,
|
||||||
|
circumference: 180,
|
||||||
|
animation: { duration: 450 },
|
||||||
|
events: [],
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { enabled: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CacheHitRateChart({ rate }: { rate: number }) {
|
||||||
|
const finiteRate = Number.isFinite(rate) ? rate : 0;
|
||||||
|
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
|
||||||
|
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
|
||||||
|
const data = useMemo(() => ({
|
||||||
|
labels: [t("命中"), t("未命中")],
|
||||||
|
datasets: [{
|
||||||
|
data: [percentage, Math.max(0, 100 - percentage)],
|
||||||
|
backgroundColor: chartColor,
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverBorderWidth: 0,
|
||||||
|
selfJoin: false,
|
||||||
|
borderRadius: (context: ScriptableContext<"doughnut">) => segmentBorderRadius(percentage, context.dataIndex),
|
||||||
|
}],
|
||||||
|
}), [percentage]);
|
||||||
|
|
||||||
|
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
|
||||||
|
<Doughnut className={styles.canvas} data={data} options={options} />
|
||||||
|
<div className={styles.label}>{label}</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@use "../../../styles/typography" as type;
|
@use "../../styles/typography" as type;
|
||||||
|
|
||||||
.scroller {
|
.scroller {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
+38
-27
@@ -1,8 +1,8 @@
|
|||||||
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
|
import { useState } from "react";
|
||||||
import { useAppStore } from "../../../shared/store/appStore";
|
import { formatCompactInteger, formatInteger } from "../../utils/numberFormat";
|
||||||
import { Icon } from "../../../shared/ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
|
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||||
import { informationOutlineIcon } from "../../../shared/ui/icons";
|
import { informationOutlineIcon } from "../ui/icons";
|
||||||
import { CacheHitRateChart } from "./CacheHitRateChart";
|
import { CacheHitRateChart } from "./CacheHitRateChart";
|
||||||
import styles from "./HomeMetrics.module.scss";
|
import styles from "./HomeMetrics.module.scss";
|
||||||
|
|
||||||
@@ -16,6 +16,13 @@ export type HomeMetricsData = {
|
|||||||
cacheWriteTokens: number;
|
cacheWriteTokens: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TOKEN_PRICE_PER_MILLION = {
|
||||||
|
input: 5,
|
||||||
|
output: 25,
|
||||||
|
cacheRead: 0.5,
|
||||||
|
cacheWrite: 6.25,
|
||||||
|
} as const;
|
||||||
|
|
||||||
function formatMetricValue(value: number) {
|
function formatMetricValue(value: number) {
|
||||||
const full = formatInteger(value);
|
const full = formatInteger(value);
|
||||||
const compact = formatCompactInteger(value);
|
const compact = formatCompactInteger(value);
|
||||||
@@ -46,21 +53,25 @@ function elementAnchor(element: HTMLElement): TooltipAnchor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InfoTooltip({ content }: { content: string }) {
|
function InfoTooltip({ content }: { content: string }) {
|
||||||
const { show, hide } = useTooltip();
|
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
|
||||||
|
|
||||||
return <button
|
return <>
|
||||||
type="button"
|
<button
|
||||||
className={styles.info}
|
type="button"
|
||||||
aria-label={t("查看说明")}
|
className={styles.info}
|
||||||
onMouseEnter={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
|
aria-label={t("查看说明")}
|
||||||
onMouseLeave={hide}
|
onMouseEnter={(event) => setAnchor(elementAnchor(event.currentTarget))}
|
||||||
onFocus={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
|
onMouseLeave={() => setAnchor(null)}
|
||||||
onBlur={hide}
|
onFocus={(event) => setAnchor(elementAnchor(event.currentTarget))}
|
||||||
><Icon icon={informationOutlineIcon} size="1.1em" /></button>;
|
onBlur={() => setAnchor(null)}
|
||||||
|
><Icon icon={informationOutlineIcon} size="1.1em" /></button>
|
||||||
|
<Tooltip anchor={anchor}>
|
||||||
|
<div className={styles.tooltipText}>{content}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
|
export function HomeMetrics({ data }: { data: HomeMetricsData }) {
|
||||||
const { pricing } = useAppStore();
|
|
||||||
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
||||||
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
||||||
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
||||||
@@ -70,10 +81,10 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
|
|||||||
);
|
);
|
||||||
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
|
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
|
||||||
const costs = {
|
const costs = {
|
||||||
input: priceTokens(inputTokens, pricing.input_per_million),
|
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
|
||||||
output: priceTokens(outputTokens, pricing.output_per_million),
|
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
|
||||||
cacheRead: priceTokens(data.cacheReadTokens, pricing.cache_read_per_million),
|
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
|
||||||
cacheWrite: priceTokens(data.cacheWriteTokens, pricing.cache_write_per_million),
|
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
|
||||||
};
|
};
|
||||||
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
|
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
|
||||||
const cacheCost = costs.cacheRead + costs.cacheWrite;
|
const cacheCost = costs.cacheRead + costs.cacheWrite;
|
||||||
@@ -106,27 +117,27 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
|
|||||||
t("缓存读写已计入提示词侧统计。"),
|
t("缓存读写已计入提示词侧统计。"),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const costTooltip = [
|
const costTooltip = [
|
||||||
t("按配置的 Token 价格估算。"),
|
t("按 Claude Opus 4.7 价格估算。"),
|
||||||
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
|
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
|
||||||
"",
|
"",
|
||||||
t("普通输入:{tokens} × ${price}/1M = {cost}", {
|
t("普通输入:{tokens} × ${price}/1M = {cost}", {
|
||||||
tokens: formatMetricValue(inputTokens),
|
tokens: formatMetricValue(inputTokens),
|
||||||
price: pricing.input_per_million,
|
price: TOKEN_PRICE_PER_MILLION.input,
|
||||||
cost: formatUSD(costs.input),
|
cost: formatUSD(costs.input),
|
||||||
}),
|
}),
|
||||||
t("模型输出:{tokens} × ${price}/1M = {cost}", {
|
t("模型输出:{tokens} × ${price}/1M = {cost}", {
|
||||||
tokens: formatMetricValue(outputTokens),
|
tokens: formatMetricValue(outputTokens),
|
||||||
price: pricing.output_per_million,
|
price: TOKEN_PRICE_PER_MILLION.output,
|
||||||
cost: formatUSD(costs.output),
|
cost: formatUSD(costs.output),
|
||||||
}),
|
}),
|
||||||
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
|
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
|
||||||
tokens: formatMetricValue(data.cacheReadTokens),
|
tokens: formatMetricValue(data.cacheReadTokens),
|
||||||
price: pricing.cache_read_per_million,
|
price: TOKEN_PRICE_PER_MILLION.cacheRead,
|
||||||
cost: formatUSD(costs.cacheRead),
|
cost: formatUSD(costs.cacheRead),
|
||||||
}),
|
}),
|
||||||
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
|
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
|
||||||
tokens: formatMetricValue(data.cacheWriteTokens),
|
tokens: formatMetricValue(data.cacheWriteTokens),
|
||||||
price: pricing.cache_write_per_million,
|
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
|
||||||
cost: formatUSD(costs.cacheWrite),
|
cost: formatUSD(costs.cacheWrite),
|
||||||
}),
|
}),
|
||||||
"",
|
"",
|
||||||
@@ -137,7 +148,7 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
|
|||||||
<section className={styles.root} aria-label={t("调用统计")}>
|
<section className={styles.root} aria-label={t("调用统计")}>
|
||||||
<article className={styles.metric}>
|
<article className={styles.metric}>
|
||||||
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
|
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
|
||||||
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} animationKey={refreshVersion} />
|
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} />
|
||||||
</article>
|
</article>
|
||||||
<article className={styles.metric}>
|
<article className={styles.metric}>
|
||||||
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
|
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
|
||||||
+2
-21
@@ -1,4 +1,4 @@
|
|||||||
@use "../../../styles/typography" as type;
|
@use "../../styles/typography" as type;
|
||||||
|
|
||||||
.root,
|
.root,
|
||||||
.presets {
|
.presets {
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
height: 25px;
|
height: 30px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -76,22 +76,3 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 6px;
|
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-25
@@ -1,32 +1,32 @@
|
|||||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { parseTimeInput } from "../../../shared/utils/parseTimeInput";
|
import { parseTimeInput } from "../../utils/parseTimeInput";
|
||||||
import controls from "../../../shared/ui/Controls.module.scss";
|
import controls from "../ui/Controls.module.scss";
|
||||||
import { Icon } from "../../../shared/ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { ModelSelect, type ModelSelectOption } from "../../../shared/ui/ModelSelect";
|
import { MultiSelect, type MultiSelectOption } from "../ui/MultiSelect";
|
||||||
import { TooltipTrigger } from "../../../shared/ui/TooltipTrigger";
|
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||||
import { refreshIcon } from "../../../shared/ui/icons";
|
import { refreshIcon } from "../ui/icons";
|
||||||
import styles from "./OverviewTimeRangeFilter.module.scss";
|
import styles from "./OverviewTimeRangeFilter.module.scss";
|
||||||
|
|
||||||
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
|
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
|
||||||
export type QuickPreset = "four-hours" | "twenty-four-hours";
|
|
||||||
|
|
||||||
export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
|
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: {
|
||||||
value: OverviewRangePreset;
|
value: OverviewRangePreset;
|
||||||
quick: QuickPreset | null;
|
|
||||||
customOpen: boolean;
|
customOpen: boolean;
|
||||||
customStart: string;
|
customStart: string;
|
||||||
customEnd: string;
|
customEnd: string;
|
||||||
modelOptions: ModelSelectOption[];
|
modelOptions: MultiSelectOption[];
|
||||||
|
providerOptions: MultiSelectOption[];
|
||||||
selectedModels: string[];
|
selectedModels: string[];
|
||||||
|
selectedProviders: string[];
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
|
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
|
||||||
onQuickSelect: (durationMs: number) => void;
|
|
||||||
onCustomOpenChange: (open: boolean) => void;
|
onCustomOpenChange: (open: boolean) => void;
|
||||||
onCustomStartChange: (value: string) => void;
|
onCustomStartChange: (value: string) => void;
|
||||||
onCustomEndChange: (value: string) => void;
|
onCustomEndChange: (value: string) => void;
|
||||||
onSelectedModelsChange: (value: string[]) => void;
|
onSelectedModelsChange: (value: string[]) => void;
|
||||||
|
onSelectedProvidersChange: (value: string[]) => void;
|
||||||
onCustomApply: () => void;
|
onCustomApply: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -37,10 +37,6 @@ export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart,
|
|||||||
{ value: "week", label: t("近一周") },
|
{ value: "week", label: t("近一周") },
|
||||||
{ value: "month", 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 customButton = useRef<HTMLButtonElement>(null);
|
||||||
const popover = useRef<HTMLDivElement>(null);
|
const popover = useRef<HTMLDivElement>(null);
|
||||||
const popoverId = useId();
|
const popoverId = useId();
|
||||||
@@ -73,6 +69,7 @@ export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart,
|
|||||||
const parsedStart = parseTimeInput(customStart);
|
const parsedStart = parseTimeInput(customStart);
|
||||||
const parsedEnd = parseTimeInput(customEnd);
|
const parsedEnd = parseTimeInput(customEnd);
|
||||||
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
|
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
|
||||||
|
|
||||||
return <div className={styles.root} aria-label={t("概览时间范围")}>
|
return <div className={styles.root} aria-label={t("概览时间范围")}>
|
||||||
<div className={styles.presets}>
|
<div className={styles.presets}>
|
||||||
{presets.map((preset) => <button
|
{presets.map((preset) => <button
|
||||||
@@ -101,7 +98,6 @@ export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart,
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={t("自定义概览筛选")}
|
aria-label={t("自定义概览筛选")}
|
||||||
style={position}
|
style={position}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -110,17 +106,10 @@ export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart,
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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 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>
|
<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}><ModelSelect mode="multiple" label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||||
|
<div className={styles.filterRow}><MultiSelect label={t("上游")} value={selectedProviders} options={providerOptions} onChange={onSelectedProvidersChange} /></div>
|
||||||
<div className={styles.popoverActions}>
|
<div className={styles.popoverActions}>
|
||||||
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
||||||
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
||||||
+1
-6
@@ -97,7 +97,6 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
|||||||
|
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
const localViewportRef = useRef<HTMLDivElement | null>(null)
|
const localViewportRef = useRef<HTMLDivElement | null>(null)
|
||||||
const localContentRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||||
const thumbRef = useRef<HTMLDivElement | null>(null)
|
const thumbRef = useRef<HTMLDivElement | null>(null)
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
@@ -108,7 +107,6 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
|||||||
|
|
||||||
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
|
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
|
||||||
viewportRef: localViewportRef,
|
viewportRef: localViewportRef,
|
||||||
contentRef: localContentRef,
|
|
||||||
trackRef,
|
trackRef,
|
||||||
thumbRef,
|
thumbRef,
|
||||||
contentHeight,
|
contentHeight,
|
||||||
@@ -134,10 +132,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const setContentRef = useCallback(
|
const setContentRef = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => assignRef(contentRef, node),
|
||||||
localContentRef.current = node
|
|
||||||
assignRef(contentRef, node)
|
|
||||||
},
|
|
||||||
[contentRef]
|
[contentRef]
|
||||||
)
|
)
|
||||||
|
|
||||||
+6
-10
@@ -33,7 +33,6 @@ export interface ScrollMetrics extends ScrollAreaState {
|
|||||||
|
|
||||||
interface UseScrollableModelOptions {
|
interface UseScrollableModelOptions {
|
||||||
viewportRef: RefObject<HTMLDivElement | null>
|
viewportRef: RefObject<HTMLDivElement | null>
|
||||||
contentRef: RefObject<HTMLDivElement | null>
|
|
||||||
trackRef: RefObject<HTMLDivElement | null>
|
trackRef: RefObject<HTMLDivElement | null>
|
||||||
thumbRef: RefObject<HTMLDivElement | null>
|
thumbRef: RefObject<HTMLDivElement | null>
|
||||||
contentHeight?: number
|
contentHeight?: number
|
||||||
@@ -132,7 +131,6 @@ function applyThumbStyle(
|
|||||||
export function useScrollableModel(options: UseScrollableModelOptions) {
|
export function useScrollableModel(options: UseScrollableModelOptions) {
|
||||||
const {
|
const {
|
||||||
viewportRef,
|
viewportRef,
|
||||||
contentRef,
|
|
||||||
trackRef,
|
trackRef,
|
||||||
thumbRef,
|
thumbRef,
|
||||||
contentHeight,
|
contentHeight,
|
||||||
@@ -321,30 +319,28 @@ export function useScrollableModel(options: UseScrollableModelOptions) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = viewportRef.current
|
const element = viewportRef.current
|
||||||
const content = contentRef.current
|
|
||||||
const track = trackRef.current
|
const track = trackRef.current
|
||||||
if (!element || !content || !track) return
|
if (!element || !track) return
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
const viewportEntry = entries.find((entry) => entry.target === element)
|
const entry = entries[0]
|
||||||
if (viewportEntry) {
|
if (entry) {
|
||||||
onViewportResizeRef.current?.({
|
onViewportResizeRef.current?.({
|
||||||
width: viewportEntry.contentRect.width,
|
width: entry.contentRect.width,
|
||||||
height: viewportEntry.contentRect.height,
|
height: entry.contentRect.height,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
snapshot(false)
|
snapshot(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
resizeObserver.observe(element)
|
resizeObserver.observe(element)
|
||||||
resizeObserver.observe(content)
|
|
||||||
resizeObserver.observe(track)
|
resizeObserver.observe(track)
|
||||||
snapshot(false)
|
snapshot(false)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect()
|
resizeObserver.disconnect()
|
||||||
}
|
}
|
||||||
}, [contentRef, snapshot, trackRef, viewportRef])
|
}, [snapshot, trackRef, viewportRef])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
snapshot(false)
|
snapshot(false)
|
||||||
+8
-79
@@ -1,32 +1,24 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
currentAppVersion,
|
currentAppVersion,
|
||||||
hasDockVisibilitySetting,
|
|
||||||
hasNativeAppLifecycle,
|
hasNativeAppLifecycle,
|
||||||
readAutostart,
|
readAutostart,
|
||||||
readDesktopSettings,
|
|
||||||
writeAutostart,
|
writeAutostart,
|
||||||
writeDockIconVisibility,
|
} from "../../native/appLifecycle";
|
||||||
writeSilentStart,
|
import { updateStore, useUpdateStore } from "../../store/updateStore";
|
||||||
} from "../../shared/native/appLifecycle";
|
import { Button } from "../ui/Button";
|
||||||
import { updateStore, useUpdateStore } from "../../shared/store/updateStore";
|
import { Switch } from "../ui/Switch";
|
||||||
import { Button } from "../../shared/ui/Button";
|
import { TitledCard } from "../ui/TitledCard";
|
||||||
import { Switch } from "../../shared/ui/Switch";
|
import { useMessage } from "../ui/message";
|
||||||
import { TitledCard } from "../../shared/ui/TitledCard";
|
|
||||||
import { useMessage } from "../../shared/ui/message";
|
|
||||||
import styles from "./AppLifecycleSettingsCard.module.scss";
|
import styles from "./AppLifecycleSettingsCard.module.scss";
|
||||||
|
|
||||||
export function AppLifecycleSettingsCard() {
|
export function AppLifecycleSettingsCard() {
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const native = hasNativeAppLifecycle();
|
const native = hasNativeAppLifecycle();
|
||||||
const dockVisibilitySetting = hasDockVisibilitySetting();
|
|
||||||
const { availableVersion, checking, installing } = useUpdateStore();
|
const { availableVersion, checking, installing } = useUpdateStore();
|
||||||
const [version, setVersion] = useState("…");
|
const [version, setVersion] = useState("…");
|
||||||
const [autostart, setAutostart] = useState(false);
|
const [autostart, setAutostart] = useState(false);
|
||||||
const [loadingAutostart, setLoadingAutostart] = useState(native);
|
const [loadingAutostart, setLoadingAutostart] = useState(native);
|
||||||
const [silentStart, setSilentStart] = useState(false);
|
|
||||||
const [dockIconVisible, setDockIconVisible] = useState(true);
|
|
||||||
const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -36,17 +28,6 @@ export function AppLifecycleSettingsCard() {
|
|||||||
.then((enabled) => { if (!disposed) setAutostart(enabled); })
|
.then((enabled) => { if (!disposed) setAutostart(enabled); })
|
||||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
|
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
|
||||||
.finally(() => { if (!disposed) setLoadingAutostart(false); });
|
.finally(() => { if (!disposed) setLoadingAutostart(false); });
|
||||||
void readDesktopSettings()
|
|
||||||
.then((settings) => {
|
|
||||||
if (disposed) return;
|
|
||||||
setSilentStart(settings.silent_start);
|
|
||||||
setDockIconVisible(settings.show_dock_icon);
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
if (disposed) return;
|
|
||||||
setLoadingDesktopSettings(false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return () => { disposed = true; };
|
return () => { disposed = true; };
|
||||||
}, [message, native]);
|
}, [message, native]);
|
||||||
@@ -64,39 +45,12 @@ export function AppLifecycleSettingsCard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSilentStart = async (enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
setLoadingDesktopSettings(true);
|
|
||||||
await writeSilentStart(enabled);
|
|
||||||
setSilentStart(enabled);
|
|
||||||
message(enabled ? t("已开启静默启动") : t("已关闭静默启动"));
|
|
||||||
} catch (cause) {
|
|
||||||
message(cause instanceof Error ? cause.message : String(cause));
|
|
||||||
} finally {
|
|
||||||
setLoadingDesktopSettings(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDockIcon = async (visible: boolean) => {
|
|
||||||
try {
|
|
||||||
setLoadingDesktopSettings(true);
|
|
||||||
await writeDockIconVisibility(visible);
|
|
||||||
setDockIconVisible(visible);
|
|
||||||
message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标"));
|
|
||||||
} catch (cause) {
|
|
||||||
message(cause instanceof Error ? cause.message : String(cause));
|
|
||||||
} finally {
|
|
||||||
setLoadingDesktopSettings(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkUpdate = async () => {
|
const checkUpdate = async () => {
|
||||||
try {
|
try {
|
||||||
const nextVersion = await updateStore.check();
|
const nextVersion = await updateStore.check();
|
||||||
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
|
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const error = cause instanceof Error ? cause.message : String(cause);
|
message(cause instanceof Error ? cause.message : String(cause));
|
||||||
message(t("检查更新失败:{error}", { error }));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,8 +58,7 @@ export function AppLifecycleSettingsCard() {
|
|||||||
try {
|
try {
|
||||||
await updateStore.install();
|
await updateStore.install();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const error = cause instanceof Error ? cause.message : String(cause);
|
message(cause instanceof Error ? cause.message : String(cause));
|
||||||
message(t("安装更新失败:{error}", { error }));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,30 +75,6 @@ export function AppLifecycleSettingsCard() {
|
|||||||
onChange={(enabled) => void toggleAutostart(enabled)}
|
onChange={(enabled) => void toggleAutostart(enabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{autostart && <div className={styles.row}>
|
|
||||||
<div>
|
|
||||||
<strong>{t("静默启动")}</strong>
|
|
||||||
<small>{t("开机启动时不显示主窗口,仅保留系统托盘图标。")}</small>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={silentStart}
|
|
||||||
disabled={!native || loadingDesktopSettings}
|
|
||||||
label={t("静默启动")}
|
|
||||||
onChange={(enabled) => void toggleSilentStart(enabled)}
|
|
||||||
/>
|
|
||||||
</div>}
|
|
||||||
{dockVisibilitySetting && <div className={styles.row}>
|
|
||||||
<div>
|
|
||||||
<strong>{t("在 Dock 栏显示")}</strong>
|
|
||||||
<small>{t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")}</small>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={dockIconVisible}
|
|
||||||
disabled={loadingDesktopSettings}
|
|
||||||
label={t("在 Dock 栏显示")}
|
|
||||||
onChange={(visible) => void toggleDockIcon(visible)}
|
|
||||||
/>
|
|
||||||
</div>}
|
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("软件更新")}</strong>
|
<strong>{t("软件更新")}</strong>
|
||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control {
|
.control {
|
||||||
width: min(320px, 50%);
|
width: min(420px, 62%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
+8
-8
@@ -1,9 +1,9 @@
|
|||||||
import type { ProxySettings, ProxySettingsInput } from "../../shared/api";
|
import type { ProxySettings, ProxySettingsInput } from "../../api";
|
||||||
import { Button } from "../../shared/ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { Checkbox } from "../../shared/ui/Checkbox";
|
import { Checkbox } from "../ui/Checkbox";
|
||||||
import { TextInput } from "../../shared/ui/FormControls";
|
import { TextInput } from "../ui/FormControls";
|
||||||
import { Select } from "../../shared/ui/Select";
|
import { Select } from "../ui/Select";
|
||||||
import { TitledCard } from "../../shared/ui/TitledCard";
|
import { TitledCard } from "../ui/TitledCard";
|
||||||
import styles from "./ProxySettingsCard.module.scss";
|
import styles from "./ProxySettingsCard.module.scss";
|
||||||
|
|
||||||
export function ProxySettingsCard({
|
export function ProxySettingsCard({
|
||||||
@@ -26,7 +26,7 @@ export function ProxySettingsCard({
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
const custom = draft.mode === "custom";
|
const custom = draft.mode === "custom";
|
||||||
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "default" ? t("默认") : t("自定义");
|
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
|
||||||
const action = editing ? (
|
const action = editing ? (
|
||||||
<div className={styles.actionGroup}>
|
<div className={styles.actionGroup}>
|
||||||
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
|
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
|
||||||
@@ -43,7 +43,7 @@ export function ProxySettingsCard({
|
|||||||
{editing ? <>
|
{editing ? <>
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<strong>{t("代理方式")}</strong>
|
<strong>{t("代理方式")}</strong>
|
||||||
<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 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>
|
</div>
|
||||||
{custom && <div className={styles.customFields}>
|
{custom && <div className={styles.customFields}>
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ComponentProps } from "react";
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
import controls from "./Controls.module.scss";
|
import controls from "./Controls.module.scss";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary";
|
export type ButtonVariant = "primary" | "secondary";
|
||||||
@@ -10,7 +10,7 @@ export function Button({
|
|||||||
className,
|
className,
|
||||||
type = "button",
|
type = "button",
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<"button"> & {
|
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
}) {
|
}) {
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ElementType, ReactNode } from "react";
|
||||||
|
import styles from "./Card.module.scss";
|
||||||
|
|
||||||
|
type CardProps = {
|
||||||
|
as?: ElementType;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Card({ as: Component = "div", className, children }: CardProps) {
|
||||||
|
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
|
||||||
|
}
|
||||||
-1
@@ -8,7 +8,6 @@
|
|||||||
color: var(--vscode-foreground);
|
color: var(--vscode-foreground);
|
||||||
font-size: type.$font-size-xs;
|
font-size: type.$font-size-xs;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
input {
|
input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
+2
-3
@@ -8,7 +8,6 @@
|
|||||||
background: var(--vscode-button-background);
|
background: var(--vscode-button-background);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
&:disabled { opacity: .5; cursor: not-allowed; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
@@ -35,8 +34,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iconButton {
|
.iconButton {
|
||||||
width: 25px;
|
width: 30px;
|
||||||
height: 25px;
|
height: 30px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
-10
@@ -47,16 +47,6 @@
|
|||||||
.input {
|
.input {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
|
||||||
&[type="number"] {
|
|
||||||
appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[type="number"]::-webkit-inner-spin-button,
|
|
||||||
&[type="number"]::-webkit-outer-spin-button {
|
|
||||||
margin: 0;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secret {
|
.secret {
|
||||||
+1
-1
@@ -12,7 +12,7 @@ export function SecretTextInput({ className, ...props }: InputHTMLAttributes<HTM
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
return <div className={styles.secret}>
|
return <div className={styles.secret}>
|
||||||
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
|
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
|
||||||
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏敏感内容") : t("显示敏感内容")} onClick={() => setVisible((current) => !current)}>
|
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏 API Key") : t("显示 API Key")} onClick={() => setVisible((current) => !current)}>
|
||||||
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
|
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
|
||||||
</button>
|
</button>
|
||||||
</div>;
|
</div>;
|
||||||
+7
-17
@@ -34,14 +34,12 @@
|
|||||||
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
|
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
|
||||||
|
|
||||||
header {
|
header {
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
border-bottom: 1px solid var(--vscode-editorWidget-border);
|
border-bottom: 1px solid var(--vscode-editorWidget-border);
|
||||||
font-size: type.$font-size-base;
|
font-size: type.$font-size-base;
|
||||||
font-weight: 630;
|
font-weight: 630;
|
||||||
}
|
}
|
||||||
footer {
|
footer {
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -54,23 +52,15 @@
|
|||||||
width: min(1080px, calc(100vw - 48px));
|
width: min(1080px, calc(100vw - 48px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact {
|
|
||||||
width: min(560px, calc(100vw - 48px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullHeight {
|
|
||||||
height: calc(100vh - var(--window-header-height) - 24px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 12px 18px;
|
|
||||||
border-bottom: 1px solid var(--vscode-editorWidget-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
flex: 1 1 auto;
|
flex: 0 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
max-height: calc(100vh - var(--window-header-height) - 112px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyViewport {
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - var(--window-header-height) - 112px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bodyContent {
|
.bodyContent {
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user