mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b7ea9cc8 | ||
|
|
0fd5e9d6f2 | ||
|
|
ddaa61c827 | ||
|
|
919c1d8032 | ||
|
|
669f129dcd | ||
|
|
f22c7b6680 | ||
|
|
5cdf642dd1 | ||
|
|
2dad593263 | ||
|
|
76417e005b | ||
|
|
e768980dad | ||
|
|
2c63bd845a | ||
|
|
6e74637c69 | ||
|
|
d004139526 | ||
|
|
8c6c415a84 | ||
|
|
d83e14af9a | ||
|
|
29fde7d7c7 | ||
|
|
ee2592c469 | ||
|
|
49c1fb6378 | ||
|
|
788868f8b9 | ||
|
|
4c3fe230ce | ||
|
|
ac14245d19 | ||
|
|
84addec26a | ||
|
|
5de547041c | ||
|
|
8bd0d70add | ||
|
|
3ac4402a86 | ||
|
|
e535a98945 | ||
|
|
45e694fd63 | ||
|
|
9120b90be7 | ||
|
|
b807608bf3 | ||
|
|
e7a1cca4c6 | ||
|
|
76baa3b0e7 | ||
|
|
21048fb34b | ||
|
|
fc79adbb43 | ||
|
|
3184615719 | ||
|
|
5e405b31b2 | ||
|
|
3a2d47954e | ||
|
|
43a18377b6 | ||
|
|
453c120740 | ||
|
|
a5bbe67845 | ||
|
|
05181f9e8a | ||
|
|
4373507571 | ||
|
|
e6130e01a7 | ||
|
|
e673a034df | ||
|
|
6ac666da4f | ||
|
|
9e2d22418b | ||
|
|
97ee138de8 | ||
|
|
1609b57433 | ||
|
|
e5bfcdd202 | ||
|
|
76003a907a |
@@ -0,0 +1,71 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
function readOptions(args) {
|
||||
const options = new Map();
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const name = args[index];
|
||||
const value = args[index + 1];
|
||||
if (!name?.startsWith("--") || value === undefined) {
|
||||
throw new Error(`invalid argument near ${name ?? "end of command"}`);
|
||||
}
|
||||
options.set(name.slice(2), value);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function required(options, name) {
|
||||
const value = options.get(name)?.trim();
|
||||
if (!value) throw new Error(`--${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function generatePortableUpdate({ version, repository, assetName, signature }) {
|
||||
const normalizedVersion = version.replace(/^v/, "");
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(normalizedVersion)) {
|
||||
throw new Error(`invalid semantic version: ${normalizedVersion}`);
|
||||
}
|
||||
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
|
||||
throw new Error(`invalid GitHub repository: ${repository}`);
|
||||
}
|
||||
if (!assetName || basename(assetName) !== assetName) {
|
||||
throw new Error("asset name must be a file name");
|
||||
}
|
||||
if (!signature.trim()) throw new Error("signature is required");
|
||||
|
||||
return {
|
||||
version: normalizedVersion,
|
||||
notes: `Cursor BYOK v${normalizedVersion}`,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms: {
|
||||
"windows-x86_64": {
|
||||
signature: signature.trim(),
|
||||
url: `https://github.com/${repository}/releases/download/v${normalizedVersion}/${encodeURIComponent(assetName)}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = readOptions(process.argv.slice(2));
|
||||
const version = required(options, "version");
|
||||
const repository = required(options, "repository");
|
||||
const asset = required(options, "asset");
|
||||
const signaturePath = resolve(required(options, "signature"));
|
||||
const output = resolve(required(options, "output"));
|
||||
const manifest = generatePortableUpdate({
|
||||
version,
|
||||
repository,
|
||||
assetName: basename(asset),
|
||||
signature: await readFile(signaturePath, "utf8"),
|
||||
});
|
||||
await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { generatePortableUpdate } from "./generate-portable-update.mjs";
|
||||
|
||||
test("generates a signed Windows portable updater manifest", () => {
|
||||
const manifest = generatePortableUpdate({
|
||||
version: "v1.2.3-beta.1",
|
||||
repository: "owner/repository",
|
||||
assetName: "cursor-byok-1.2.3-beta.1-windows-amd64.zip",
|
||||
signature: "signed-payload\n",
|
||||
});
|
||||
|
||||
assert.equal(manifest.version, "1.2.3-beta.1");
|
||||
assert.deepEqual(Object.keys(manifest.platforms), ["windows-x86_64"]);
|
||||
assert.equal(manifest.platforms["windows-x86_64"].signature, "signed-payload");
|
||||
assert.equal(
|
||||
manifest.platforms["windows-x86_64"].url,
|
||||
"https://github.com/owner/repository/releases/download/v1.2.3-beta.1/cursor-byok-1.2.3-beta.1-windows-amd64.zip",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid inputs", () => {
|
||||
assert.throws(() => generatePortableUpdate({
|
||||
version: "latest",
|
||||
repository: "owner/repository",
|
||||
assetName: "update.zip",
|
||||
signature: "signature",
|
||||
}), /semantic version/);
|
||||
assert.throws(() => generatePortableUpdate({
|
||||
version: "1.2.3",
|
||||
repository: "owner/repository",
|
||||
assetName: "../update.zip",
|
||||
signature: "signature",
|
||||
}), /file name/);
|
||||
assert.throws(() => generatePortableUpdate({
|
||||
version: "1.2.3",
|
||||
repository: "owner/repository",
|
||||
assetName: "update.zip",
|
||||
signature: " ",
|
||||
}), /signature/);
|
||||
});
|
||||
@@ -158,14 +158,27 @@ jobs:
|
||||
mkdir -p legacy-update
|
||||
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
|
||||
|
||||
- name: Package legacy Windows updater asset
|
||||
- name: Package and sign legacy Windows updater asset
|
||||
if: matrix.platform == 'windows-x86_64'
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force legacy-update | Out-Null
|
||||
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
|
||||
$asset = "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
|
||||
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath $asset
|
||||
Push-Location apps/desktop
|
||||
npm exec tauri signer sign -- "../../$asset"
|
||||
Pop-Location
|
||||
$entries = @(tar -tf $asset)
|
||||
if ($entries.Count -ne 1 -or [System.IO.Path]::GetFileName($entries[0]) -ne 'cursor-byok-desktop.exe') {
|
||||
throw "Windows updater archive must contain only cursor-byok-desktop.exe"
|
||||
}
|
||||
if (!(Test-Path "$asset.sig")) {
|
||||
throw "Windows updater archive signature was not generated"
|
||||
}
|
||||
|
||||
- name: Package legacy macOS updater asset
|
||||
if: contains(matrix.platform, 'macos')
|
||||
@@ -213,6 +226,20 @@ jobs:
|
||||
--output legacy-update/update.json \
|
||||
--notes "Cursor BYOK v${VERSION}"
|
||||
|
||||
- name: Generate signed Windows portable update manifest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
asset="cursor-byok-${VERSION}-windows-amd64.zip"
|
||||
test -f "legacy-update/${asset}"
|
||||
test -f "legacy-update/${asset}.sig"
|
||||
node .github/scripts/generate-portable-update.mjs \
|
||||
--version "${VERSION}" \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--asset "${asset}" \
|
||||
--signature "legacy-update/${asset}.sig" \
|
||||
--output legacy-update/portable-latest.json
|
||||
|
||||
- name: Normalize Tauri updater download URLs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
Generated
+27
-1
@@ -1172,10 +1172,11 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.5-beta.1"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cursor-server",
|
||||
"libc",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1187,12 +1188,15 @@ dependencies = [
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1248,6 +1252,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"x509-parser",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1929,6 +1934,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9079,16 +9085,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5-beta.1",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5-beta.1",
|
||||
"version": "0.1.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5-beta.1",
|
||||
"version": "0.1.6",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
||||
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
|
||||
"build": "vite build",
|
||||
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
|
||||
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.5-beta.1"
|
||||
version = "0.1.6"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
cursor-server = { path = "../../../server" }
|
||||
libc = "0.2"
|
||||
rfd = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -24,9 +25,14 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
url = "2"
|
||||
zip = { version = "4", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading"] }
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
fn main() {
|
||||
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
|
||||
let manifest = tauri_build::AppManifest::new().commands(&[
|
||||
"open_terminal_with_command",
|
||||
"check_portable_update",
|
||||
"install_portable_update",
|
||||
]);
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
|
||||
.expect("failed to build Tauri application")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"core:window:allow-close",
|
||||
"core:app:allow-set-dock-visibility",
|
||||
"allow-open-terminal-with-command",
|
||||
"allow-check-portable-update",
|
||||
"allow-install-portable-update",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"autostart:default",
|
||||
"process:allow-restart",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-check-portable-update"
|
||||
description = "Enables the check_portable_update command without any pre-configured scope."
|
||||
commands.allow = ["check_portable_update"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-check-portable-update"
|
||||
description = "Denies the check_portable_update command without any pre-configured scope."
|
||||
commands.deny = ["check_portable_update"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-install-portable-update"
|
||||
description = "Enables the install_portable_update command without any pre-configured scope."
|
||||
commands.allow = ["install_portable_update"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-install-portable-update"
|
||||
description = "Denies the install_portable_update command without any pre-configured scope."
|
||||
commands.deny = ["install_portable_update"]
|
||||
@@ -149,6 +149,23 @@ pub fn run() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let open_file_limit = match crate::resource_limits::raise_open_file_limit() {
|
||||
Ok(limit) => limit,
|
||||
Err(error) => {
|
||||
diagnostics.report_fatal(&error);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
requested = crate::resource_limits::REQUESTED_OPEN_FILE_LIMIT,
|
||||
previous = open_file_limit.previous,
|
||||
effective = open_file_limit.effective,
|
||||
hard = open_file_limit.hard,
|
||||
"open file limit configured"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
os = std::env::consts::OS,
|
||||
@@ -160,7 +177,11 @@ pub fn run() -> ExitCode {
|
||||
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_terminal_with_command,
|
||||
crate::update::check_portable_update,
|
||||
crate::update::install_portable_update,
|
||||
])
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
||||
tray::show_main_window(app);
|
||||
@@ -175,7 +196,12 @@ pub fn run() -> ExitCode {
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec![AUTOSTART_ARG]),
|
||||
))?;
|
||||
let config = Config::desktop()?;
|
||||
let config = {
|
||||
let mut config = Config::desktop()?;
|
||||
// 插件的 minAppVersion 按桌面应用版本判定,而不是内嵌 server 库的版本。
|
||||
config.app_version = env!("CARGO_PKG_VERSION").into();
|
||||
config
|
||||
};
|
||||
#[cfg(dev)]
|
||||
let config = {
|
||||
let mut config = config;
|
||||
@@ -223,6 +249,7 @@ pub fn run() -> ExitCode {
|
||||
window.set_focus()?;
|
||||
}
|
||||
tray::create(app)?;
|
||||
crate::update::signal_ready_if_requested()?;
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!());
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
mod desktop;
|
||||
#[cfg(not(dev))]
|
||||
mod frontend;
|
||||
mod resource_limits;
|
||||
mod startup;
|
||||
mod tray;
|
||||
mod update;
|
||||
|
||||
pub use desktop::run;
|
||||
pub fn run() -> std::process::ExitCode {
|
||||
if let Some(exit_code) = update::run_replacement_if_requested() {
|
||||
return exit_code;
|
||||
}
|
||||
desktop::run()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Configures process resource limits before the desktop runtime starts.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::io;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) const REQUESTED_OPEN_FILE_LIMIT: u64 = 65_536;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) struct OpenFileLimit {
|
||||
pub(crate) previous: u64,
|
||||
pub(crate) effective: u64,
|
||||
pub(crate) hard: u64,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn raise_open_file_limit() -> io::Result<OpenFileLimit> {
|
||||
let mut limits = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
// SAFETY: `limits` points to writable memory for one `rlimit` value.
|
||||
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let previous = limits.rlim_cur;
|
||||
let target = limits
|
||||
.rlim_max
|
||||
.min(REQUESTED_OPEN_FILE_LIMIT as libc::rlim_t);
|
||||
if previous < target {
|
||||
let requested = libc::rlimit {
|
||||
rlim_cur: target,
|
||||
rlim_max: limits.rlim_max,
|
||||
};
|
||||
// SAFETY: `requested` is a valid `rlimit` value and does not raise the hard limit.
|
||||
if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &requested) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
|
||||
let mut effective = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
// SAFETY: `effective` points to writable memory for one `rlimit` value.
|
||||
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut effective) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(OpenFileLimit {
|
||||
previous: previous as u64,
|
||||
effective: effective.rlim_cur as u64,
|
||||
hard: effective.rlim_max as u64,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::{Cursor, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode},
|
||||
};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod replacement;
|
||||
|
||||
const PORTABLE_UPDATE_ENDPOINT: &str =
|
||||
"https://github.com/leookun/cursor-byok/releases/latest/download/portable-latest.json";
|
||||
const WINDOWS_PAYLOAD_NAME: &str = "cursor-byok-desktop.exe";
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PortableUpdateInfo {
|
||||
version: String,
|
||||
}
|
||||
|
||||
pub fn run_replacement_if_requested() -> Option<ExitCode> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
match replacement::request_from_args() {
|
||||
Ok(Some(request)) => return Some(replacement::run(request)),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
eprintln!("invalid portable update replacement request: {error}");
|
||||
return Some(ExitCode::FAILURE);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn signal_ready_if_requested() -> std::io::Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(path) = replacement::ready_marker_from_args() {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, b"ready")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn check_portable_update(
|
||||
app: AppHandle,
|
||||
) -> Result<Option<PortableUpdateInfo>, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let update = portable_update(&app).await?;
|
||||
return Ok(update.map(|update| PortableUpdateInfo {
|
||||
version: update.version,
|
||||
}));
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = app;
|
||||
Err("portable updates are only supported on Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_portable_update(
|
||||
app: AppHandle,
|
||||
expected_version: String,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let update = portable_update(&app)
|
||||
.await?
|
||||
.ok_or_else(|| "the selected update is no longer available".to_string())?;
|
||||
if update.version != expected_version {
|
||||
return Err(format!(
|
||||
"available update changed from {expected_version} to {}",
|
||||
update.version
|
||||
));
|
||||
}
|
||||
|
||||
let target = std::env::current_exe()
|
||||
.map_err(|error| format!("failed to locate the running executable: {error}"))?;
|
||||
ensure_target_writable(&target)
|
||||
.map_err(|error| format!("the application directory is not writable: {error}"))?;
|
||||
|
||||
let bytes = update
|
||||
.download(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|error| format!("failed to download or verify the update: {error}"))?;
|
||||
let payload = extract_windows_payload(&bytes)
|
||||
.map_err(|error| format!("invalid Windows update archive: {error}"))?;
|
||||
let staged = stage_payload(&target, &payload)
|
||||
.map_err(|error| format!("failed to stage the update: {error}"))?;
|
||||
|
||||
let handshake = staged.with_extension("started");
|
||||
let _ = fs::remove_file(&handshake);
|
||||
let mut replacement = Command::new(&staged)
|
||||
.arg("--apply-portable-update")
|
||||
.arg("--update-target")
|
||||
.arg(&target)
|
||||
.arg("--update-wait-pid")
|
||||
.arg(std::process::id().to_string())
|
||||
.arg("--update-handshake")
|
||||
.arg(&handshake)
|
||||
.spawn()
|
||||
.map_err(|error| format!("failed to start the update replacement process: {error}"))?;
|
||||
wait_for_replacement_start(&mut replacement, &handshake).await?;
|
||||
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (app, expected_version);
|
||||
Err("portable updates are only supported on Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn portable_update(app: &AppHandle) -> Result<Option<tauri_plugin_updater::Update>, String> {
|
||||
let endpoint = PORTABLE_UPDATE_ENDPOINT
|
||||
.parse()
|
||||
.map_err(|error| format!("invalid portable update endpoint: {error}"))?;
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![endpoint])
|
||||
.map_err(|error| format!("failed to configure the updater: {error}"))?
|
||||
.build()
|
||||
.map_err(|error| format!("failed to initialize the updater: {error}"))?;
|
||||
updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|error| format!("failed to check for updates: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn wait_for_replacement_start(
|
||||
child: &mut std::process::Child,
|
||||
handshake: &Path,
|
||||
) -> Result<(), String> {
|
||||
let wait = async {
|
||||
loop {
|
||||
if handshake.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.map_err(|error| format!("failed to inspect replacement process: {error}"))?
|
||||
{
|
||||
return Err(format!(
|
||||
"update replacement process exited before it was ready: {status}"
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
};
|
||||
let result = match tokio::time::timeout(std::time::Duration::from_secs(5), wait).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err("update replacement process did not become ready".into());
|
||||
}
|
||||
};
|
||||
if result.is_err() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn ensure_target_writable(target: &Path) -> std::io::Result<()> {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("application executable has no parent directory"))?;
|
||||
let probe = parent.join(format!(
|
||||
".cursor-byok-update-write-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&probe)?;
|
||||
file.write_all(b"test")?;
|
||||
drop(file);
|
||||
fs::remove_file(probe)
|
||||
}
|
||||
|
||||
fn extract_windows_payload(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
|
||||
.map_err(|error| format!("failed to open ZIP: {error}"))?;
|
||||
if archive.len() != 1 {
|
||||
return Err("archive must contain exactly one file".into());
|
||||
}
|
||||
let mut entry = archive
|
||||
.by_index(0)
|
||||
.map_err(|error| format!("failed to read ZIP entry: {error}"))?;
|
||||
let name = Path::new(entry.name())
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| "archive entry has an invalid file name".to_string())?;
|
||||
if name != WINDOWS_PAYLOAD_NAME || entry.is_dir() {
|
||||
return Err(format!(
|
||||
"expected {WINDOWS_PAYLOAD_NAME}, found {}",
|
||||
entry.name()
|
||||
));
|
||||
}
|
||||
let mut payload = Vec::with_capacity(entry.size() as usize);
|
||||
entry
|
||||
.read_to_end(&mut payload)
|
||||
.map_err(|error| format!("failed to extract executable: {error}"))?;
|
||||
if payload.len() < 2 || &payload[..2] != b"MZ" {
|
||||
return Err("payload is not a Windows executable".into());
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn stage_payload(target: &Path, payload: &[u8]) -> std::io::Result<PathBuf> {
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix("cursor-byok-portable-update-")
|
||||
.tempdir()?;
|
||||
let name = target
|
||||
.file_name()
|
||||
.ok_or_else(|| std::io::Error::other("application executable has no file name"))?;
|
||||
let path = directory.path().join(name);
|
||||
fs::write(&path, payload)?;
|
||||
let _ = directory.keep();
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn update_zip(name: &str, payload: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut archive = zip::ZipWriter::new(&mut bytes);
|
||||
archive
|
||||
.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
archive.write_all(payload).unwrap();
|
||||
archive.finish().unwrap();
|
||||
}
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_the_single_expected_windows_executable() {
|
||||
let bytes = update_zip(WINDOWS_PAYLOAD_NAME, b"MZpayload");
|
||||
assert_eq!(extract_windows_payload(&bytes).unwrap(), b"MZpayload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unexpected_or_non_executable_payloads() {
|
||||
let wrong_name = update_zip("other.exe", b"MZpayload");
|
||||
assert!(extract_windows_payload(&wrong_name).is_err());
|
||||
let wrong_content = update_zip(WINDOWS_PAYLOAD_NAME, b"not an executable");
|
||||
assert!(extract_windows_payload(&wrong_content).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::{
|
||||
ffi::{OsStr, OsString},
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, ExitCode},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const APPLY_ARG: &str = "--apply-portable-update";
|
||||
const TARGET_ARG: &str = "--update-target";
|
||||
const PID_ARG: &str = "--update-wait-pid";
|
||||
const HANDSHAKE_ARG: &str = "--update-handshake";
|
||||
pub(super) const READY_ARG: &str = "--portable-update-ready";
|
||||
const PROCESS_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const READY_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub(super) struct ReplacementRequest {
|
||||
target: PathBuf,
|
||||
pid: u32,
|
||||
handshake: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) fn request_from_args() -> Result<Option<ReplacementRequest>, String> {
|
||||
let args = std::env::args_os().collect::<Vec<_>>();
|
||||
if !args.iter().any(|arg| arg == APPLY_ARG) {
|
||||
return Ok(None);
|
||||
}
|
||||
let target = PathBuf::from(
|
||||
argument_value(&args, TARGET_ARG).ok_or_else(|| format!("{TARGET_ARG} is required"))?,
|
||||
);
|
||||
let pid = argument_value(&args, PID_ARG)
|
||||
.ok_or_else(|| format!("{PID_ARG} is required"))?
|
||||
.to_string_lossy()
|
||||
.parse::<u32>()
|
||||
.map_err(|error| format!("invalid {PID_ARG}: {error}"))?;
|
||||
let handshake = PathBuf::from(
|
||||
argument_value(&args, HANDSHAKE_ARG)
|
||||
.ok_or_else(|| format!("{HANDSHAKE_ARG} is required"))?,
|
||||
);
|
||||
validate_target(&target).map_err(|error| error.to_string())?;
|
||||
Ok(Some(ReplacementRequest {
|
||||
target,
|
||||
pid,
|
||||
handshake,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn ready_marker_from_args() -> Option<PathBuf> {
|
||||
let args = std::env::args_os().collect::<Vec<_>>();
|
||||
argument_value(&args, READY_ARG).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn argument_value<'a>(args: &'a [OsString], name: &str) -> Option<&'a OsStr> {
|
||||
args.iter()
|
||||
.position(|arg| arg == name)
|
||||
.and_then(|index| args.get(index + 1))
|
||||
.map(OsString::as_os_str)
|
||||
}
|
||||
|
||||
fn validate_target(target: &Path) -> io::Result<()> {
|
||||
if !target.is_absolute() || !target.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"update target must be an existing absolute file",
|
||||
));
|
||||
}
|
||||
let source_name = std::env::current_exe()?
|
||||
.file_name()
|
||||
.map(OsStr::to_os_string)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "updater has no file name"))?;
|
||||
if target.file_name() != Some(source_name.as_os_str()) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"update target file name does not match the updater",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn run(request: ReplacementRequest) -> ExitCode {
|
||||
match run_inner(request) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("portable update replacement failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_inner(request: ReplacementRequest) -> io::Result<()> {
|
||||
wait_for_process(request.pid, PROCESS_WAIT_TIMEOUT, &request.handshake)?;
|
||||
remove_file_if_exists(&request.handshake)?;
|
||||
let source = std::env::current_exe()?;
|
||||
let backup = backup_path(&request.target);
|
||||
let ready = source.with_extension("ready");
|
||||
remove_file_if_exists(&ready)?;
|
||||
|
||||
if let Err(error) = install_staged(&source, &request.target, &backup) {
|
||||
relaunch(&request.target);
|
||||
return Err(error);
|
||||
}
|
||||
let mut child = match Command::new(&request.target)
|
||||
.arg(READY_ARG)
|
||||
.arg(&ready)
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
restore_backup(&request.target, &backup)?;
|
||||
relaunch(&request.target);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
match wait_until_ready(&mut child, &ready, READY_WAIT_TIMEOUT) {
|
||||
Ok(()) => {
|
||||
let _ = fs::remove_file(&backup);
|
||||
let _ = fs::remove_file(&ready);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
restore_backup(&request.target, &backup)?;
|
||||
relaunch(&request.target);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relaunch(target: &Path) {
|
||||
if target.is_file() {
|
||||
let _ = Command::new(target).spawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_file_if_exists(path: &Path) -> io::Result<()> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_path(target: &Path) -> PathBuf {
|
||||
path_with_suffix(target, ".old")
|
||||
}
|
||||
|
||||
fn pending_path(target: &Path) -> PathBuf {
|
||||
path_with_suffix(target, ".new")
|
||||
}
|
||||
|
||||
fn path_with_suffix(target: &Path, suffix: &str) -> PathBuf {
|
||||
let mut name = target.as_os_str().to_os_string();
|
||||
name.push(suffix);
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
fn install_staged(source: &Path, target: &Path, backup: &Path) -> io::Result<()> {
|
||||
let pending = pending_path(target);
|
||||
remove_file_if_exists(&pending)?;
|
||||
fs::copy(source, &pending)?;
|
||||
|
||||
let result = activate_pending(&pending, target, backup);
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&pending);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn activate_pending(pending: &Path, target: &Path, backup: &Path) -> io::Result<()> {
|
||||
remove_file_if_exists(backup)?;
|
||||
fs::rename(target, backup)?;
|
||||
if let Err(error) = fs::rename(pending, target) {
|
||||
if let Err(restore_error) = restore_backup(target, backup) {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to install update ({error}) and restore the original executable ({restore_error})"
|
||||
)));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_backup(target: &Path, backup: &Path) -> io::Result<()> {
|
||||
remove_file_if_exists(target)?;
|
||||
fs::rename(backup, target)
|
||||
}
|
||||
|
||||
fn wait_until_ready(child: &mut Child, marker: &Path, timeout: Duration) -> io::Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if marker.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Err(io::Error::other(format!(
|
||||
"updated application exited before startup completed: {status}"
|
||||
)));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"updated application did not report a successful startup",
|
||||
));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn wait_for_process(pid: u32, timeout: Duration, handshake: &Path) -> io::Result<()> {
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{
|
||||
CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT,
|
||||
},
|
||||
System::Threading::{OpenProcess, WaitForSingleObject},
|
||||
};
|
||||
|
||||
const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
|
||||
let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) };
|
||||
if handle.is_null() {
|
||||
let error = unsafe { GetLastError() };
|
||||
return if error == ERROR_INVALID_PARAMETER {
|
||||
fs::write(handshake, b"started")
|
||||
} else {
|
||||
Err(io::Error::from_raw_os_error(error as i32))
|
||||
};
|
||||
}
|
||||
if let Err(error) = fs::write(handshake, b"started") {
|
||||
unsafe { CloseHandle(handle) };
|
||||
return Err(error);
|
||||
}
|
||||
let milliseconds = timeout.as_millis().min(u32::MAX as u128) as u32;
|
||||
let result = unsafe { WaitForSingleObject(handle, milliseconds) };
|
||||
unsafe { CloseHandle(handle) };
|
||||
match result {
|
||||
WAIT_OBJECT_0 => Ok(()),
|
||||
WAIT_TIMEOUT => Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"running application did not exit before the update timeout",
|
||||
)),
|
||||
_ => Err(io::Error::last_os_error()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn staged_file_can_be_restored() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let source = directory.path().join("source.exe");
|
||||
let target = directory.path().join("target.exe");
|
||||
let backup = backup_path(&target);
|
||||
fs::write(&source, b"new").unwrap();
|
||||
fs::write(&target, b"old").unwrap();
|
||||
|
||||
install_staged(&source, &target, &backup).unwrap();
|
||||
assert_eq!(fs::read(&target).unwrap(), b"new");
|
||||
assert_eq!(fs::read(&backup).unwrap(), b"old");
|
||||
|
||||
restore_backup(&target, &backup).unwrap();
|
||||
assert_eq!(fs::read(&target).unwrap(), b"old");
|
||||
assert!(!backup.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pending_file_restores_original_after_backup() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let target = directory.path().join("target.exe");
|
||||
let pending = pending_path(&target);
|
||||
let backup = backup_path(&target);
|
||||
fs::write(&target, b"old").unwrap();
|
||||
|
||||
assert!(activate_pending(&pending, &target, &backup).is_err());
|
||||
assert_eq!(fs::read(&target).unwrap(), b"old");
|
||||
assert!(!backup.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_source_preserves_original_file() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let source = directory.path().join("missing.exe");
|
||||
let target = directory.path().join("target.exe");
|
||||
let backup = backup_path(&target);
|
||||
fs::write(&target, b"old").unwrap();
|
||||
|
||||
assert!(install_staged(&source, &target, &backup).is_err());
|
||||
assert_eq!(fs::read(&target).unwrap(), b"old");
|
||||
assert!(!backup.exists());
|
||||
assert!(!pending_path(&target).exists());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.5-beta.1",
|
||||
"version": "0.1.6",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
|
||||
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CallsPage } from "./features/calls/CallsPage";
|
||||
import { CallDetailsPage } from "./features/calls/CallDetailsPage";
|
||||
import { CursorSettingsPage } from "./features/models/CursorSettingsPage";
|
||||
import { HomePage } from "./features/home/HomePage";
|
||||
import { PluginManagementPage } from "./features/plugins/PluginManagementPage";
|
||||
import { SettingsPage } from "./features/settings/SettingsPage";
|
||||
import { useAppStore } from "./shared/store/appStore";
|
||||
import { updateStore } from "./shared/store/updateStore";
|
||||
@@ -23,6 +24,7 @@ export function App() {
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="calls" element={<CallsPage />} />
|
||||
<Route path="harness/cursor" element={<CursorSettingsPage />} />
|
||||
<Route path="plugins" element={<PluginManagementPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -185,6 +185,7 @@ function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/re
|
||||
model_hash: hash,
|
||||
sort_order: order,
|
||||
display_name: name,
|
||||
group_name: null,
|
||||
type,
|
||||
base_url: url,
|
||||
use_full_url: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Overview } from "../../shared/api";
|
||||
import { api, configuredPluginModels, type Overview } from "../../shared/api";
|
||||
import { ContributionCalendarChart } from "./charts/ContributionCalendarChart";
|
||||
import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart";
|
||||
import { HomeMetrics } from "./metrics/HomeMetrics";
|
||||
@@ -9,7 +9,7 @@ import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/Ov
|
||||
import { PageActions } from "../../shell/PageActions";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
|
||||
import { claudeIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
|
||||
type TimeRange = { startMs: number; endMs: number };
|
||||
|
||||
@@ -30,7 +30,7 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { overview, busy, models } = useAppStore();
|
||||
const { overview, busy, models, plugins } = useAppStore();
|
||||
const [preset, setPreset] = useState<OverviewRangePreset>("month");
|
||||
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
@@ -101,11 +101,18 @@ export function HomePage() {
|
||||
setRefreshVersion((version) => version + 1);
|
||||
};
|
||||
const iconFor = (type: string) => type === "anthropic" ? claudeIcon : openAiIcon;
|
||||
const modelOptions = models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
icon: iconFor(model.type),
|
||||
}));
|
||||
const modelOptions = [
|
||||
...models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
icon: iconFor(model.type),
|
||||
})),
|
||||
...configuredPluginModels(plugins).map((model) => ({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
icon: flatColorOrganizationIcon,
|
||||
})),
|
||||
];
|
||||
const sections: VirtualPageSection[] = [
|
||||
{
|
||||
key: "daily-token-usage",
|
||||
|
||||
@@ -24,8 +24,9 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh,
|
||||
}
|
||||
|
||||
export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||
const { models } = useAppStore();
|
||||
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||
const { models, plugins } = useAppStore();
|
||||
const hasConfiguredPlugin = plugins.some((plugin) => plugin.providers.some((provider) => provider.configured));
|
||||
return <ModelsReady.Provider value={models.length > 0 || hasConfiguredPlugin}>{children}</ModelsReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { IconifyIcon } from "@iconify/react/offline";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import Sortable from "sortablejs";
|
||||
import type { Model } from "../../shared/api";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import type { Model, PluginModelDescriptor } from "../../shared/api";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
@@ -20,6 +20,7 @@ export type CursorModelGroup = {
|
||||
|
||||
type CursorModelCardsProps = {
|
||||
models: Model[];
|
||||
pluginModels: PluginModelDescriptor[];
|
||||
grouping: CursorModelGrouping;
|
||||
disabled: boolean;
|
||||
testingModelHashes: Set<string>;
|
||||
@@ -28,10 +29,13 @@ type CursorModelCardsProps = {
|
||||
onEdit: (model: Model) => void;
|
||||
onDuplicate: (model: Model) => void;
|
||||
onDelete: (model: Model) => void;
|
||||
onTestPluginModel: (model: PluginModelDescriptor) => void;
|
||||
onPluginSettings: (model: PluginModelDescriptor) => void;
|
||||
onReorder: (modelHashes: string[]) => void;
|
||||
onGroupSettings: (group: CursorModelGroup) => void;
|
||||
};
|
||||
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping" | "pluginModels" | "onTestPluginModel" | "onPluginSettings"> & {
|
||||
sortable: boolean;
|
||||
};
|
||||
|
||||
@@ -50,18 +54,139 @@ export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModel
|
||||
}
|
||||
|
||||
export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
|
||||
<ModelGrid {...props} sortable />
|
||||
</div>;
|
||||
|
||||
const builtins = props.grouping === "flat"
|
||||
? <div style={{ paddingTop: "10px" }}><ModelGrid {...props} sortable /></div>
|
||||
: <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <CollapsibleGroup
|
||||
key={group.key}
|
||||
label={group.label}
|
||||
icon={group.icon}
|
||||
onSettings={props.grouping === "provider" ? () => props.onGroupSettings(group) : undefined}
|
||||
>
|
||||
{group.models.map((model) => <ModelListRow
|
||||
key={model.model_hash}
|
||||
model={model}
|
||||
disabled={props.disabled}
|
||||
testing={props.testingModelHashes.has(model.model_hash)}
|
||||
result={props.testResults.get(model.model_hash)}
|
||||
onTest={() => props.onTest(model)}
|
||||
onEdit={() => props.onEdit(model)}
|
||||
onDuplicate={() => props.onDuplicate(model)}
|
||||
onDelete={() => props.onDelete(model)}
|
||||
/>)}
|
||||
</CollapsibleGroup>)}
|
||||
</div>;
|
||||
return <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
|
||||
<div className={styles.modelGroupHeader}>
|
||||
<Icon icon={group.icon} size="1.1em" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
<ModelGrid {...props} models={group.models} sortable={false} />
|
||||
</section>)}
|
||||
{builtins}
|
||||
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
|
||||
key={group.pluginId}
|
||||
label={group.pluginName}
|
||||
iconSrc={group.icon}
|
||||
>
|
||||
{group.models.map((model) => <PluginModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
disabled={props.disabled}
|
||||
testing={props.testingModelHashes.has(model.id)}
|
||||
result={props.testResults.get(model.id)}
|
||||
onTest={() => props.onTestPluginModel(model)}
|
||||
onSettings={() => props.onPluginSettings(model)}
|
||||
/>)}
|
||||
</CollapsibleGroup>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function pluginGroups(models: PluginModelDescriptor[]) {
|
||||
const groups: { pluginId: string; pluginName: string; icon: string; models: PluginModelDescriptor[] }[] = [];
|
||||
for (const model of models) {
|
||||
let group = groups.find((candidate) => candidate.pluginId === model.pluginId);
|
||||
if (!group) {
|
||||
group = { pluginId: model.pluginId, pluginName: model.pluginName, icon: model.icon, models: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.models.push(model);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CollapsibleGroup({ label, icon, iconSrc, onSettings, children }: {
|
||||
label: string;
|
||||
icon?: IconifyIcon;
|
||||
iconSrc?: string;
|
||||
onSettings?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return <Card className={styles.groupCard}>
|
||||
<div className={styles.groupHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupToggle}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{icon && <Icon icon={icon} size="1.1em" />}
|
||||
{iconSrc && <Icon src={iconSrc} size="1.1em" />}
|
||||
<span className={styles.groupLabel}>{label}</span>
|
||||
</button>
|
||||
{onSettings && <button type="button" className={styles.groupSettings} onClick={onSettings}>{t("分组设置")}</button>}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupChevron}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Icon icon={open ? chevronDownIcon : chevronRightIcon} size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
{open && <div className={styles.modelList}>{children}</div>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ModelListRow({ model, disabled, testing, result, onTest, onEdit, onDuplicate, onDelete }: {
|
||||
model: Model;
|
||||
disabled: boolean;
|
||||
testing: boolean;
|
||||
result: CursorModelTestState | undefined;
|
||||
onTest: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return <div className={styles.modelRow}>
|
||||
<div className={styles.modelRowName}>
|
||||
<span className={styles.modelRowNameText}>{model.display_name}</span>
|
||||
<span className={styles.modelRowModelId}>{model.model_id}</span>
|
||||
</div>
|
||||
<CursorModelTestResult compact state={result} testing={testing} />
|
||||
<div className={styles.modelCardActions}>
|
||||
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={onTest} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("编辑")} onClick={onEdit} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("复制")} onClick={onDuplicate} />
|
||||
<TruncatedButton size="small" className={styles.deleteButton} disabled={disabled} label={t("删除")} onClick={onDelete} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PluginModelRow({ model, disabled, testing, result, onTest, onSettings }: {
|
||||
model: PluginModelDescriptor;
|
||||
disabled: boolean;
|
||||
testing: boolean;
|
||||
result: CursorModelTestState | undefined;
|
||||
onTest: () => void;
|
||||
onSettings: () => void;
|
||||
}) {
|
||||
return <div className={styles.modelRow}>
|
||||
<div className={styles.modelRowName}>
|
||||
<span className={styles.modelRowNameText}>{model.displayName}</span>
|
||||
<span className={styles.modelRowModelId}>{model.modelId}</span>
|
||||
</div>
|
||||
<CursorModelTestResult compact state={result} testing={testing} />
|
||||
<div className={styles.modelCardActions}>
|
||||
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={onTest} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("设置")} onClick={onSettings} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -150,10 +275,10 @@ function ModelGrid({
|
||||
<CursorModelTestResult state={result} testing={testing} />
|
||||
</div>
|
||||
<div className={styles.modelCardActions}>
|
||||
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
|
||||
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
|
||||
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={() => onTest(model)} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("编辑")} onClick={() => onEdit(model)} />
|
||||
<TruncatedButton size="small" disabled={disabled} label={t("复制")} onClick={() => onDuplicate(model)} />
|
||||
<TruncatedButton size="small" className={styles.deleteButton} disabled={disabled} label={t("删除")} onClick={() => onDelete(model)} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>;
|
||||
@@ -162,8 +287,9 @@ function ModelGrid({
|
||||
}
|
||||
|
||||
function providerGroup(model: Model) {
|
||||
const label = providerDomain(model.base_url);
|
||||
return { key: label, label, icon: flatColorOrganizationIcon };
|
||||
const key = providerDomain(model.base_url);
|
||||
const label = model.group_name?.trim() || key;
|
||||
return { key, label, icon: flatColorOrganizationIcon };
|
||||
}
|
||||
|
||||
function providerDomain(baseUrl: string) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CursorPresetChips } from "./CursorPresetChips";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
providerId: string;
|
||||
model: ModelInput;
|
||||
openAIExtraParamsText: string;
|
||||
customHeadersText: string;
|
||||
@@ -19,9 +20,11 @@ export type CursorModelDraft = {
|
||||
};
|
||||
|
||||
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
providerId: "builtin/openai",
|
||||
model: {
|
||||
sort_order: 0,
|
||||
display_name: "",
|
||||
group_name: null,
|
||||
type: "openai",
|
||||
base_url: "",
|
||||
use_full_url: false,
|
||||
@@ -63,6 +66,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
const endpoint = preset ? presetEndpoint(preset, type) : null;
|
||||
onChange({
|
||||
...draft,
|
||||
providerId: `builtin/${type}`,
|
||||
model: {
|
||||
...draft.model,
|
||||
type,
|
||||
@@ -123,13 +127,19 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
? "https://api.anthropic.com"
|
||||
: "https://api.openai.com";
|
||||
|
||||
const providerOptions = [
|
||||
{ value: "builtin/openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "builtin/anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
];
|
||||
const setProvider = (providerId: string) => {
|
||||
if (providerId === "builtin/openai") setType("openai");
|
||||
if (providerId === "builtin/anthropic") setType("anthropic");
|
||||
};
|
||||
|
||||
return <div className={styles.editor}>
|
||||
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} />
|
||||
<div className={styles.grid}>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
|
||||
{ value: "openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(value) => setType(value as ModelType)} /></FormField>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.providerId} options={providerOptions} onChange={setProvider} /></FormField>
|
||||
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
|
||||
{ value: "/v1/responses", label: "Responses API" },
|
||||
{ value: "/v1/chat/completions", label: "Chat Completions API" },
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
|
||||
}
|
||||
|
||||
.compact {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -9,10 +9,25 @@ export type CursorModelTestState =
|
||||
| { status: "error"; error: string }
|
||||
| { status: "cancelled" };
|
||||
|
||||
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
|
||||
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
export function CursorModelTestResult({ state, testing = false, compact = false }: {
|
||||
state?: CursorModelTestState;
|
||||
testing?: boolean;
|
||||
/** 列表行内的紧凑徽标形态:未测试时不渲染,详情放入悬浮提示。 */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (testing) {
|
||||
return compact
|
||||
? <span className={`${styles.compact} ${styles.testing}`}>{t("测试中…")}</span>
|
||||
: <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
}
|
||||
if (!state) {
|
||||
return compact ? null : <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
}
|
||||
if (state.status === "cancelled") {
|
||||
return compact
|
||||
? <span className={`${styles.compact} ${styles.idle}`}>{t("测试已取消")}</span>
|
||||
: <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
}
|
||||
|
||||
const success = state.status === "success";
|
||||
const summary = success
|
||||
@@ -29,6 +44,15 @@ export function CursorModelTestResult({ state, testing = false }: { state?: Curs
|
||||
})
|
||||
: t("测试失败:{error}", { error: state.error });
|
||||
|
||||
if (compact) {
|
||||
return <TooltipTrigger label={detail}>
|
||||
<span className={`${styles.compact} ${success ? styles.success : styles.error}`}>
|
||||
{success ? `${formatSpeed(state.result.tokens_per_second)} tokens/s` : t("测试失败")}
|
||||
<Icon icon={informationOutlineIcon} size="1em" />
|
||||
</span>
|
||||
</TooltipTrigger>;
|
||||
}
|
||||
|
||||
return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
|
||||
<span className={styles.summary}>{summary}</span>
|
||||
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
|
||||
@@ -70,19 +70,87 @@
|
||||
gap: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.modelGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
.groupCard {
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
.modelGroupHeader {
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
.groupSettings {
|
||||
padding: 4px 2px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { color: var(--vscode-foreground); }
|
||||
}
|
||||
.groupChevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
color: var(--vscode-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.groupToggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 2px;
|
||||
color: var(--vscode-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: type.$font-size-base;
|
||||
opacity: 0.6;
|
||||
padding-left: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.groupLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
padding: 10px 2px;
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
.modelRowName {
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.modelRowNameText,
|
||||
.modelRowModelId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelRowNameText {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
.modelRowModelId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
}
|
||||
.modelCard {
|
||||
position: relative;
|
||||
@@ -141,9 +209,15 @@
|
||||
}
|
||||
.modelCardActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
// 空间不足时按钮收缩显示省略号,而不是换行。
|
||||
> button {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
.deleteButton:hover {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, type Model, type ModelInput } from "../../shared/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, configuredPluginModels, type Model, type ModelInput } from "../../shared/api";
|
||||
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "./CursorGates";
|
||||
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "./CursorModelCards";
|
||||
import { CursorModelCards, cursorModelGroups, type CursorModelGroup, type CursorModelGrouping } from "./CursorModelCards";
|
||||
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "./CursorModelEditor";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { LegacyModelImport } from "./LegacyModelImport";
|
||||
import { ConfirmDialog } from "../../shared/ui/ConfirmDialog";
|
||||
import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls";
|
||||
import controls from "../../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
@@ -18,7 +20,8 @@ import { PageActions } from "../../shell/PageActions";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
|
||||
export function CursorSettingsPage() {
|
||||
const { models, cursorHarness, cursorBusy } = useAppStore();
|
||||
const { models, cursorHarness, cursorBusy, plugins } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
const message = useMessage();
|
||||
const [draft, setDraft] = useState<CursorModelDraft | null>(null);
|
||||
const [editing, setEditing] = useState<Model | null>(null);
|
||||
@@ -32,8 +35,18 @@ export function CursorSettingsPage() {
|
||||
const [savingAndTesting, setSavingAndTesting] = useState(false);
|
||||
const [batchTesting, setBatchTesting] = useState(false);
|
||||
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
|
||||
const [settingsGroup, setSettingsGroup] = useState<CursorModelGroup | null>(null);
|
||||
const [groupNameDraft, setGroupNameDraft] = useState("");
|
||||
const [groupBaseUrlDraft, setGroupBaseUrlDraft] = useState("");
|
||||
const [groupApiKeyDraft, setGroupApiKeyDraft] = useState("");
|
||||
const [groupSettingsBusy, setGroupSettingsBusy] = useState(false);
|
||||
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
|
||||
const caReady = cursorHarness?.ca === "ready";
|
||||
const pluginModels = configuredPluginModels(plugins);
|
||||
const testTargets = [
|
||||
...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })),
|
||||
...pluginModels.map((model) => ({ model_hash: model.id, display_name: model.displayName })),
|
||||
];
|
||||
const providerGroups = cursorModelGroups(models, "provider");
|
||||
const typeGroups = cursorModelGroups(models, "type");
|
||||
const canGroupByProvider = providerGroups.length > 1;
|
||||
@@ -64,6 +77,7 @@ export function CursorSettingsPage() {
|
||||
setEditing(model);
|
||||
setModelOptions([model.model_id]);
|
||||
setDraft({
|
||||
providerId: `builtin/${model.type}`,
|
||||
model: modelInput(model),
|
||||
openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2),
|
||||
customHeadersText: JSON.stringify(model.custom_headers, null, 2),
|
||||
@@ -120,7 +134,7 @@ export function CursorSettingsPage() {
|
||||
const cancelAllModelTests = async () => {
|
||||
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
|
||||
};
|
||||
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
|
||||
const testModel = async (model: { model_hash: string; display_name: string }, notify = true): Promise<"success" | "failure" | "cancelled"> => {
|
||||
if (activeModelTests.current.has(model.model_hash)) {
|
||||
await cancelModelTest(model.model_hash);
|
||||
return "cancelled";
|
||||
@@ -167,17 +181,17 @@ export function CursorSettingsPage() {
|
||||
await appStore.refresh();
|
||||
};
|
||||
const testAllModels = async () => {
|
||||
if (!models.length || batchTesting) return;
|
||||
if (!testTargets.length || batchTesting) return;
|
||||
setBatchTesting(true);
|
||||
try {
|
||||
const results = await Promise.all(models.map((model) => testModel(model, false)));
|
||||
const results = await Promise.all(testTargets.map((model) => testModel(model, false)));
|
||||
const successful = results.filter((result) => result === "success").length;
|
||||
const failed = results.filter((result) => result === "failure").length;
|
||||
const cancelled = results.filter((result) => result === "cancelled").length;
|
||||
message(cancelled > 0
|
||||
? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed })
|
||||
: failed === 0
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: testTargets.length })
|
||||
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
|
||||
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
|
||||
} finally {
|
||||
@@ -200,6 +214,39 @@ export function CursorSettingsPage() {
|
||||
}]);
|
||||
if (created) message(t("模型已复制"));
|
||||
};
|
||||
const openGroupSettings = (group: CursorModelGroup) => {
|
||||
setGroupNameDraft(group.models.find((model) => model.group_name?.trim())?.group_name?.trim() ?? "");
|
||||
setGroupBaseUrlDraft(sharedValue(group.models.map((model) => model.base_url)) ?? "");
|
||||
setGroupApiKeyDraft(sharedValue(group.models.map((model) => model.api_key)) ?? "");
|
||||
setSettingsGroup(group);
|
||||
};
|
||||
const saveGroupSettings = async () => {
|
||||
if (!settingsGroup) return;
|
||||
const group_name = groupNameDraft.trim() || null;
|
||||
const base_url = groupBaseUrlDraft.trim();
|
||||
const api_key = groupApiKeyDraft.trim();
|
||||
setGroupSettingsBusy(true);
|
||||
try {
|
||||
for (const model of settingsGroup.models) {
|
||||
const input: ModelInput = {
|
||||
...modelInput(model),
|
||||
group_name,
|
||||
...(base_url ? { base_url } : {}),
|
||||
...(api_key ? { api_key } : {}),
|
||||
};
|
||||
if (input.group_name === (model.group_name ?? null)
|
||||
&& input.base_url === model.base_url
|
||||
&& input.api_key === model.api_key) continue;
|
||||
await api.updateModel(model.model_hash, input);
|
||||
}
|
||||
await appStore.refresh();
|
||||
setSettingsGroup(null);
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setGroupSettingsBusy(false);
|
||||
}
|
||||
};
|
||||
const reorderModels = useCallback(async (modelHashes: string[]) => {
|
||||
if (!await appStore.reorderCursorModels(modelHashes)) {
|
||||
message(appStore.getSnapshot().error || t("排序失败"));
|
||||
@@ -208,6 +255,7 @@ export function CursorSettingsPage() {
|
||||
|
||||
const list = <CursorModelCards
|
||||
models={models}
|
||||
pluginModels={pluginModels}
|
||||
grouping={grouping}
|
||||
disabled={cursorBusy}
|
||||
testingModelHashes={testingModelHashes}
|
||||
@@ -216,7 +264,10 @@ export function CursorSettingsPage() {
|
||||
onEdit={openEdit}
|
||||
onDuplicate={(model) => void duplicateModel(model)}
|
||||
onDelete={setDeleting}
|
||||
onTestPluginModel={(model) => void testModel({ model_hash: model.id, display_name: model.displayName })}
|
||||
onPluginSettings={() => navigate("/plugins")}
|
||||
onReorder={reorderModels}
|
||||
onGroupSettings={openGroupSettings}
|
||||
/>;
|
||||
|
||||
const refreshCa = async () => {
|
||||
@@ -239,12 +290,13 @@ export function CursorSettingsPage() {
|
||||
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
|
||||
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
|
||||
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
|
||||
const pluginSectionHeight = pluginModels.length > 0 ? 60 + pluginModels.length * 56 : 0;
|
||||
const estimatedModelHeight = grouping === "flat"
|
||||
? Math.max(380, Math.ceil(models.length / 3) * 196)
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + Math.ceil(group.models.length / 3) * 196 + 34, 0) + Math.max(0, activeGroups.length - 1) * 20);
|
||||
? Math.max(380, Math.ceil(models.length / 3) * 196 + pluginSectionHeight)
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
|
||||
|
||||
return <>
|
||||
{models.length > 0 && <PageActions position="left">
|
||||
{testTargets.length > 0 && <PageActions position="left">
|
||||
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
@@ -253,8 +305,8 @@ export function CursorSettingsPage() {
|
||||
</div>
|
||||
</PageActions>}
|
||||
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
|
||||
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
|
||||
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
|
||||
<PageContent title="Cursor" sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
|
||||
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
|
||||
{draft && <>
|
||||
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
</>}
|
||||
@@ -262,6 +314,19 @@ export function CursorSettingsPage() {
|
||||
<ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
|
||||
<div className={styles.editor}><strong>{t("需要授权安装证书")}</strong><span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span><pre className={styles.command}>{caCommand}</pre></div>
|
||||
</ConfirmDialog>
|
||||
<Modal open={settingsGroup !== null} title={t("分组设置")} busy={groupSettingsBusy || cursorBusy} onClose={() => setSettingsGroup(null)} onSubmit={() => void saveGroupSettings()} submitLabel={t("保存")}>
|
||||
{settingsGroup && <div className={styles.editor}>
|
||||
<FormField label={t("分组名称")} hint={t("应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。")}>
|
||||
<TextInput placeholder={settingsGroup.key} value={groupNameDraft} onChange={(event) => setGroupNameDraft(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label={t("服务器地址")} hint={t("修改后应用于该分组下的全部模型;留空保持各模型现有配置不变。")}>
|
||||
<TextInput placeholder={t("留空保持不变")} value={groupBaseUrlDraft} onChange={(event) => setGroupBaseUrlDraft(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="API Key" hint={t("修改后应用于该分组下的全部模型;留空保持各模型现有配置不变。")}>
|
||||
<SecretTextInput placeholder={t("留空保持不变")} autoComplete="off" value={groupApiKeyDraft} onChange={(event) => setGroupApiKeyDraft(event.target.value)} />
|
||||
</FormField>
|
||||
</div>}
|
||||
</Modal>
|
||||
<ConfirmDialog open={deleting !== null} title={t("删除模型")} cancelLabel={t("取消")} confirmLabel={t("删除")} onCancel={() => setDeleting(null)} onConfirm={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}><p>{t("确定删除这个模型吗?")}</p></ConfirmDialog>
|
||||
</>;
|
||||
}
|
||||
@@ -271,6 +336,13 @@ function modelInput(model: Model): ModelInput {
|
||||
return input;
|
||||
}
|
||||
|
||||
/** 组内所有模型取值一致时返回该值,否则返回 null(表单留空表示保持不变)。 */
|
||||
function sharedValue(values: string[]): string | null {
|
||||
const [first, ...rest] = values;
|
||||
if (first === undefined) return null;
|
||||
return rest.every((value) => value === first) ? first : null;
|
||||
}
|
||||
|
||||
function draftInput(draft: CursorModelDraft): ModelInput {
|
||||
const model = {
|
||||
...draft.model,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.pluginGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.pluginCard {
|
||||
min-width: 0;
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.pluginCardTop {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pluginIcon {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
}
|
||||
|
||||
.pluginIdentity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pluginName,
|
||||
.pluginId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pluginName {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pluginId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.stateBadge {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
padding: 3px 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stateReady {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
|
||||
}
|
||||
|
||||
.pluginMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.pluginAuthor {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 7px;
|
||||
|
||||
> button {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 主操作靠左,"更多"推到行尾,两端对齐。
|
||||
.moreAction {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gate,
|
||||
.empty {
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
|
||||
strong {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
|
||||
span {
|
||||
max-width: 500px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.gate button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
// 反向斜纹(-45°)+ 无限反向滚动;未知总量时以 100% 宽度作不确定态。
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background-color: var(--vscode-progressBar-background, #0e70c0);
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
rgb(255 255 255 / 24%) 25%,
|
||||
transparent 25% 50%,
|
||||
rgb(255 255 255 / 24%) 50% 75%,
|
||||
transparent 75%
|
||||
);
|
||||
background-size: 24px 24px;
|
||||
border-radius: 999px;
|
||||
transition: width 160ms ease;
|
||||
animation: progress-stripes 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-stripes {
|
||||
to {
|
||||
background-position: -24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.progressContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
strong {
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--vscode-errorForeground);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { ActionMenu } from "../../shared/ui/ActionMenu";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
|
||||
import { PluginAddPanel, PluginSettingsPanel } from "./PluginResourcePanels";
|
||||
import styles from "./PluginManagementPage.module.scss";
|
||||
|
||||
export function PluginManagementPage() {
|
||||
const { pluginRuntime, plugins } = useAppStore();
|
||||
const [progressOpen, setProgressOpen] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [selected, setSelected] = useState<{ pluginId: string; mode: "add" | "settings" } | null>(null);
|
||||
const cancelRequested = useRef(false);
|
||||
const selectedPlugin = selected ? plugins.find((plugin) => plugin.id === selected.pluginId) ?? null : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginRuntime) void appStore.refreshPluginRuntime();
|
||||
}, [pluginRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pluginRuntime?.state !== "initializing") return;
|
||||
if (!cancelRequested.current) setProgressOpen(true);
|
||||
const timer = window.setInterval(() => void appStore.refreshPluginRuntime(), 300);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [pluginRuntime?.state]);
|
||||
|
||||
const initialize = async () => {
|
||||
if (starting) return;
|
||||
cancelRequested.current = false;
|
||||
setStarting(true);
|
||||
setProgressOpen(true);
|
||||
const status = await appStore.initializePluginRuntime();
|
||||
setStarting(false);
|
||||
if (!status) {
|
||||
setProgressOpen(false);
|
||||
} else if (cancelRequested.current && status.state === "initializing") {
|
||||
void appStore.cancelPluginRuntimeInitialization();
|
||||
}
|
||||
};
|
||||
|
||||
const closeProgress = () => {
|
||||
setProgressOpen(false);
|
||||
cancelRequested.current = true;
|
||||
if (pluginRuntime?.state === "initializing") {
|
||||
void appStore.cancelPluginRuntimeInitialization();
|
||||
}
|
||||
};
|
||||
|
||||
const content = pluginRuntime?.state === "ready"
|
||||
? <PluginCards plugins={plugins} onOpen={(pluginId, mode) => setSelected({ pluginId, mode })} />
|
||||
: <RuntimeGate status={pluginRuntime} starting={starting} onInitialize={() => void initialize()} />;
|
||||
const estimatedHeight = plugins.length > 0
|
||||
? Math.max(320, Math.ceil(plugins.length / 3) * 180)
|
||||
: 320;
|
||||
|
||||
return <>
|
||||
<PageContent
|
||||
title={t("插件配置")}
|
||||
sections={[{ key: "installed-plugins", estimatedHeight, content }]}
|
||||
/>
|
||||
<RuntimeProgressModal
|
||||
open={progressOpen}
|
||||
status={pluginRuntime}
|
||||
starting={starting}
|
||||
onClose={closeProgress}
|
||||
/>
|
||||
<Modal
|
||||
fullHeight
|
||||
open={selectedPlugin !== null}
|
||||
title={selected?.mode === "settings"
|
||||
? t("{name} 账号管理", { name: selectedPlugin?.name ?? "" })
|
||||
: t("添加 {name} 账号", { name: selectedPlugin?.name ?? "" })}
|
||||
onClose={() => setSelected(null)}
|
||||
>
|
||||
{selected?.mode === "add" && selectedPlugin && <PluginAddPanel plugin={selectedPlugin} onConfigured={() => setSelected(null)} />}
|
||||
{selected?.mode === "settings" && selectedPlugin && <PluginSettingsPanel plugin={selectedPlugin} />}
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
function RuntimeGate({ status, starting, onInitialize }: { status: PluginRuntimeStatus | null; starting: boolean; onInitialize: () => void }) {
|
||||
const checking = status === null;
|
||||
const initializing = starting || status?.state === "initializing";
|
||||
const failed = status?.state === "failed";
|
||||
const unsupported = status?.state === "unsupported";
|
||||
const title = checking
|
||||
? t("正在检查插件运行时")
|
||||
: failed
|
||||
? t("插件运行时初始化失败")
|
||||
: unsupported
|
||||
? t("当前系统不支持插件运行时")
|
||||
: t("需要先初始化插件运行时");
|
||||
const description = failed
|
||||
? t("请重试初始化")
|
||||
: unsupported
|
||||
? status.error ?? t("当前操作系统或 CPU 架构暂不受支持")
|
||||
: t("初始化将下载并安装插件运行时。");
|
||||
|
||||
return <div className={styles.gate}>
|
||||
<strong>{title}</strong>
|
||||
<span>{description}</span>
|
||||
{!unsupported && <Button variant="primary" disabled={checking || initializing} onClick={onInitialize}>
|
||||
{checking ? t("检查中…") : initializing ? t("初始化中…") : failed ? t("重新初始化插件") : t("初始化插件")}
|
||||
</Button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PluginCards({ plugins, onOpen }: {
|
||||
plugins: PluginDescriptor[];
|
||||
onOpen: (pluginId: string, mode: "add" | "settings") => void;
|
||||
}) {
|
||||
if (plugins.length === 0) {
|
||||
return <div className={styles.empty}>
|
||||
<strong>{t("还没有安装插件")}</strong>
|
||||
<span>{t("安装插件后会显示在这里。")}</span>
|
||||
</div>;
|
||||
}
|
||||
return <div className={styles.pluginGrid}>
|
||||
{plugins.map((plugin) => <PluginCard key={plugin.id} plugin={plugin} onOpen={onOpen} />)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PluginCard({ plugin, onOpen }: {
|
||||
plugin: PluginDescriptor;
|
||||
onOpen: (pluginId: string, mode: "add" | "settings") => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const { ports } = useAppStore();
|
||||
const message = useMessage();
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const configured = plugin.providers.some((provider) => provider.configured);
|
||||
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
|
||||
const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0);
|
||||
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
|
||||
const importResource = plugin.resources.find((resource) => resource.import);
|
||||
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
|
||||
|
||||
const importFiles = async (files: FileList | null) => {
|
||||
if (!files?.length || !importResource) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const entries: PluginImportFile[] = await Promise.all(
|
||||
[...files].map(async (file) => ({ name: file.name, content: await file.text() })),
|
||||
);
|
||||
const result = await api.importPluginResources(plugin.id, importResource.type, entries);
|
||||
await appStore.refreshPlugins();
|
||||
const summary = t("导入完成:新增 {added},更新 {updated}", { added: result.added, updated: result.updated });
|
||||
if (result.modelSyncError) {
|
||||
message(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }), { duration: 5000 });
|
||||
} else if (result.warnings.length > 0) {
|
||||
message(`${summary} · ${result.warnings.join("; ")}`, { duration: 5000 });
|
||||
} else {
|
||||
message(summary);
|
||||
}
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause), { duration: 5000 });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (importInput.current) importInput.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={styles.pluginCard}>
|
||||
<div className={styles.pluginCardTop}>
|
||||
<img className={styles.pluginIcon} src={plugin.icon} />
|
||||
<div className={styles.pluginIdentity}>
|
||||
<span className={styles.pluginName}>{plugin.name}</span>
|
||||
<span className={styles.pluginId}>{subtitle}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`${styles.stateBadge} ${configured ? styles.stateReady : ""}`}
|
||||
>
|
||||
{configured ? t("已配置") : t("未配置")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.pluginMeta}>
|
||||
<span>
|
||||
{t("{accounts} 个账号 · {models} 个模型", {
|
||||
accounts: accountCount,
|
||||
models: modelCount,
|
||||
})}
|
||||
</span>
|
||||
<span className={styles.pluginAuthor}>
|
||||
{[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.cardActions}>
|
||||
<TruncatedButton
|
||||
size="small"
|
||||
variant="primary"
|
||||
label={t("添加账号")}
|
||||
onClick={() => onOpen(plugin.id, "add")}
|
||||
/>
|
||||
{configured && (
|
||||
<TruncatedButton
|
||||
size="small"
|
||||
label={t("账号管理")}
|
||||
onClick={() => onOpen(plugin.id, "settings")}
|
||||
/>
|
||||
)}
|
||||
{(importResource || exportResource) && (
|
||||
<span className={styles.moreAction}>
|
||||
<ActionMenu
|
||||
label={t("更多")}
|
||||
items={[
|
||||
...(importResource
|
||||
? [
|
||||
{
|
||||
id: "import",
|
||||
label: importing ? t("正在导入…") : t("批量导入"),
|
||||
disabled: importing,
|
||||
onSelect: () => importInput.current?.click(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(exportResource
|
||||
? [
|
||||
{
|
||||
id: "export",
|
||||
label: t("批量导出"),
|
||||
onSelect: () =>
|
||||
void api.openExternalUrl(
|
||||
api.pluginResourceExportUrl(
|
||||
ports.service_port,
|
||||
plugin.id,
|
||||
exportResource.type,
|
||||
),
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{importResource && (
|
||||
<input
|
||||
ref={importInput}
|
||||
type="file"
|
||||
hidden
|
||||
accept={importResource.import?.accept.join(",")}
|
||||
multiple={importResource.import?.multiple ?? false}
|
||||
onChange={(event) => void importFiles(event.target.files)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeProgressModal({ open, status, starting, onClose }: { open: boolean; status: PluginRuntimeStatus | null; starting: boolean; onClose: () => void }) {
|
||||
const initializing = starting || status?.state === "initializing";
|
||||
const downloaded = status?.downloaded_bytes ?? 0;
|
||||
const total = status?.total_bytes ?? null;
|
||||
const percent = total && total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null;
|
||||
const stage = status?.state === "ready"
|
||||
? t("插件运行时初始化完成")
|
||||
: status?.state === "failed"
|
||||
? t("插件运行时初始化失败")
|
||||
: phaseText(status?.phase ?? null);
|
||||
|
||||
return <Modal
|
||||
open={open}
|
||||
title={t("初始化插件运行时")}
|
||||
closeLabel={status?.state === "ready" ? t("完成") : initializing ? t("取消") : t("关闭")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.progressContent} aria-live="polite">
|
||||
<strong>{stage}</strong>
|
||||
{status?.phase === "downloading" && <>
|
||||
<div
|
||||
className={styles.progressBar}
|
||||
role="progressbar"
|
||||
aria-label={t("下载进度")}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent ?? undefined}
|
||||
>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${percent ?? 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{total ? t("已下载 {downloaded} / {total}", { downloaded: formatBytes(downloaded), total: formatBytes(total) }) : t("已下载 {downloaded}", { downloaded: formatBytes(downloaded) })}
|
||||
</span>
|
||||
</>}
|
||||
{status?.state === "failed" && <span className={styles.error}>{t("请重试初始化")}</span>}
|
||||
{status?.state === "ready" && <span>{t("插件运行时 {version} 已安装,可以开始使用插件。", { version: status.version })}</span>}
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function phaseText(phase: PluginRuntimePhase | null) {
|
||||
switch (phase) {
|
||||
case "checking": return t("正在检查插件运行时");
|
||||
case "downloading": return t("正在下载插件运行时");
|
||||
case "verifying": return t("正在验证插件运行时下载文件");
|
||||
case "installing": return t("正在安装插件运行时");
|
||||
case "validating": return t("正在验证插件运行时");
|
||||
default: return t("正在准备插件运行时");
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value < 10 ? value.toFixed(1) : value.toFixed(0)} ${units[unit]}`;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.methodCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
|
||||
> span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.actions,
|
||||
.toolbar,
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deviceCode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 10px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 4px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.copy {
|
||||
padding: 6px 2px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
letter-spacing: normal;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fileButton {
|
||||
align-self: flex-start;
|
||||
padding: 5px 10px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
|
||||
input {
|
||||
min-width: 180px;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.resourceSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resourceList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.providerRow,
|
||||
.resourceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
> div:first-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.ready {
|
||||
color: var(--vscode-testing-iconPassed, #73c991) !important;
|
||||
}
|
||||
|
||||
.cooling {
|
||||
color: var(--vscode-editorWarning-foreground, #cca700) !important;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
color: var(--vscode-errorForeground, #f48771) !important;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
pluginText,
|
||||
type PluginAddMethod,
|
||||
type PluginDescriptor,
|
||||
type PluginOAuthBegin,
|
||||
type PluginProviderDescriptor,
|
||||
type PluginResourceDescriptor,
|
||||
type PluginResourceView,
|
||||
} from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { appStore } from "../../shared/store/appStore";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { FormField, TextInput } from "../../shared/ui/FormControls";
|
||||
import styles from "./PluginResourcePanels.module.scss";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function PluginAddPanel({ plugin, onConfigured }: { plugin: PluginDescriptor; onConfigured: () => void }) {
|
||||
return <div className={styles.panel}>
|
||||
{plugin.resources.map((resource) => <ResourceAddSection
|
||||
key={resource.type}
|
||||
plugin={plugin}
|
||||
resource={resource}
|
||||
onConfigured={onConfigured}
|
||||
/>)}
|
||||
{plugin.resources.length === 0 && <span className={styles.empty}>{t("该插件不需要添加资源")}</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ResourceAddSection({ plugin, resource, onConfigured }: {
|
||||
plugin: PluginDescriptor;
|
||||
resource: PluginResourceDescriptor;
|
||||
onConfigured: () => void;
|
||||
}) {
|
||||
return <>
|
||||
{resource.add.map((method) => <OAuthMethodCard
|
||||
key={method.id}
|
||||
pluginId={plugin.id}
|
||||
resourceType={resource.type}
|
||||
method={method}
|
||||
onConfigured={onConfigured}
|
||||
/>)}
|
||||
</>;
|
||||
}
|
||||
|
||||
function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
method: PluginAddMethod;
|
||||
onConfigured: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const [status, setStatus] = useState<"idle" | "starting" | "polling" | "success" | "error">("idle");
|
||||
const [begun, setBegun] = useState<PluginOAuthBegin | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const stopped = useRef(false);
|
||||
|
||||
const copyCode = async (code: string) => {
|
||||
await api.copyCursorText(code).catch(() => undefined);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
useEffect(() => () => { stopped.current = true; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!begun || status !== "polling") return;
|
||||
let timer = 0;
|
||||
const poll = async (intervalMs: number) => {
|
||||
if (stopped.current) return;
|
||||
try {
|
||||
const result = await api.pluginOAuthPoll(begun.sessionId);
|
||||
if (stopped.current) return;
|
||||
if (result.status === "pending") {
|
||||
timer = window.setTimeout(() => void poll(result.pollIntervalMs), Math.max(1000, result.pollIntervalMs));
|
||||
return;
|
||||
}
|
||||
if (result.status === "completed") {
|
||||
await appStore.refreshPlugins();
|
||||
if (result.modelSyncError) {
|
||||
setStatus("error");
|
||||
setError(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }));
|
||||
return;
|
||||
}
|
||||
setStatus("success");
|
||||
onConfigured();
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setError(result.message || t("授权被拒绝或已失败。"));
|
||||
} catch (cause) {
|
||||
if (stopped.current) return;
|
||||
setError(errorText(cause));
|
||||
timer = window.setTimeout(() => void poll(intervalMs), Math.max(1000, intervalMs));
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(begun.pollIntervalMs), Math.max(1000, begun.pollIntervalMs));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [begun, onConfigured, status]);
|
||||
|
||||
const start = async () => {
|
||||
setStatus("starting");
|
||||
setError(null);
|
||||
try {
|
||||
const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id);
|
||||
setBegun(next);
|
||||
setStatus("polling");
|
||||
await api.copyCursorText(next.userCode).catch(() => undefined);
|
||||
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
|
||||
} catch (cause) {
|
||||
setStatus("error");
|
||||
setError(errorText(cause));
|
||||
}
|
||||
};
|
||||
|
||||
return <Card className={styles.methodCard}>
|
||||
<strong>{pluginText(method.displayName, locale)}</strong>
|
||||
{method.description && <span>{pluginText(method.description, locale)}</span>}
|
||||
{begun && status === "polling" && <div className={styles.deviceCode}>
|
||||
<small>{t("设备验证码")}</small>
|
||||
<button type="button" onClick={() => void copyCode(begun.userCode)}>{begun.userCode}</button>
|
||||
<button type="button" className={styles.copy} onClick={() => void copyCode(begun.userCode)}>
|
||||
{copied ? t("已复制") : t("复制")}
|
||||
</button>
|
||||
</div>}
|
||||
<div className={styles.actions}>
|
||||
<Button variant="primary" disabled={status === "starting" || status === "polling"} onClick={() => void start()}>
|
||||
{status === "starting" ? t("正在申请授权码…") : status === "polling" ? t("等待网页端确认授权中…") : t("开始登录")}
|
||||
</Button>
|
||||
{begun && status === "polling" && <Button onClick={() => void api.openExternalUrl(begun.verificationUrlComplete || begun.verificationUrl)}>{t("打开授权网页")}</Button>}
|
||||
</div>
|
||||
{status === "success" && <span className={styles.success}>{t("账号已保存,模型目录已同步。")}</span>}
|
||||
{error && <span className={styles.error} role="alert">{error}</span>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async (key: string, task: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await task();
|
||||
await appStore.refreshPlugins();
|
||||
} catch (cause) {
|
||||
setError(errorText(cause));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={styles.panel}>
|
||||
{plugin.providers.map((provider) => <ProviderRow
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
busy={busy !== null}
|
||||
syncing={busy === `sync:${provider.id}`}
|
||||
onSync={() => void run(`sync:${provider.id}`, async () => {
|
||||
await api.syncPluginModels(plugin.id, provider.id);
|
||||
})}
|
||||
/>)}
|
||||
{plugin.resources.map((resource) => <ResourceList
|
||||
key={resource.type}
|
||||
resource={resource}
|
||||
busy={busy !== null}
|
||||
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
|
||||
await api.refreshPluginResource(plugin.id, resource.type, item.id);
|
||||
})}
|
||||
onDelete={(item) => void run(`delete:${item.id}`, async () => {
|
||||
await api.deletePluginResource(plugin.id, resource.type, item.id);
|
||||
})}
|
||||
/>)}
|
||||
{error && <span className={styles.error} role="alert">{error}</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ProviderRow({ provider, busy, syncing, onSync }: {
|
||||
provider: PluginProviderDescriptor;
|
||||
busy: boolean;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
return <Card className={styles.providerRow}>
|
||||
<div>
|
||||
<strong>{pluginText(provider.displayName, locale)}</strong>
|
||||
<span>
|
||||
{provider.providerType}
|
||||
{" · "}
|
||||
{provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")}
|
||||
{" · "}
|
||||
{provider.configured ? t("可调用") : t("未就绪")}
|
||||
</span>
|
||||
</div>
|
||||
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
|
||||
{syncing ? t("正在同步…") : t("同步模型")}
|
||||
</Button>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
||||
resource: PluginResourceDescriptor;
|
||||
busy: boolean;
|
||||
onRefresh: (item: PluginResourceView) => void;
|
||||
onDelete: (item: PluginResourceView) => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filtered = useMemo(
|
||||
() => resource.resources.filter((item) => item.displayName.toLowerCase().includes(query.trim().toLowerCase())),
|
||||
[resource.resources, query],
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const visible = filtered.slice((Math.min(page, pageCount) - 1) * PAGE_SIZE, Math.min(page, pageCount) * PAGE_SIZE);
|
||||
|
||||
useEffect(() => setPage(1), [query]);
|
||||
|
||||
return <FormField label={pluginText(resource.displayName, locale)}>
|
||||
<div className={styles.resourceSection}>
|
||||
{resource.resources.length > PAGE_SIZE && <div className={styles.toolbar}>
|
||||
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>}
|
||||
<div className={styles.resourceList}>
|
||||
{visible.map((item) => <ResourceRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
canRefresh={resource.canRefresh}
|
||||
disabled={busy}
|
||||
onRefresh={() => onRefresh(item)}
|
||||
onDelete={() => onDelete(item)}
|
||||
/>)}
|
||||
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
|
||||
</div>
|
||||
{pageCount > 1 && <div className={styles.pagination}>
|
||||
<Button size="small" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>{t("上一页")}</Button>
|
||||
<span>{t("第 {page} / {total} 页", { page: Math.min(page, pageCount), total: pageCount })}</span>
|
||||
<Button size="small" disabled={page >= pageCount} onClick={() => setPage((current) => current + 1)}>{t("下一页")}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</FormField>;
|
||||
}
|
||||
|
||||
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
|
||||
item: PluginResourceView;
|
||||
canRefresh: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
return <Card className={styles.resourceRow}>
|
||||
<div>
|
||||
<strong>{item.displayName}</strong>
|
||||
{item.description && <span>{pluginText(item.description, locale)}</span>}
|
||||
{item.metrics.map((metric) => <span key={metric.id}>
|
||||
{metric.unit === "percent"
|
||||
? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) })
|
||||
: `${pluginText(metric.label, locale)}: ${metric.value}`}
|
||||
</span>)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<StateBadge state={item.state} />
|
||||
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
|
||||
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
|
||||
if (state.status === "cooling") {
|
||||
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
|
||||
}
|
||||
if (state.status === "invalid") {
|
||||
return <span className={styles.invalid} title={state.message ?? undefined}>{t("已失效")}</span>;
|
||||
}
|
||||
return <span className={styles.ready}>{t("可用")}</span>;
|
||||
}
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
@@ -95,7 +95,8 @@ export function AppLifecycleSettingsCard() {
|
||||
const nextVersion = await updateStore.check();
|
||||
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
const error = cause instanceof Error ? cause.message : String(cause);
|
||||
message(t("检查更新失败:{error}", { error }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,7 +104,8 @@ export function AppLifecycleSettingsCard() {
|
||||
try {
|
||||
await updateStore.install();
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
const error = cause instanceof Error ? cause.message : String(cause);
|
||||
message(t("安装更新失败:{error}", { error }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,23 @@
|
||||
"0006d696d8e1ec28": "New",
|
||||
"00929f23850e4ff0": "Successful calls: {count}",
|
||||
"01f3e69a5a9b2c9b": "The key required to access the model service.",
|
||||
"023810003eb4563d": "{count} models",
|
||||
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
|
||||
"028c60a8a8e30a1b": "Page {page} / {total}",
|
||||
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "Edit",
|
||||
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
|
||||
"0580e0a99a6f1afc": "Artifacts",
|
||||
"06619f339fa0ab46": "Preparing the plugin runtime",
|
||||
"076832c1b2de22c3": "Cache write: {tokens}",
|
||||
"07879e064ae16542": "Estimated output: {tokens}",
|
||||
"07c657ed4747126e": "Anthropic extra parameters",
|
||||
"08791ba06e7441de": "{accounts} accounts · {models} models",
|
||||
"092b520558eff5f2": "Not tested",
|
||||
"099008ea7a42ebd1": "All custom Header values must be strings",
|
||||
"09ebc2643631ba25": "Estimated value",
|
||||
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
|
||||
"0bbb2c0ce279d6d5": "Models not synced yet",
|
||||
"0c70665b6eb65f1a": "No",
|
||||
"0c72229b7db0e1a9": "Model output",
|
||||
"0d2dab3d62eb73d6": "All statistics cleared",
|
||||
@@ -33,8 +38,11 @@
|
||||
"168e845a86bc3703": "Add model",
|
||||
"16d0d7e2b332af72": "Total calls: {count}",
|
||||
"1813d362a82fd437": "Maximize window",
|
||||
"18165f8865eacc91": "No plugins installed",
|
||||
"19658d9fa9aa8de4": "Installing…",
|
||||
"1a3f0617d6de8e52": "Username",
|
||||
"1a60c9eb3cf1dbb5": "Import finished: {added} added, {updated} updated",
|
||||
"1aa65c55c6cc6163": "Device code",
|
||||
"1ae6b0a0f8266382": "Close window",
|
||||
"1b5932b8946d2d68": "Delete model",
|
||||
"1b7d5b1a9315fc64": "Calculating…",
|
||||
@@ -50,12 +58,14 @@
|
||||
"22d7895ea5fca72e": "By provider",
|
||||
"23ae7a90b1b9816d": "Clear scope",
|
||||
"23e49479e15e6770": "Version {version} is available",
|
||||
"2400fbd0aeab9e13": "Downloaded {downloaded}",
|
||||
"24a0a24864454575": "Existing, skipped",
|
||||
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
|
||||
"29585d7193539200": "Current version {version}",
|
||||
"29fbbef32a6eb58b": "Do not show this ad again",
|
||||
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
|
||||
"2caeaec539e78898": "Thinking budget tokens",
|
||||
"2cbc58108d78b06c": "Waiting for browser authorization…",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2d30c2a98ebb5278": "Current: {rate}",
|
||||
"2eb2bf7c6597ab9a": "Detailed records",
|
||||
@@ -67,12 +77,17 @@
|
||||
"2f7ba5fd1d12f7f9": "Open the tutorial?",
|
||||
"2f7dec3be28d7597": "{count} selected",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
"2fe5a8d0eee9f14c": "Invalid",
|
||||
"303c30f301514250": "Search resources",
|
||||
"3260348163d03b8e": "Leave blank to keep unchanged",
|
||||
"32896fdaaaa4c106": "Account saved and the model catalog is synced.",
|
||||
"346ff60e6c7c5181": "Reading…",
|
||||
"36f33adaf0942634": "Confirm",
|
||||
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
|
||||
"378bb0eec39fa8a2": "Last page",
|
||||
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
|
||||
"382f2e3419a02fef": "Only clear detailed records",
|
||||
"38844b135cf70dfc": "More",
|
||||
"393e1241552b1870": "Request",
|
||||
"398f8e6c6f0a0b97": "Continue selecting or typing",
|
||||
"39f52eee100131d7": "Cached input",
|
||||
@@ -88,6 +103,7 @@
|
||||
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
|
||||
"3fd118e2ffe0b2b6": "Cancel all tests",
|
||||
"3fd47edce45b3603": "Close",
|
||||
"402495402ce333b1": "Reinitialize plugins",
|
||||
"40a08e7cf320ae07": "Clear detailed records?",
|
||||
"4125fc7ba333524c": "Default light",
|
||||
"42655ed8e4108ae2": "Input (non-cached)",
|
||||
@@ -97,6 +113,7 @@
|
||||
"461d6a57900c2ed7": "Connectivity test failed: {error}",
|
||||
"470049252e54de6a": "Success rate: {rate}",
|
||||
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
|
||||
"48a3bf87eb254591": "Start sign-in",
|
||||
"48b970b568a7f8f9": "Proxy settings",
|
||||
"48d8db17bae06246": "{count} total",
|
||||
"492042ed1fdc29ed": "Version {version} is ready to install",
|
||||
@@ -104,9 +121,12 @@
|
||||
"497c85690c4cc0fc": "No data",
|
||||
"499c729eb09aa2a6": "Context window tokens",
|
||||
"49be72e6045c007d": "Cancel test",
|
||||
"4a861200ad513a3c": "Initialize plugin runtime",
|
||||
"4a8d6841b4023edf": "Confirm import",
|
||||
"4aca6a31090fe2b8": "Initializing…",
|
||||
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
|
||||
"4d0680f9efaef147": "Unread",
|
||||
"4d99c976beb8827e": "Ready",
|
||||
"4e30d7c9ed2b0eee": "Not set",
|
||||
"4eafa9e925b30bcd": "Custom",
|
||||
"51d04bc3d286f018": "Last calendar day",
|
||||
@@ -127,6 +147,7 @@
|
||||
"5b17f59d33bde39e": "Error: {error}",
|
||||
"5ba65a74c4e792c5": "By type",
|
||||
"5c55a67935af8f45": "All",
|
||||
"5c62e36c152dfc7c": "Plugin runtime initialized",
|
||||
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
||||
"5f8d556a9c47da3c": "Launch at login disabled",
|
||||
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
|
||||
@@ -158,53 +179,72 @@
|
||||
"72644ec4389da2f7": "Default layout",
|
||||
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
|
||||
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
|
||||
"77c9e582e85583af": "Test failed",
|
||||
"788db1cfec2a3db5": "Theme",
|
||||
"7995087e5a3dfe66": "Restore window",
|
||||
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
|
||||
"7a3cec4ca715de80": "Call statistics",
|
||||
"7ba2d6728fe2531b": "Confirm clear",
|
||||
"7c10d97162c96dbd": "Validating the plugin runtime",
|
||||
"7cea2f3c46565d29": "OpenAI extra parameters",
|
||||
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
|
||||
"7e0891860c9e6374": "TAB service address is required",
|
||||
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
|
||||
"7f3c8312816fe26a": "Refreshing…",
|
||||
"7f68ebad19ba6bcd": "Check for updates",
|
||||
"802b0faf0ceb513e": "{label}: {percent}% left",
|
||||
"80a57e03f0717f91": "Not configured",
|
||||
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
|
||||
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
|
||||
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
|
||||
"83e8d0b7aff2b394": "Downloaded {downloaded} / {total}",
|
||||
"83fcfb4c1f2c1641": "Fetch models",
|
||||
"842b9f11cdd96bda": "Launch at login",
|
||||
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
||||
"864597982c308d72": "Silent start enabled",
|
||||
"86b7355ec3bd55ef": "Hide API Key",
|
||||
"86de7c4ee8fa7689": "Sync models",
|
||||
"8716e1344b0daddb": "Cursor official",
|
||||
"878a8ab176429a86": "View instructions",
|
||||
"8911e4f1407d58cb": "Downloading the plugin runtime",
|
||||
"89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.",
|
||||
"8a8542f6964852dc": "Next page",
|
||||
"8b6ff498515bcc2f": "Time",
|
||||
"8cbcf741e727dbf7": "Models",
|
||||
"8ccaf87ddb9ca3f4": "Legacy configuration",
|
||||
"8d0c47eb9eac2d34": "Call type",
|
||||
"8df48894086d6fbd": "Reason (optional)",
|
||||
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
|
||||
"8ea973394446abba": "Cursor Configuration",
|
||||
"8f6f8d979c981ced": "Copied",
|
||||
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
|
||||
"90800c48a1dd0655": "{label} must be a JSON object",
|
||||
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
|
||||
"91aaf184cfc17ffd": "Overview",
|
||||
"91af6e57e7453fbe": "Add account",
|
||||
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
|
||||
"92e26b27d5ea8f0e": "Failed to check for updates: {error}",
|
||||
"940a168911ade998": "Items per page",
|
||||
"945fb1c67eca8493": "Installing the plugin runtime",
|
||||
"946b3ffc02f026c0": "Delete this model?",
|
||||
"954ec984cd4f49d1": "Syncing…",
|
||||
"966498853d801a52": "TAB connection",
|
||||
"9850ed41a5bfbb0c": "{count} selected",
|
||||
"997ec8201c2adeda": "Open terminal to install CA",
|
||||
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
||||
"9b9bc9cd7c76406f": "Open authorization page",
|
||||
"9c41b3a9e12ac994": "Reasoning effort",
|
||||
"9db205c6055bacc4": "Plugin runtime initialization failed",
|
||||
"9e356080c56877f8": "Silent start disabled",
|
||||
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
|
||||
"9ebeab8c4532d671": "{name} accounts",
|
||||
"9ec4caa5fe43b8e3": "Installed plugins will appear here.",
|
||||
"9ed11266ead88f5b": "Verifying the plugin runtime download",
|
||||
"9ef7da883941091c": "The account was saved, but model sync failed: {error}",
|
||||
"9f6fee1aba17a565": "Language",
|
||||
"9fb48101d237ff96": "Last week",
|
||||
"a026f37e613cf48b": "Output Tokens",
|
||||
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
|
||||
"a0c42c24e74f8380": "{name} Copy",
|
||||
"a12ee6a3e98a29c2": "Hide sensitive content",
|
||||
"a1a42cd9b16e2162": "Application",
|
||||
"a1b8c98f29374a2f": "Silent start",
|
||||
"a3030bf8f16dc63c": "Save",
|
||||
@@ -215,10 +255,12 @@
|
||||
"a4d222236dc1003d": "Failed to cancel test: {error}",
|
||||
"a5fb6189a8ad011d": "Open tutorial",
|
||||
"a621ab606db2a11f": "Password",
|
||||
"a66e11477dcc97c1": "Add {name} account",
|
||||
"a693d69af48bfe48": "Save and test",
|
||||
"a748cc074f78de00": "View details",
|
||||
"a7617f42f898b2bf": "Use complete request URL",
|
||||
"a8036485f9227f2c": "Drag to reorder",
|
||||
"a80b53f8848e6d27": "Failed to install update: {error}",
|
||||
"a98585871c5313ff": "Display name",
|
||||
"ab9084a640fbb864": "Deselect all",
|
||||
"abecab6701177721": "Launch at login enabled",
|
||||
@@ -230,7 +272,10 @@
|
||||
"aee88743413144a2": "Refresh",
|
||||
"b06325c5660f0c29": "Direct",
|
||||
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
|
||||
"b254ff315d861346": "Try initializing again",
|
||||
"b2617bf9ae663752": "Group settings",
|
||||
"b4411558b932266f": "Provider type",
|
||||
"b4c9e08870d41aa2": "Initialize the plugin runtime first",
|
||||
"b502b1d414664337": "Prompt: {tokens}",
|
||||
"b5141d3d19e9a048": "Yes",
|
||||
"b6725f218ebaef26": "Dock icon shown",
|
||||
@@ -243,14 +288,20 @@
|
||||
"b9670c85a4ab939e": "Route",
|
||||
"b9af2de88d903be7": "Proxy address",
|
||||
"ba5865fbc734e672": "For example: Primary model",
|
||||
"ba6403d22876d626": "Cooling down",
|
||||
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
||||
"bb2b7736433ae867": "Cursor tracing",
|
||||
"bb7efdcb6af6e805": "Default dark",
|
||||
"bda62ce1d5e4ace9": "Tell us why",
|
||||
"bda74b5674b6a57d": "Initialize plugins",
|
||||
"be961dc60ab610da": "Applies to every model in this group when changed; leave blank to keep each model's current configuration.",
|
||||
"bf57afd709694b55": "Overview time range",
|
||||
"bfc01caf9fe0c841": "Cache hit rate {rate}",
|
||||
"c0b3fbff51ccc40b": "Done",
|
||||
"c1e98892a77f7a19": "{count} per page",
|
||||
"c3760858cdb6d9f4": "Request body",
|
||||
"c54863655e879b36": "Plugin runtime is not supported on this system",
|
||||
"c6e7e1a9da356efc": "No resources yet. Add one first.",
|
||||
"c7ea2c9bc43134bd": "Edit model",
|
||||
"c8c14507b2d37395": "Reasoning effort",
|
||||
"c8df3c14a003bfcd": "Unable to load call details",
|
||||
@@ -258,6 +309,7 @@
|
||||
"c9b9ae7a61444ab7": "Previous page",
|
||||
"c9d146d006993cc1": "Cache statistics policy: default ({rate})",
|
||||
"cb2f1709f983d2f4": "Model name",
|
||||
"cb99f0138b032687": "Initialization downloads and installs the plugin runtime.",
|
||||
"cea1aafe9416de7b": "Request headers",
|
||||
"cfae1a14d2120c57": "Detailed mode",
|
||||
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
|
||||
@@ -265,12 +317,17 @@
|
||||
"d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
|
||||
"d1a3d72618d1ed27": "All call summaries, detailed content, and trace records will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
|
||||
"d2d648bd1c94b7f9": "Authentication",
|
||||
"d2fcdde81f06645c": "Bulk export",
|
||||
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
|
||||
"d3716cc5a2f5a810": "Server address",
|
||||
"d3d21191f32e79a5": "Processing…",
|
||||
"d507652243a2151e": "Show sensitive content",
|
||||
"d58c88688e1a949d": "Presets",
|
||||
"d59e47070f7f358e": "Callable",
|
||||
"d60669bb26a22f5d": "Leave blank to use the default",
|
||||
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
|
||||
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
|
||||
"d7e266bdc8064193": "Group name",
|
||||
"d86fa42c3848c680": "Use system proxy",
|
||||
"d8c47e9776cf1082": "Main menu",
|
||||
"da521d1c1cbd36af": "Authorization is required to install the certificate",
|
||||
@@ -279,18 +336,22 @@
|
||||
"db340a9896306d08": "Test cancelled",
|
||||
"dbd3596e4a86f3c2": "Configured models",
|
||||
"ddde16f8839da3ce": "Total requests",
|
||||
"de8184da1ef88d03": "Configured",
|
||||
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
|
||||
"df1baa9f706d970b": "To add",
|
||||
"df3d58c7d84b85f2": "Settings",
|
||||
"df8b71c74d9b8478": "Response stream",
|
||||
"dfb802238b38fbd4": "Enabled",
|
||||
"e025f1ff71996425": "Set",
|
||||
"e049096ab5614581": "This plugin does not need any resources.",
|
||||
"e0fae77446a389a3": "Speed: {speed} tokens/s",
|
||||
"e1295adecbb77755": "Close ad",
|
||||
"e14115de7f7c5795": "Token usage over the past year",
|
||||
"e14f20d572c02611": "Provider call sequence",
|
||||
"e17a5b9c90cda6ab": "Model duplicated",
|
||||
"e18516550b9a5105": "No usage",
|
||||
"e231f1f3428d1c93": "Requesting an authorization code…",
|
||||
"e24096c81b1a8af4": "Authorization was denied or failed.",
|
||||
"e24ebe4a866d69bf": "Test failed: {error}",
|
||||
"e25bf3f419bb68f0": "Call history",
|
||||
"e3fee05f688708b4": "LLM calls",
|
||||
@@ -298,6 +359,7 @@
|
||||
"e5043c7a2b408271": "Last 10 minutes",
|
||||
"e59ae97924d62f01": "First page",
|
||||
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
|
||||
"e5c84c9aa7826566": "Not ready",
|
||||
"e77e3d58b0dcffaa": "Duration",
|
||||
"e825a2a42c22380e": "Model type",
|
||||
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
||||
@@ -305,17 +367,20 @@
|
||||
"ea26b760e930a7ca": "Call observability",
|
||||
"eb11e2df1d8ae387": "Provider URL",
|
||||
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
|
||||
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
|
||||
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
|
||||
"eba54690937bc532": "Manage accounts",
|
||||
"ed31fbb483ee1b0a": "Actions",
|
||||
"edc70de18c6da1a6": "Install local CA",
|
||||
"ee239f3943293f87": "Sunday",
|
||||
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
|
||||
"f2bdc88464c51c2e": "Show API Key",
|
||||
"f4694c46b1e19602": "Final request type",
|
||||
"f4dcb6a3ceb32247": "Page {page} of {count}",
|
||||
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
|
||||
"f50276449943286c": "End time",
|
||||
"f69273dbbebfb3a1": "Format",
|
||||
"f6dc1b1641600dd0": "Importing…",
|
||||
"f78265089144369a": "Plugins",
|
||||
"f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}",
|
||||
"f85537d1fd2ef6f6": "Custom overview filters",
|
||||
"f95ea7f4c063eea7": "Disabled",
|
||||
@@ -323,11 +388,14 @@
|
||||
"f9aa11dbb15ce647": "Saturday",
|
||||
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.",
|
||||
"fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
|
||||
"fad86bf65f72c747": "Download progress",
|
||||
"fb11aa6f29827095": "Checking…",
|
||||
"fbe8778fa8b9bab5": "Initialize the local CA first",
|
||||
"fc22d1ab9ac73c6f": "Checking the plugin runtime",
|
||||
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
|
||||
"fcd311fd8ad42462": "Open model list",
|
||||
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
|
||||
"fd77192739703811": "Bulk import",
|
||||
"fdc4cabc370fa3f7": "No options",
|
||||
"fea405f9b01d1416": "Summary",
|
||||
"fec45092945f8790": "User guide",
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
"0006d696d8e1ec28": "新增",
|
||||
"00929f23850e4ff0": "成功调用:{count}",
|
||||
"01f3e69a5a9b2c9b": "访问模型服务所需的密钥。",
|
||||
"023810003eb4563d": "{count} 个模型",
|
||||
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
|
||||
"028c60a8a8e30a1b": "第 {page} / {total} 页",
|
||||
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "编辑",
|
||||
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
|
||||
"0580e0a99a6f1afc": "工件数",
|
||||
"06619f339fa0ab46": "正在准备插件运行时",
|
||||
"076832c1b2de22c3": "缓存写入:{tokens}",
|
||||
"07879e064ae16542": "输出推算:{tokens}",
|
||||
"07c657ed4747126e": "Anthropic 额外参数",
|
||||
"08791ba06e7441de": "{accounts} 个账号 · {models} 个模型",
|
||||
"092b520558eff5f2": "未测试",
|
||||
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
|
||||
"09ebc2643631ba25": "价值估算",
|
||||
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
|
||||
"0bbb2c0ce279d6d5": "尚未同步模型",
|
||||
"0c70665b6eb65f1a": "否",
|
||||
"0c72229b7db0e1a9": "模型输出",
|
||||
"0d2dab3d62eb73d6": "全部统计数据已清理",
|
||||
@@ -33,8 +38,11 @@
|
||||
"168e845a86bc3703": "添加模型",
|
||||
"16d0d7e2b332af72": "总调用:{count}",
|
||||
"1813d362a82fd437": "最大化窗口",
|
||||
"18165f8865eacc91": "还没有安装插件",
|
||||
"19658d9fa9aa8de4": "安装中…",
|
||||
"1a3f0617d6de8e52": "用户名",
|
||||
"1a60c9eb3cf1dbb5": "导入完成:新增 {added},更新 {updated}",
|
||||
"1aa65c55c6cc6163": "设备验证码",
|
||||
"1ae6b0a0f8266382": "关闭窗口",
|
||||
"1b5932b8946d2d68": "删除模型",
|
||||
"1b7d5b1a9315fc64": "计算中…",
|
||||
@@ -50,12 +58,14 @@
|
||||
"22d7895ea5fca72e": "按供应商",
|
||||
"23ae7a90b1b9816d": "清理范围",
|
||||
"23e49479e15e6770": "发现新版本 {version}",
|
||||
"2400fbd0aeab9e13": "已下载 {downloaded}",
|
||||
"24a0a24864454575": "已存在,跳过",
|
||||
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
|
||||
"29585d7193539200": "当前版本 {version}",
|
||||
"29fbbef32a6eb58b": "不再显示此广告",
|
||||
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cbc58108d78b06c": "等待网页端确认授权中…",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2d30c2a98ebb5278": "当前:{rate}",
|
||||
"2eb2bf7c6597ab9a": "详细记录",
|
||||
@@ -67,12 +77,17 @@
|
||||
"2f7ba5fd1d12f7f9": "打开使用教程?",
|
||||
"2f7dec3be28d7597": "已选择 {count} 个",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"2fe5a8d0eee9f14c": "已失效",
|
||||
"303c30f301514250": "搜索资源",
|
||||
"3260348163d03b8e": "留空保持不变",
|
||||
"32896fdaaaa4c106": "账号已保存,模型目录已同步。",
|
||||
"346ff60e6c7c5181": "读取中…",
|
||||
"36f33adaf0942634": "确认",
|
||||
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
|
||||
"378bb0eec39fa8a2": "最后一页",
|
||||
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
|
||||
"382f2e3419a02fef": "仅清理详细记录",
|
||||
"38844b135cf70dfc": "更多",
|
||||
"393e1241552b1870": "请求",
|
||||
"398f8e6c6f0a0b97": "继续选择或输入",
|
||||
"39f52eee100131d7": "缓存输入",
|
||||
@@ -88,6 +103,7 @@
|
||||
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
|
||||
"3fd118e2ffe0b2b6": "取消全部测试",
|
||||
"3fd47edce45b3603": "关闭",
|
||||
"402495402ce333b1": "重新初始化插件",
|
||||
"40a08e7cf320ae07": "确定要清理详细记录吗?",
|
||||
"4125fc7ba333524c": "默认亮色",
|
||||
"42655ed8e4108ae2": "输入(非缓存)",
|
||||
@@ -97,6 +113,7 @@
|
||||
"461d6a57900c2ed7": "连通性测试失败:{error}",
|
||||
"470049252e54de6a": "成功占比:{rate}",
|
||||
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
|
||||
"48a3bf87eb254591": "开始登录",
|
||||
"48b970b568a7f8f9": "代理设置",
|
||||
"48d8db17bae06246": "共 {count} 条",
|
||||
"492042ed1fdc29ed": "版本 {version} 可以安装",
|
||||
@@ -104,9 +121,12 @@
|
||||
"497c85690c4cc0fc": "暂无数据",
|
||||
"499c729eb09aa2a6": "上下文窗口 Token",
|
||||
"49be72e6045c007d": "取消测试",
|
||||
"4a861200ad513a3c": "初始化插件运行时",
|
||||
"4a8d6841b4023edf": "确认导入",
|
||||
"4aca6a31090fe2b8": "初始化中…",
|
||||
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
|
||||
"4d0680f9efaef147": "未读",
|
||||
"4d99c976beb8827e": "可用",
|
||||
"4e30d7c9ed2b0eee": "不设置",
|
||||
"4eafa9e925b30bcd": "自定义",
|
||||
"51d04bc3d286f018": "近1自然日",
|
||||
@@ -127,6 +147,7 @@
|
||||
"5b17f59d33bde39e": "错误:{error}",
|
||||
"5ba65a74c4e792c5": "按类型",
|
||||
"5c55a67935af8f45": "全部",
|
||||
"5c62e36c152dfc7c": "插件运行时初始化完成",
|
||||
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
||||
"5f8d556a9c47da3c": "已关闭开机启动",
|
||||
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
||||
@@ -158,53 +179,72 @@
|
||||
"72644ec4389da2f7": "默认平铺",
|
||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
|
||||
"77c9e582e85583af": "测试失败",
|
||||
"788db1cfec2a3db5": "主题",
|
||||
"7995087e5a3dfe66": "还原窗口",
|
||||
"7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA",
|
||||
"7a3cec4ca715de80": "调用统计",
|
||||
"7ba2d6728fe2531b": "确认清理",
|
||||
"7c10d97162c96dbd": "正在验证插件运行时",
|
||||
"7cea2f3c46565d29": "OpenAI 额外参数",
|
||||
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
|
||||
"7e0891860c9e6374": "TAB 服务地址不能为空",
|
||||
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
||||
"7f3c8312816fe26a": "刷新中…",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
||||
"80a57e03f0717f91": "未配置",
|
||||
"811a3b22a5a7f2d5": "无法连接本地管理服务",
|
||||
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
|
||||
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
|
||||
"83e8d0b7aff2b394": "已下载 {downloaded} / {total}",
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"842b9f11cdd96bda": "开机启动",
|
||||
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
||||
"864597982c308d72": "已开启静默启动",
|
||||
"86b7355ec3bd55ef": "隐藏 API Key",
|
||||
"86de7c4ee8fa7689": "同步模型",
|
||||
"8716e1344b0daddb": "Cursor 官方",
|
||||
"878a8ab176429a86": "查看说明",
|
||||
"8911e4f1407d58cb": "正在下载插件运行时",
|
||||
"89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。",
|
||||
"8a8542f6964852dc": "下一页",
|
||||
"8b6ff498515bcc2f": "时间",
|
||||
"8cbcf741e727dbf7": "模型配置",
|
||||
"8ccaf87ddb9ca3f4": "旧版配置",
|
||||
"8d0c47eb9eac2d34": "调用类型",
|
||||
"8df48894086d6fbd": "原因(可选)",
|
||||
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
|
||||
"8ea973394446abba": "Cursor 配置",
|
||||
"8f6f8d979c981ced": "已复制",
|
||||
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
|
||||
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
|
||||
"919cb0ce0c8db4e7": "留空表示保留当前密码",
|
||||
"91aaf184cfc17ffd": "数据概览",
|
||||
"91af6e57e7453fbe": "添加账号",
|
||||
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||
"92e26b27d5ea8f0e": "检查更新失败:{error}",
|
||||
"940a168911ade998": "每页条数",
|
||||
"945fb1c67eca8493": "正在安装插件运行时",
|
||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||
"954ec984cd4f49d1": "正在同步…",
|
||||
"966498853d801a52": "TAB 选择",
|
||||
"9850ed41a5bfbb0c": "已选 {count} 项",
|
||||
"997ec8201c2adeda": "打开终端安装 CA",
|
||||
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
||||
"9b9bc9cd7c76406f": "打开授权网页",
|
||||
"9c41b3a9e12ac994": "思考强度",
|
||||
"9db205c6055bacc4": "插件运行时初始化失败",
|
||||
"9e356080c56877f8": "已关闭静默启动",
|
||||
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
|
||||
"9ebeab8c4532d671": "{name} 账号管理",
|
||||
"9ec4caa5fe43b8e3": "安装插件后会显示在这里。",
|
||||
"9ed11266ead88f5b": "正在验证插件运行时下载文件",
|
||||
"9ef7da883941091c": "账号已保存,但同步模型失败:{error}",
|
||||
"9f6fee1aba17a565": "语言",
|
||||
"9fb48101d237ff96": "近一周",
|
||||
"a026f37e613cf48b": "输出 Token",
|
||||
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
|
||||
"a0c42c24e74f8380": "{name} 副本",
|
||||
"a12ee6a3e98a29c2": "隐藏敏感内容",
|
||||
"a1a42cd9b16e2162": "应用设置",
|
||||
"a1b8c98f29374a2f": "静默启动",
|
||||
"a3030bf8f16dc63c": "保存",
|
||||
@@ -215,10 +255,12 @@
|
||||
"a4d222236dc1003d": "取消测试失败:{error}",
|
||||
"a5fb6189a8ad011d": "打开教程",
|
||||
"a621ab606db2a11f": "密码",
|
||||
"a66e11477dcc97c1": "添加 {name} 账号",
|
||||
"a693d69af48bfe48": "保存并测试",
|
||||
"a748cc074f78de00": "查看详情",
|
||||
"a7617f42f898b2bf": "使用完整请求地址",
|
||||
"a8036485f9227f2c": "拖动排序",
|
||||
"a80b53f8848e6d27": "安装更新失败:{error}",
|
||||
"a98585871c5313ff": "显示名称",
|
||||
"ab9084a640fbb864": "全不选",
|
||||
"abecab6701177721": "已开启开机启动",
|
||||
@@ -230,7 +272,10 @@
|
||||
"aee88743413144a2": "刷新",
|
||||
"b06325c5660f0c29": "直连",
|
||||
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
|
||||
"b254ff315d861346": "请重试初始化",
|
||||
"b2617bf9ae663752": "分组设置",
|
||||
"b4411558b932266f": "上游类型",
|
||||
"b4c9e08870d41aa2": "需要先初始化插件运行时",
|
||||
"b502b1d414664337": "提示词:{tokens}",
|
||||
"b5141d3d19e9a048": "是",
|
||||
"b6725f218ebaef26": "已显示 Dock 栏图标",
|
||||
@@ -243,14 +288,20 @@
|
||||
"b9670c85a4ab939e": "路由",
|
||||
"b9af2de88d903be7": "代理地址",
|
||||
"ba5865fbc734e672": "例如:主力模型",
|
||||
"ba6403d22876d626": "冷却中",
|
||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||
"bb2b7736433ae867": "Cursor 追踪",
|
||||
"bb7efdcb6af6e805": "默认暗色",
|
||||
"bda62ce1d5e4ace9": "可以告诉我们原因",
|
||||
"bda74b5674b6a57d": "初始化插件",
|
||||
"be961dc60ab610da": "修改后应用于该分组下的全部模型;留空保持各模型现有配置不变。",
|
||||
"bf57afd709694b55": "概览时间范围",
|
||||
"bfc01caf9fe0c841": "缓存命中率 {rate}",
|
||||
"c0b3fbff51ccc40b": "完成",
|
||||
"c1e98892a77f7a19": "{count} 条/页",
|
||||
"c3760858cdb6d9f4": "请求体",
|
||||
"c54863655e879b36": "当前系统不支持插件运行时",
|
||||
"c6e7e1a9da356efc": "还没有资源,请先添加。",
|
||||
"c7ea2c9bc43134bd": "编辑模型",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c8df3c14a003bfcd": "无法加载调用详情",
|
||||
@@ -258,6 +309,7 @@
|
||||
"c9b9ae7a61444ab7": "上一页",
|
||||
"c9d146d006993cc1": "缓存统计策略:默认口径({rate})",
|
||||
"cb2f1709f983d2f4": "模型名称",
|
||||
"cb99f0138b032687": "初始化将下载并安装插件运行时。",
|
||||
"cea1aafe9416de7b": "请求头",
|
||||
"cfae1a14d2120c57": "详细模式",
|
||||
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
|
||||
@@ -265,12 +317,17 @@
|
||||
"d1251cd752d4ec25": "留空时使用 adaptive thinking。",
|
||||
"d1a3d72618d1ed27": "所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"d2d648bd1c94b7f9": "认证",
|
||||
"d2fcdde81f06645c": "批量导出",
|
||||
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
|
||||
"d3716cc5a2f5a810": "服务器地址",
|
||||
"d3d21191f32e79a5": "处理中…",
|
||||
"d507652243a2151e": "显示敏感内容",
|
||||
"d58c88688e1a949d": "常用预设",
|
||||
"d59e47070f7f358e": "可调用",
|
||||
"d60669bb26a22f5d": "留空使用默认值",
|
||||
"d6b1f203680f5496": "留空使用 adaptive thinking",
|
||||
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
|
||||
"d7e266bdc8064193": "分组名称",
|
||||
"d86fa42c3848c680": "使用系统代理",
|
||||
"d8c47e9776cf1082": "主菜单",
|
||||
"da521d1c1cbd36af": "需要授权安装证书",
|
||||
@@ -279,18 +336,22 @@
|
||||
"db340a9896306d08": "测试已取消",
|
||||
"dbd3596e4a86f3c2": "配置模型",
|
||||
"ddde16f8839da3ce": "总请求",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
|
||||
"df1baa9f706d970b": "将新增",
|
||||
"df3d58c7d84b85f2": "设置",
|
||||
"df8b71c74d9b8478": "响应流",
|
||||
"dfb802238b38fbd4": "已启用",
|
||||
"e025f1ff71996425": "已设置",
|
||||
"e049096ab5614581": "该插件不需要添加资源",
|
||||
"e0fae77446a389a3": "速度:{speed} tokens/s",
|
||||
"e1295adecbb77755": "关闭广告",
|
||||
"e14115de7f7c5795": "过去一年的 Token 用量",
|
||||
"e14f20d572c02611": "上游调用序号",
|
||||
"e17a5b9c90cda6ab": "模型已复制",
|
||||
"e18516550b9a5105": "无用量",
|
||||
"e231f1f3428d1c93": "正在申请授权码…",
|
||||
"e24096c81b1a8af4": "授权被拒绝或已失败。",
|
||||
"e24ebe4a866d69bf": "测试失败:{error}",
|
||||
"e25bf3f419bb68f0": "调用详细",
|
||||
"e3fee05f688708b4": "LLM 调用",
|
||||
@@ -298,6 +359,7 @@
|
||||
"e5043c7a2b408271": "近10分钟",
|
||||
"e59ae97924d62f01": "第一页",
|
||||
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
|
||||
"e5c84c9aa7826566": "未就绪",
|
||||
"e77e3d58b0dcffaa": "耗时",
|
||||
"e825a2a42c22380e": "模型类型",
|
||||
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
||||
@@ -305,17 +367,20 @@
|
||||
"ea26b760e930a7ca": "调用观测",
|
||||
"eb11e2df1d8ae387": "上游地址",
|
||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||
"eb4a3db23661fb52": "应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。",
|
||||
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
|
||||
"eba54690937bc532": "账号管理",
|
||||
"ed31fbb483ee1b0a": "操作",
|
||||
"edc70de18c6da1a6": "安装本地 CA",
|
||||
"ee239f3943293f87": "周日",
|
||||
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||
"f2bdc88464c51c2e": "显示 API Key",
|
||||
"f4694c46b1e19602": "最终请求类型",
|
||||
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
|
||||
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
|
||||
"f50276449943286c": "结束时间",
|
||||
"f69273dbbebfb3a1": "格式化",
|
||||
"f6dc1b1641600dd0": "正在导入…",
|
||||
"f78265089144369a": "插件配置",
|
||||
"f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
|
||||
"f85537d1fd2ef6f6": "自定义概览筛选",
|
||||
"f95ea7f4c063eea7": "未启用",
|
||||
@@ -323,11 +388,14 @@
|
||||
"f9aa11dbb15ce647": "周六",
|
||||
"f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。",
|
||||
"fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。",
|
||||
"fad86bf65f72c747": "下载进度",
|
||||
"fb11aa6f29827095": "检查中…",
|
||||
"fbe8778fa8b9bab5": "需要先初始化本地 CA",
|
||||
"fc22d1ab9ac73c6f": "正在检查插件运行时",
|
||||
"fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}",
|
||||
"fcd311fd8ad42462": "打开模型列表",
|
||||
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
|
||||
"fd77192739703811": "批量导入",
|
||||
"fdc4cabc370fa3f7": "暂无选项",
|
||||
"fea405f9b01d1416": "概览",
|
||||
"fec45092945f8790": "使用教程",
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Model {
|
||||
model_hash: string;
|
||||
sort_order: number;
|
||||
display_name: string;
|
||||
group_name: string | null;
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
use_full_url: boolean;
|
||||
@@ -33,6 +34,7 @@ export interface Model {
|
||||
export interface ModelInput {
|
||||
sort_order: number;
|
||||
display_name: string;
|
||||
group_name: string | null;
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
use_full_url: boolean;
|
||||
@@ -148,6 +150,160 @@ export interface DesktopSettings {
|
||||
show_dock_icon: boolean;
|
||||
}
|
||||
|
||||
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
|
||||
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
|
||||
|
||||
export interface PluginRuntimeStatus {
|
||||
state: PluginRuntimeState;
|
||||
version: string;
|
||||
target: string | null;
|
||||
phase: PluginRuntimePhase | null;
|
||||
downloaded_bytes: number;
|
||||
total_bytes: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 插件提供的显示文本:纯字符串或 locale → 文本映射。 */
|
||||
export type PluginLocalizedText = string | Record<string, string>;
|
||||
|
||||
export function pluginText(value: PluginLocalizedText | null | undefined, locale: string): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (value[locale]) return value[locale];
|
||||
const language = locale.split("-")[0].toLowerCase();
|
||||
for (const [key, text] of Object.entries(value)) {
|
||||
const normalized = key.toLowerCase();
|
||||
if (normalized === language || normalized.startsWith(`${language}-`)) return text;
|
||||
}
|
||||
return value["en-US"] ?? value["en"] ?? Object.values(value)[0] ?? "";
|
||||
}
|
||||
|
||||
export interface PluginResourceState {
|
||||
status: "ready" | "cooling" | "invalid";
|
||||
retryAtMs?: number | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginResourceMetric {
|
||||
id: string;
|
||||
label: PluginLocalizedText;
|
||||
unit: "percent" | "count";
|
||||
value: number;
|
||||
resetAtMs?: number | null;
|
||||
}
|
||||
|
||||
export interface PluginResourceView {
|
||||
id: string;
|
||||
state: PluginResourceState;
|
||||
displayName: string;
|
||||
description: PluginLocalizedText | null;
|
||||
metrics: PluginResourceMetric[];
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface PluginAddMethod {
|
||||
type: "oauth2.0";
|
||||
id: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
}
|
||||
|
||||
export interface PluginImportDescriptor {
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
accept: string[];
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export interface PluginResourceDescriptor {
|
||||
type: string;
|
||||
displayName: PluginLocalizedText;
|
||||
add: PluginAddMethod[];
|
||||
import: PluginImportDescriptor | null;
|
||||
canRefresh: boolean;
|
||||
canRemove: boolean;
|
||||
resources: PluginResourceView[];
|
||||
}
|
||||
|
||||
export interface PluginModelDescriptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
icon: string;
|
||||
providerType: string;
|
||||
maxOutputTokens: number | null;
|
||||
images: boolean;
|
||||
}
|
||||
|
||||
export interface PluginProviderDescriptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
providerType: string;
|
||||
resourceType: string | null;
|
||||
hasModels: boolean;
|
||||
configured: boolean;
|
||||
models: PluginModelDescriptor[];
|
||||
}
|
||||
|
||||
export interface PluginDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string | null;
|
||||
icon: string;
|
||||
providers: PluginProviderDescriptor[];
|
||||
resources: PluginResourceDescriptor[];
|
||||
}
|
||||
|
||||
export interface PluginOAuthBegin {
|
||||
sessionId: string;
|
||||
userCode: string;
|
||||
verificationUrl: string;
|
||||
verificationUrlComplete: string | null;
|
||||
expiresAtMs: number;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
export type PluginOAuthPoll =
|
||||
| { status: "pending"; pollIntervalMs: number }
|
||||
| { status: "completed"; added: number; updated: number; modelSyncError: string | null }
|
||||
| { status: "denied"; message: string | null }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export interface PluginImportFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PluginImportResult {
|
||||
added: number;
|
||||
updated: number;
|
||||
warnings: string[];
|
||||
modelSyncError: string | null;
|
||||
}
|
||||
|
||||
export type ConfiguredModel =
|
||||
| { kind: "builtin"; id: string; name: string; builtin: Model }
|
||||
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
|
||||
|
||||
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
||||
return plugins.flatMap((plugin) =>
|
||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
|
||||
}
|
||||
|
||||
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
|
||||
return [
|
||||
...models.map((model): ConfiguredModel => ({ kind: "builtin", id: model.model_hash, name: model.display_name, builtin: model })),
|
||||
...configuredPluginModels(plugins).map((model): ConfiguredModel => ({ kind: "plugin", id: model.id, name: model.displayName, plugin: model })),
|
||||
];
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
llm_calls: number;
|
||||
successful_calls: number;
|
||||
@@ -295,8 +451,8 @@ export const api = {
|
||||
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
|
||||
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
|
||||
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) {
|
||||
@@ -309,6 +465,18 @@ export const api = {
|
||||
},
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
plugins: () => request<PluginDescriptor[]>("/plugins"),
|
||||
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
|
||||
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
|
||||
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
|
||||
refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }),
|
||||
deletePluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, { method: "DELETE" }),
|
||||
syncPluginModels: (pluginId: string, providerId: string) => request<{ models: number }>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/sync`, { method: "POST" }),
|
||||
pluginResourceExportUrl: (servicePort: number, pluginId: string, resourceType: string) => `http://127.0.0.1:${servicePort}${API_ROOT}/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/export`,
|
||||
removePluginConfiguration: (pluginId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}`, { method: "DELETE" }),
|
||||
pluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime"),
|
||||
initializePluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "POST" }),
|
||||
cancelPluginRuntimeInitialization: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "DELETE" }),
|
||||
openCursorCaInstallTerminal: async (command: string) => {
|
||||
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getVersion, setDockVisibility } from "@tauri-apps/api/app";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
@@ -46,11 +46,39 @@ export async function writeDockIconVisibility(visible: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdate(): Promise<Update | null> {
|
||||
return check();
|
||||
export type AppUpdate = {
|
||||
version: string;
|
||||
install(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type PortableUpdateInfo = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
export async function checkForUpdate(): Promise<AppUpdate | null> {
|
||||
if (desktopPlatform() === "windows") {
|
||||
const update = await invoke<PortableUpdateInfo | null>("check_portable_update");
|
||||
if (!update) return null;
|
||||
return {
|
||||
version: update.version,
|
||||
install: () => invoke("install_portable_update", { expectedVersion: update.version }),
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const update: Update | null = await check();
|
||||
if (!update) return null;
|
||||
return {
|
||||
version: update.version,
|
||||
install: async () => {
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
},
|
||||
close: () => update.close(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installUpdate(update: Update): Promise<void> {
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
export async function installUpdate(update: AppUpdate): Promise<void> {
|
||||
await update.install();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings } from "../api";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
||||
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
||||
|
||||
export type AppSnapshot = {
|
||||
@@ -13,6 +13,8 @@ export type AppSnapshot = {
|
||||
theme: ThemeId;
|
||||
cursorHarness: CursorHarnessStatus | null;
|
||||
cursorBusy: boolean;
|
||||
pluginRuntime: PluginRuntimeStatus | null;
|
||||
plugins: PluginDescriptor[];
|
||||
};
|
||||
|
||||
const savedTheme = (): ThemeId => {
|
||||
@@ -45,6 +47,8 @@ let snapshot: AppSnapshot = {
|
||||
theme: savedTheme(),
|
||||
cursorHarness: null,
|
||||
cursorBusy: false,
|
||||
pluginRuntime: null,
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -73,15 +77,17 @@ export const appStore = {
|
||||
async refresh() {
|
||||
update({ busy: true, error: null });
|
||||
try {
|
||||
const [models, calls, overview, settings, ports, cursorHarness] = await Promise.all([
|
||||
const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime, plugins] = await Promise.all([
|
||||
api.models(),
|
||||
api.calls(),
|
||||
api.overview(),
|
||||
api.observability(),
|
||||
api.ports(),
|
||||
api.cursorHarness(),
|
||||
api.pluginRuntime(),
|
||||
api.plugins(),
|
||||
]);
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness });
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime, plugins });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
} finally {
|
||||
@@ -107,6 +113,55 @@ export const appStore = {
|
||||
return null;
|
||||
} finally { update({ cursorBusy: false }); }
|
||||
},
|
||||
async initializePluginRuntime() {
|
||||
update({ error: null });
|
||||
try {
|
||||
const pluginRuntime = await api.initializePluginRuntime();
|
||||
update({ pluginRuntime });
|
||||
return pluginRuntime;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async refreshPluginRuntime() {
|
||||
try {
|
||||
const wasReady = snapshot.pluginRuntime?.state === "ready";
|
||||
const pluginRuntime = await api.pluginRuntime();
|
||||
update({ pluginRuntime });
|
||||
if (!wasReady && pluginRuntime.state === "ready") {
|
||||
const plugins = await api.plugins();
|
||||
update({ plugins });
|
||||
}
|
||||
return pluginRuntime;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async cancelPluginRuntimeInitialization() {
|
||||
try {
|
||||
const pluginRuntime = await api.cancelPluginRuntimeInitialization();
|
||||
update({ pluginRuntime });
|
||||
return pluginRuntime;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async refreshPlugins() {
|
||||
try {
|
||||
update({ plugins: await api.plugins() });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
},
|
||||
async removePluginConfiguration(pluginId: string) {
|
||||
await perform(async () => {
|
||||
await api.removePluginConfiguration(pluginId);
|
||||
update({ plugins: await api.plugins() });
|
||||
});
|
||||
},
|
||||
async setCursorEnabled(enabled: boolean) {
|
||||
update({ cursorBusy: true, error: null });
|
||||
try { update({ cursorHarness: await api.setCursorEnabled(enabled) }); }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import {
|
||||
checkForUpdate,
|
||||
hasNativeAppLifecycle,
|
||||
installUpdate,
|
||||
type AppUpdate,
|
||||
} from "../native/appLifecycle";
|
||||
|
||||
export type UpdateSnapshot = {
|
||||
@@ -17,7 +17,7 @@ let snapshot: UpdateSnapshot = {
|
||||
checking: false,
|
||||
installing: false,
|
||||
};
|
||||
let availableUpdate: Update | null = null;
|
||||
let availableUpdate: AppUpdate | null = null;
|
||||
let pendingCheck: Promise<string | null> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -26,7 +26,7 @@ function update(patch: Partial<UpdateSnapshot>) {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
async function replaceAvailableUpdate(next: Update | null) {
|
||||
async function replaceAvailableUpdate(next: AppUpdate | null) {
|
||||
const previous = availableUpdate;
|
||||
availableUpdate = next;
|
||||
update({ availableVersion: next?.version ?? null });
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 14000;
|
||||
min-width: 132px;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
display: block;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.openIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "./Button";
|
||||
import { Icon } from "./Icon";
|
||||
import { chevronDownIcon } from "./icons";
|
||||
import styles from "./ActionMenu.module.scss";
|
||||
|
||||
export type ActionMenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
/** 触发器 + 动作列表的下拉菜单,用于容纳卡片上的次要操作。 */
|
||||
export function ActionMenu({ label, items, disabled }: {
|
||||
label: string;
|
||||
items: ActionMenuItem[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !trigger.current || !menu.current) return;
|
||||
return autoUpdate(trigger.current, menu.current, () =>
|
||||
void computePosition(trigger.current!, menu.current!, {
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 })],
|
||||
}).then(({ x, y }) => setPosition({ left: x, top: y })));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!trigger.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
};
|
||||
|
||||
return <>
|
||||
<Button
|
||||
ref={trigger}
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") close();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<Icon icon={chevronDownIcon} size="1em" className={open ? styles.openIcon : undefined} />
|
||||
</Button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
id={menuId}
|
||||
ref={menu}
|
||||
className={styles.menu}
|
||||
role="menu"
|
||||
style={{ left: position.left, top: position.top }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{items.map((item) => <button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import controls from "./Controls.module.scss";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary";
|
||||
@@ -10,7 +10,7 @@ export function Button({
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
}: ComponentProps<"button"> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
background: var(--vscode-button-background);
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
&:disabled { opacity: .5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.secondary {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function SecretTextInput({ className, ...props }: InputHTMLAttributes<HTM
|
||||
const [visible, setVisible] = useState(false);
|
||||
return <div className={styles.secret}>
|
||||
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
|
||||
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏 API Key") : t("显示 API Key")} onClick={() => setVisible((current) => !current)}>
|
||||
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏敏感内容") : t("显示敏感内容")} onClick={() => setVisible((current) => !current)}>
|
||||
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>;
|
||||
|
||||
@@ -21,6 +21,7 @@ type ModalProps = {
|
||||
secondaryAction?: ReactNode;
|
||||
closeLabel?: string;
|
||||
submitLabel?: string;
|
||||
submitDisabled?: boolean;
|
||||
};
|
||||
|
||||
const focusableSelector = [
|
||||
@@ -37,7 +38,7 @@ function focusableElements(root: HTMLElement) {
|
||||
.filter((element) => element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存"), submitDisabled = false }: ModalProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const submitButton = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
@@ -95,7 +96,7 @@ export function Modal({ id, open, title, children, banner, busy, wide, fullHeigh
|
||||
<footer>
|
||||
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
|
||||
{secondaryAction}
|
||||
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy || submitDisabled} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
</footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon } from "./icons";
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"] };
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string };
|
||||
|
||||
export function Select({ value, options, disabled, ariaLabel, onChange }: { value: string; options: SelectOption[]; disabled?: boolean; ariaLabel: string; onChange: (value: string) => void }) {
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
@@ -48,10 +48,10 @@ export function Select({ value, options, disabled, ariaLabel, onChange }: { valu
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open) { event.preventDefault(); choose(options[active]); }
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}}><span className={styles.optionContent}>{selected?.icon && <Icon icon={selected.icon} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
}}><span className={styles.optionContent}>{(selected?.icon || selected?.iconSrc) && <Icon icon={selected.icon} src={selected.iconSrc} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" style={{ left: position.left, top: position.top, width: position.width }}>
|
||||
<VirtualList items={options} itemKey="value" estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(options.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span></button>}
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{(option.icon || option.iconSrc) && <Icon icon={option.icon} src={option.iconSrc} />}<span>{option.label}</span></span></button>}
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useRef, useState, type ComponentProps } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { TooltipTrigger } from "./TooltipTrigger";
|
||||
import styles from "./TruncatedButton.module.scss";
|
||||
|
||||
/**
|
||||
* 文本被省略号截断时才显示完整文案悬浮提示的按钮。
|
||||
* 按钮是 flex 容器,省略号只作用在内层文本 span 上;
|
||||
* 截断在悬停/聚焦时现测——挂载时字体可能未加载,提前测会得到错误结果。
|
||||
*/
|
||||
export function TruncatedButton({ label, ...props }: ComponentProps<typeof Button> & { label: string }) {
|
||||
const element = useRef<HTMLSpanElement>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const measure = () => {
|
||||
const text = element.current;
|
||||
if (text) setTruncated(text.scrollWidth > text.clientWidth);
|
||||
};
|
||||
const button = <Button {...props} onPointerEnter={measure} onFocus={measure}>
|
||||
<span ref={element} className={styles.label}>{label}</span>
|
||||
</Button>;
|
||||
return truncated ? <TooltipTrigger label={label}>{button}</TooltipTrigger> : button;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export const flatColorComboChartIcon = icon('<path fill="#00BCD4" d="M37 18h6v24
|
||||
export const flatColorDataConfigurationIcon = icon('<path fill="#D1C4E9" d="M38 7H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2"/><path fill="#607D8B" d="M45.2 38.1c.1-.4.1-.8.1-1.1s0-.8-.1-1.1l2.3-1.7c.2-.2.3-.5.2-.7l-2.3-3.9c-.1-.2-.4-.3-.7-.2l-2.6 1.2c-.6-.5-1.3-.9-2-1.2l-.3-2.9c0-.3-.3-.5-.5-.5h-4.5c-.3 0-.5.2-.5.5l-.3 2.9c-.7.3-1.4.7-2 1.2l-2.6-1.2c-.3-.1-.6 0-.7.2l-2.3 3.9c-.1.2-.1.6.2.7l2.3 1.7c-.1.4-.1.8-.1 1.1s0 .8.1 1.1l-2.3 1.7c-.2.2-.3.5-.2.7l2.3 3.9c.1.2.4.3.7.2l2.6-1.2c.6.5 1.3.9 2 1.2l.3 2.9c0 .3.3.5.5.5h4.5c.3 0 .5-.2.5-.5l.3-2.9c.7-.3 1.4-.7 2-1.2l2.6 1.2c.3.1.6 0 .7-.2l2.3-3.9c.1-.2.1-.6-.2-.7zM37 42.2c-2.9 0-5.2-2.3-5.2-5.2s2.3-5.2 5.2-5.2s5.2 2.3 5.2 5.2s-2.3 5.2-5.2 5.2"/><path fill="#455A64" d="M37 31c-3.3 0-6 2.7-6 6s2.7 6 6 6s6-2.7 6-6s-2.7-6-6-6m0 9c-1.7 0-3-1.3-3-3s1.3-3 3-3s3 1.3 3 3s-1.3 3-3 3"/>', 48, 48);
|
||||
export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1-.7.2-1.4.2-2.2s-.1-1.5-.2-2.2l4.5-3.2c.4-.3.6-.9.3-1.4L40 10.8c-.3-.5-.8-.7-1.3-.4l-5 2.3c-1.2-.9-2.4-1.6-3.8-2.2L29.4 5c-.1-.5-.5-.9-1-.9h-8.6c-.5 0-1 .4-1 .9l-.5 5.5c-1.4.6-2.7 1.3-3.8 2.2l-5-2.3c-.5-.2-1.1 0-1.3.4l-4.3 7.4c-.3.5-.1 1.1.3 1.4l4.5 3.2c-.1.7-.2 1.4-.2 2.2s.1 1.5.2 2.2L4 30.4c-.4.3-.6.9-.3 1.4L8 39.2c.3.5.8.7 1.3.4l5-2.3c1.2.9 2.4 1.6 3.8 2.2l.5 5.5c.1.5.5.9 1 .9h8.6c.5 0 1-.4 1-.9l.5-5.5c1.4-.6 2.7-1.3 3.8-2.2l5 2.3c.5.2 1.1 0 1.3-.4l4.3-7.4c.3-.5.1-1.1-.3-1.4zM24 35c-5.5 0-10-4.5-10-10s4.5-10 10-10s10 4.5 10 10s-4.5 10-10 10"/><path fill="#455A64" d="M24 13c-6.6 0-12 5.4-12 12s5.4 12 12 12s12-5.4 12-12s-5.4-12-12-12m0 17c-2.8 0-5-2.2-5-5s2.2-5 5-5s5 2.2 5 5s-2.2 5-5 5"/>', 48, 48);
|
||||
|
||||
export const flatColorCrystalOscillatorIcon = icon('<path fill="#FF9800" d="M3 28h26v4H3zm0-12h26v4H3z"/><path fill="#2196F3" d="M43 11H20v26h23c1.1 0 2-.9 2-2V13c0-1.1-.9-2-2-2"/><path fill="#64B5F6" d="M20 9h-2v30h2c1.1 0 2-.9 2-2V11c0-1.1-.9-2-2-2"/>', 48, 48); // flat-color-icons:crystal-oscillator
|
||||
|
||||
export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257);
|
||||
export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>');
|
||||
// Menu icons intentionally use filled glyphs from different collections so they
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfirmDialog } from "../shared/ui/ConfirmDialog";
|
||||
import controls from "../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../shared/ui/Icon";
|
||||
import { TooltipTrigger } from "../shared/ui/TooltipTrigger";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorCrystalOscillatorIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
|
||||
import { useMessage } from "../shared/ui/message";
|
||||
import { VirtualList } from "../shared/virtual/VirtualList";
|
||||
import { useI18n } from "../i18n/store";
|
||||
@@ -27,7 +27,7 @@ type MenuItem =
|
||||
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
|
||||
| { kind: "group"; label: string };
|
||||
|
||||
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"];
|
||||
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor", "/plugins"];
|
||||
const readAdStorageKey = "cursor-byok:read-ad-ids";
|
||||
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
|
||||
const tutorialReadStorageKey = "cursor-byok:tutorial-read";
|
||||
@@ -72,8 +72,10 @@ export function AppLayout() {
|
||||
const menuItems: MenuItem[] = [
|
||||
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
|
||||
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
|
||||
{ kind: "group", label: "Harness" },
|
||||
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
|
||||
{ kind: "group", label: t("模型配置") },
|
||||
{ kind: "page", path: "/harness/cursor", label: "Cursor", icon: cursorIconUrl },
|
||||
{ kind: "group", label: t("设置") },
|
||||
{ kind: "page", path: "/plugins", label: t("插件配置"), icon: flatColorCrystalOscillatorIcon },
|
||||
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
|
||||
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "User Guide",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "tab-service", "faq"]
|
||||
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "使用指南",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "tab-service", "faq"]
|
||||
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: Plugin Development
|
||||
description: Build stateless TypeScript plugins that execute providers, enumerate models, and manage credential resources with OAuth sign-in.
|
||||
icon: Blocks
|
||||
---
|
||||
|
||||
Plugins implement three capability interfaces defined by the core: **Provider** (execute one LLM call), **Model** (enumerate available models), and **Resource** (credential resources such as accounts). Plugins hold no persistent state — resources and model catalogs are stored by the core, and every call receives the data it needs as arguments.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
~/.cursor-byok-v3/plugins/
|
||||
├── installed/
|
||||
│ └── com.example.subscription/
|
||||
│ ├── plugin.json # static identity, entry, icon, HTTPS host allowlist
|
||||
│ ├── main.ts # defineProviderPlugin composing providers and resources
|
||||
│ └── assets/icon.svg # local icon, 1 MiB max
|
||||
└── data/
|
||||
└── com.example.subscription/
|
||||
├── resources-<type>.json # resource records persisted by the core (0600)
|
||||
└── models-<provider>.json # model catalogs persisted by the core
|
||||
```
|
||||
|
||||
Built-in plugin sources live at `server/plugins/build-in/` (such as `codex-auth`); they are bundled into the binary and pre-installed into `plugins/installed/` keyed by version. Debug builds load the source directory first so edits take effect immediately.
|
||||
|
||||
## Static manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "com.example.subscription",
|
||||
"name": "Example Subscription",
|
||||
"version": "0.1.0",
|
||||
"author": "@example",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/icon.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": ["auth.example.com", "api.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions.network` accepts exact hostnames only. All plugin network requests must use HTTPS and hit this allowlist. `version` is required; the plugin is ignored when the app version is older than `minAppVersion`. Built-in plugins are pre-installed into `plugins/installed/` keyed by `version`: startup writes nothing when the version matches and resyncs the whole directory (pruning stale files) when it changes.
|
||||
|
||||
## Entry and capabilities
|
||||
|
||||
The host injects `cursor-byok:plugin`, `cursor-byok:provider`, `cursor-byok:model`, `cursor-byok:resource`, and the protocol helper `cursor-byok:protocol/openai-responses`.
|
||||
|
||||
```ts
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [{
|
||||
id: "subscription",
|
||||
displayName: "Example Subscription",
|
||||
providerType: "openai",
|
||||
resourceType: "account",
|
||||
models: {
|
||||
list: async ({ resource }, context) => {
|
||||
// Discover upstream models with the first ready resource; the return
|
||||
// value replaces the core-side catalog.
|
||||
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
|
||||
},
|
||||
},
|
||||
invoke: async (input, output, context) => {
|
||||
try {
|
||||
await streamOpenAiResponses({
|
||||
url: "https://api.example.com/v1/responses",
|
||||
model: input.model.id,
|
||||
request: input.request,
|
||||
headers: { authorization: `Bearer ${token(input.resource)}` },
|
||||
}, output, context);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: { state: { status: "invalid", message: "sign in again" } },
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: String(error) };
|
||||
}
|
||||
},
|
||||
}],
|
||||
resources: [{
|
||||
type: "account",
|
||||
displayName: "Accounts",
|
||||
add: [{
|
||||
type: "oauth2.0",
|
||||
id: "device",
|
||||
displayName: "Sign in",
|
||||
begin: async (context) => ({
|
||||
session: { deviceCode: "..." },
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUrl: "https://auth.example.com/device",
|
||||
expiresAtMs: Date.now() + 900_000,
|
||||
pollIntervalMs: 5_000,
|
||||
}),
|
||||
poll: async (session, context) => ({
|
||||
status: "completed",
|
||||
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
|
||||
}),
|
||||
}],
|
||||
present: (resource) => ({
|
||||
displayName: "person@example.com",
|
||||
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
|
||||
}),
|
||||
refresh: async (resource, context) => ({ privateData: { /* updated quota */ } }),
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
## Ownership boundaries
|
||||
|
||||
- **The core owns**: resource persistence and dedupe (upsert by `draft.key`), resource list UI, the OAuth poll loop (interval, slow-down, expiry), model catalog storage, per-call resource selection (currently the first ready resource; cooling expires automatically), call records and statistics.
|
||||
- **The plugin owns**: authentication HTTP transitions (`begin`/`poll`), credential parsing (`import.parse`), resource presentation (`present`), quota refresh (`refresh`), and protocol adaptation with streaming execution (`invoke`).
|
||||
|
||||
`invoke` receives the full `LlmRequest` (instructions, message history, tools, reasoning config), parses the upstream SSE while emitting normalized events through `output.emit()` (text/thinking boundaries, incremental tool arguments, replay state, usage, finish reason), and finally returns `completed` or a typed error. The `patch` carried by `resource-error` is applied atomically to the selected resource and is the basis for future load-balanced retries.
|
||||
|
||||
Stable model IDs take the form `plugin:<plugin-id>/<provider-id>/<model-id>`; every enumerated model enters the Cursor catalog independently.
|
||||
|
||||
## Host context
|
||||
|
||||
- `context.network.fetch(url, init)`: one-shot HTTPS request, strictly allowlisted.
|
||||
- `context.network.stream(url, init)`: streaming response iterated line by line (for SSE).
|
||||
- `context.signal`: fires when the host cancels the call.
|
||||
|
||||
## Sandbox
|
||||
|
||||
The Deno process can only read its own plugin directory and the host SDK directory. Remote modules, npm packages, direct network access, environment variables, subprocesses, and file writes are all disabled. The worker multiplexes requests by request ID; a crash fails the current request and restarts on demand.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
deno check --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/main.ts
|
||||
|
||||
deno test --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/plugin_test.ts
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: 插件开发
|
||||
description: 用无状态 TypeScript 插件实现 Provider 执行、模型枚举、资源接入与 OAuth 登录。
|
||||
icon: Blocks
|
||||
---
|
||||
|
||||
插件实现核心定义的三种能力接口:**Provider**(执行一次 LLM 调用)、**Model**(枚举可用模型)、**Resource**(账号等凭证资源)。插件不持有任何持久状态——资源与模型目录由核心存储,每次调用所需数据都通过参数传入。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
~/.cursor-byok-v3/plugins/
|
||||
├── installed/
|
||||
│ └── com.example.subscription/
|
||||
│ ├── plugin.json # 静态身份、入口、图标和 HTTPS 主机白名单
|
||||
│ ├── main.ts # defineProviderPlugin 组合 providers 与 resources
|
||||
│ └── assets/icon.svg # 本地图标,最大 1 MiB
|
||||
└── data/
|
||||
└── com.example.subscription/
|
||||
├── resources-<type>.json # 核心持久化的资源记录(0600)
|
||||
└── models-<provider>.json # 核心持久化的模型目录
|
||||
```
|
||||
|
||||
内置插件源码位于 `server/plugins/build-in/`(如 `codex-auth`),随二进制打包并按版本预装进 `plugins/installed/`;Debug 构建下源码目录优先加载,便于热改。
|
||||
|
||||
## 静态清单
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "com.example.subscription",
|
||||
"name": "Example Subscription",
|
||||
"version": "0.1.0",
|
||||
"author": "@example",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/icon.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": ["auth.example.com", "api.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions.network` 只能包含精确主机名。所有插件网络请求都必须是 HTTPS 且命中该白名单。`version` 必填;应用版本低于 `minAppVersion` 时插件会被忽略。内置插件按 `version` 预装进 `plugins/installed/`:版本一致时启动零写盘,版本变化时整目录同步并清理旧文件。
|
||||
|
||||
## 入口与能力
|
||||
|
||||
宿主注入 `cursor-byok:plugin`、`cursor-byok:provider`、`cursor-byok:model`、`cursor-byok:resource` 与协议帮助库 `cursor-byok:protocol/openai-responses`。
|
||||
|
||||
```ts
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [{
|
||||
id: "subscription",
|
||||
displayName: "Example Subscription",
|
||||
providerType: "openai",
|
||||
resourceType: "account",
|
||||
models: {
|
||||
list: async ({ resource }, context) => {
|
||||
// 用首个可用资源发现上游模型;返回值整体替换核心目录。
|
||||
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
|
||||
},
|
||||
},
|
||||
invoke: async (input, output, context) => {
|
||||
try {
|
||||
await streamOpenAiResponses({
|
||||
url: "https://api.example.com/v1/responses",
|
||||
model: input.model.id,
|
||||
request: input.request,
|
||||
headers: { authorization: `Bearer ${token(input.resource)}` },
|
||||
}, output, context);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: { state: { status: "invalid", message: "sign in again" } },
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: String(error) };
|
||||
}
|
||||
},
|
||||
}],
|
||||
resources: [{
|
||||
type: "account",
|
||||
displayName: "Accounts",
|
||||
add: [{
|
||||
type: "oauth2.0",
|
||||
id: "device",
|
||||
displayName: "Sign in",
|
||||
begin: async (context) => ({
|
||||
session: { deviceCode: "..." },
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUrl: "https://auth.example.com/device",
|
||||
expiresAtMs: Date.now() + 900_000,
|
||||
pollIntervalMs: 5_000,
|
||||
}),
|
||||
poll: async (session, context) => ({
|
||||
status: "completed",
|
||||
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
|
||||
}),
|
||||
}],
|
||||
present: (resource) => ({
|
||||
displayName: "person@example.com",
|
||||
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
|
||||
}),
|
||||
refresh: async (resource, context) => ({ privateData: { /* 更新额度 */ } }),
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
## 职责边界
|
||||
|
||||
- **核心负责**:资源持久化与去重(按 `draft.key` upsert)、资源列表 UI、OAuth 轮询循环(间隔、slow-down、超时)、模型目录存储、每次调用的资源选择(当前取首个可用,冷却到期自动恢复)、调用记录与统计。
|
||||
- **插件负责**:认证 HTTP 转移(`begin`/`poll`)、凭证解析(`import.parse`)、资源展示投影(`present`)、额度刷新(`refresh`)、协议适配与流式执行(`invoke`)。
|
||||
|
||||
`invoke` 接收完整 `LlmRequest`(指令、消息历史、工具、思考配置),边解析上游 SSE 边 `output.emit()` 标准化事件(文本/思考边界、工具参数增量、回放状态、用量、结束原因),最后返回 `completed` 或类型化错误。`resource-error` 携带的 `patch` 会被核心原子应用到选中的资源,是未来负载均衡换资源重试的依据。
|
||||
|
||||
稳定模型 ID 为 `plugin:<plugin-id>/<provider-id>/<model-id>`,每个枚举出的模型都独立进入 Cursor 模型目录。
|
||||
|
||||
## 宿主上下文
|
||||
|
||||
- `context.network.fetch(url, init)`:一次性 HTTPS 请求,严格执行清单白名单。
|
||||
- `context.network.stream(url, init)`:流式响应,按行异步迭代(用于 SSE)。
|
||||
- `context.signal`:宿主取消本次调用时触发。
|
||||
|
||||
## 沙箱
|
||||
|
||||
Deno 进程只能读取自身插件目录和宿主 SDK 目录。远程模块、npm 包、直接网络、环境变量、子进程和文件写入均被禁用。Worker 按请求 ID 多路复用,崩溃时当前请求失败并按需重启。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
deno check --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/main.ts
|
||||
|
||||
deno test --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/plugin_test.ts
|
||||
```
|
||||
@@ -1,9 +1,4 @@
|
||||
以下是重构后完整目标版本
|
||||
实现时,先创建所有目录和文件固化,每个文件头部都写好注释再实现
|
||||
旧服务已被备份为server_backup,/Users/leokun/Documents/cursor-byok/server 目录已创建
|
||||
行数均为目标估算,使用 `≈` 标记;不包含测试、生成代码和空行。
|
||||
实现时可做略微调整,测试要求相对于目标文件旁边的独立文件,禁止码内测试
|
||||
本文档目录 /Users/leokun/Documents/cursor-byok/cursor.md
|
||||
|
||||
## 完整目录
|
||||
|
||||
```text
|
||||
@@ -656,54 +651,7 @@ store ─X→ cursor
|
||||
model ─X→ cursor
|
||||
```
|
||||
|
||||
## 当前代码迁移
|
||||
|
||||
```text
|
||||
当前 目标
|
||||
|
||||
cursor/bidi_append.rs → api/cursor/bidi.rs
|
||||
cursor/run_sse.rs → api/cursor/run_sse.rs
|
||||
cursor/handlers.rs → api/cursor/handlers.rs
|
||||
cursor/proxy.rs → api/cursor/proxy.rs
|
||||
|
||||
cursor/sessions.rs → cursor/transport/registry.rs
|
||||
+ cursor/transport/handle.rs
|
||||
+ cursor/transport/output.rs
|
||||
|
||||
cursor/inbox.rs → cursor/transport/inbox.rs
|
||||
|
||||
cursor/actor.rs → cursor/transport/
|
||||
+ cursor/conversation/runtime.rs
|
||||
+ cursor/conversation/delivery.rs
|
||||
|
||||
cursor/session.rs → cursor/conversation/runtime.rs
|
||||
+ cursor/conversation/output.rs
|
||||
+ cursor/checkpoint/
|
||||
+ cursor/tools/
|
||||
|
||||
cursor/request/prepare.rs → cursor/compile/run.rs
|
||||
cursor/request/context.rs → cursor/compile/context.rs
|
||||
cursor/request/background.rs → cursor/compile/insert_messages.rs
|
||||
cursor/request/runtime.rs → cursor/compile/break_messages.rs
|
||||
cursor/request/images.rs → cursor/compile/images.rs
|
||||
cursor/request/model.rs → cursor/compile/model.rs
|
||||
|
||||
cursor/interaction/mod.rs → cursor/protocol/events.rs
|
||||
cursor/interaction/query.rs → cursor/tools/codec/query.rs
|
||||
cursor/interaction/render.rs → cursor/tools/codec/render.rs
|
||||
|
||||
cursor/projection/decode.rs → cursor/checkpoint/messages/decode.rs
|
||||
cursor/projection/encode.rs → cursor/checkpoint/messages/encode.rs
|
||||
cursor/projection/tests.rs → cursor/checkpoint/messages/tests.rs
|
||||
|
||||
cursor/presentation.rs → cursor/checkpoint/steps.rs
|
||||
|
||||
run/runtime.rs RunRegistry → cursor/conversation/registry.rs
|
||||
run/runtime.rs RunActor → run/engine.rs + run/handle.rs
|
||||
run/port.rs → run/command.rs + run/event.rs + run/port.rs
|
||||
|
||||
store/revisions.rs → store/checkpoints.rs
|
||||
```
|
||||
|
||||
|
||||
## 最终核心
|
||||
@@ -719,4 +667,4 @@ Bidi
|
||||
→ Checkpoint
|
||||
→ Transport
|
||||
→ RunSSE
|
||||
```
|
||||
```
|
||||
|
||||
+2
-1
@@ -48,7 +48,7 @@ similar = "2"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
thiserror = "2"
|
||||
time = "0.3"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "net"] }
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time", "net"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
@@ -57,6 +57,7 @@ tower-http = { version = "0.6", features = ["cors", "decompression-gzip", "fs"]
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
x509-parser = "0.18"
|
||||
zip = { version = "4", default-features = false, features = ["deflate"] }
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
protoc-bin-vendored = "3"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
-- llm_calls 是历史记录:model_hash 现在既可指向内置 model_configs,
|
||||
-- 也可携带插件稳定模型 ID(plugin:<plugin>/<provider>/<model>)。
|
||||
-- 去掉指向 model_configs 的外键;SQLite 不支持删除约束,按整表重建执行。
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE llm_calls_new (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
provider_call_index INTEGER NOT NULL,
|
||||
model_hash TEXT,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_url TEXT NOT NULL,
|
||||
request_type TEXT NOT NULL,
|
||||
request_url TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
finish_reason TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
request_started_at_ms INTEGER,
|
||||
response_headers_at_ms INTEGER,
|
||||
first_event_at_ms INTEGER,
|
||||
first_text_at_ms INTEGER,
|
||||
finished_at_ms INTEGER,
|
||||
queue_ms INTEGER,
|
||||
ttfb_ms INTEGER,
|
||||
ttft_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
cache_read_tokens INTEGER,
|
||||
cache_write_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
usage_json TEXT,
|
||||
message_count INTEGER NOT NULL,
|
||||
tool_count INTEGER NOT NULL,
|
||||
request_bytes INTEGER,
|
||||
response_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
stream_event_count INTEGER NOT NULL DEFAULT 0,
|
||||
http_status INTEGER,
|
||||
error_kind TEXT,
|
||||
error_message TEXT,
|
||||
detailed INTEGER NOT NULL,
|
||||
reasoning_effort TEXT,
|
||||
fast INTEGER NOT NULL DEFAULT 0 CHECK (fast IN (0, 1)),
|
||||
first_valid_response_at_ms INTEGER,
|
||||
ttfr_ms INTEGER
|
||||
);
|
||||
|
||||
INSERT INTO llm_calls_new (
|
||||
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
|
||||
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
|
||||
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
|
||||
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
|
||||
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
|
||||
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
|
||||
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast,
|
||||
first_valid_response_at_ms, ttfr_ms
|
||||
)
|
||||
SELECT
|
||||
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
|
||||
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
|
||||
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
|
||||
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
|
||||
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
|
||||
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
|
||||
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast,
|
||||
first_valid_response_at_ms, ttfr_ms
|
||||
FROM llm_calls;
|
||||
|
||||
CREATE TABLE llm_call_requests_new (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
headers_json TEXT NOT NULL,
|
||||
body_json TEXT NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO llm_call_requests_new(call_id, headers_json, body_json, byte_count)
|
||||
SELECT call_id, headers_json, body_json, byte_count FROM llm_call_requests;
|
||||
|
||||
CREATE TABLE llm_call_response_chunks_new (
|
||||
call_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
received_offset_ms INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(call_id, seq),
|
||||
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO llm_call_response_chunks_new(call_id, seq, received_offset_ms, data, byte_count)
|
||||
SELECT call_id, seq, received_offset_ms, data, byte_count FROM llm_call_response_chunks;
|
||||
|
||||
DROP TABLE llm_call_requests;
|
||||
DROP TABLE llm_call_response_chunks;
|
||||
DROP TABLE llm_calls;
|
||||
|
||||
ALTER TABLE llm_calls_new RENAME TO llm_calls;
|
||||
ALTER TABLE llm_call_requests_new RENAME TO llm_call_requests;
|
||||
ALTER TABLE llm_call_response_chunks_new RENAME TO llm_call_response_chunks;
|
||||
|
||||
CREATE INDEX llm_calls_created ON llm_calls(created_at_ms DESC);
|
||||
CREATE INDEX llm_calls_run ON llm_calls(run_id, provider_call_index);
|
||||
CREATE INDEX llm_calls_model ON llm_calls(model_hash, created_at_ms DESC);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Custom provider-group display name shared by models with the same upstream host.
|
||||
-- NULL means no custom name; the UI falls back to the base_url hostname and the
|
||||
-- Cursor model picker badge falls back to the model type label.
|
||||
ALTER TABLE model_configs ADD COLUMN group_name TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tool_round_calls ADD COLUMN argument_error TEXT;
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,421 @@
|
||||
import type {
|
||||
JsonValue,
|
||||
NetworkEventStream,
|
||||
NetworkResponse,
|
||||
PluginContext,
|
||||
} from "cursor-byok:plugin";
|
||||
import type { LlmRequest, ModelEvent } from "cursor-byok:provider";
|
||||
import type { ResourceSnapshot } from "cursor-byok:resource";
|
||||
import { codexDeviceOAuth } from "./oauth.ts";
|
||||
import { parseOfficialModels } from "./models.ts";
|
||||
import { buildResponsesBody } from "cursor-byok:protocol/openai-responses";
|
||||
import { codexProvider, isQuotaError } from "./provider.ts";
|
||||
import {
|
||||
accountIdentity,
|
||||
credentialDraft,
|
||||
parseCodexUsage,
|
||||
parseCredentialFiles,
|
||||
presentAccount,
|
||||
quotaState,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const left = JSON.stringify(actual);
|
||||
const right = JSON.stringify(expected);
|
||||
if (left !== right) throw new Error(`expected ${right}, received ${left}`);
|
||||
}
|
||||
|
||||
function jwt(payload: Record<string, unknown>): string {
|
||||
const encoded = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(
|
||||
/\//g,
|
||||
"_",
|
||||
);
|
||||
return `header.${encoded}.signature`;
|
||||
}
|
||||
|
||||
type RequestInit = { body?: string; headers?: Record<string, string> };
|
||||
type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse;
|
||||
type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream;
|
||||
|
||||
function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext {
|
||||
return {
|
||||
network: {
|
||||
fetch: (url, init) => {
|
||||
if (!handlers.fetch) throw new Error("fetch was not expected");
|
||||
return Promise.resolve(handlers.fetch(url, init));
|
||||
},
|
||||
stream: (url, init) => {
|
||||
if (!handlers.stream) throw new Error("stream was not expected");
|
||||
return Promise.resolve(handlers.stream(url, init));
|
||||
},
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(privateData: JsonValue): ResourceSnapshot {
|
||||
return {
|
||||
id: "resource-1",
|
||||
type: RESOURCE_TYPE,
|
||||
key: "codex:acct-1",
|
||||
privateData,
|
||||
state: { status: "ready" },
|
||||
};
|
||||
}
|
||||
|
||||
async function* sse(lines: string[]): AsyncGenerator<string> {
|
||||
for (const line of lines) yield line;
|
||||
}
|
||||
|
||||
function request(): LlmRequest {
|
||||
return {
|
||||
instructions: "You are a coding assistant.",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
||||
tools: [],
|
||||
reasoning: { enabled: true, effort: "medium" },
|
||||
latency: "fast",
|
||||
maxOutputTokens: 128_000,
|
||||
cacheKey: "conversation-1",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("account identity prioritizes ChatGPT account ID and drafts keep tokens private-side", async () => {
|
||||
const token = jwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
|
||||
sub: "subject-1",
|
||||
email: "person@example.com",
|
||||
});
|
||||
assertEquals(await accountIdentity(token), {
|
||||
key: "codex:acct-1",
|
||||
displayName: "person@example.com",
|
||||
});
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
assertEquals(draft.key, "codex:acct-1");
|
||||
const view = presentAccount(snapshot(draft.privateData));
|
||||
assert(!JSON.stringify(view).includes(token), "resource view exposed an access token");
|
||||
assertEquals(view.displayName, "person@example.com");
|
||||
});
|
||||
|
||||
Deno.test("credential import accepts Codex auth JSON files", () => {
|
||||
const { credentials, warnings } = parseCredentialFiles([
|
||||
{
|
||||
name: "auth.json",
|
||||
content: JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "access-secret",
|
||||
refresh_token: "refresh-secret",
|
||||
id_token: jwt({ email: "person@example.com" }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ name: "broken.json", content: "{not json" },
|
||||
]);
|
||||
assertEquals(credentials, [{
|
||||
accessToken: "access-secret",
|
||||
refreshToken: "refresh-secret",
|
||||
displayName: "person@example.com",
|
||||
}]);
|
||||
assertEquals(warnings, ["broken.json: not valid JSON"]);
|
||||
});
|
||||
|
||||
Deno.test("usage maps secondary to weekly and primary to five-hour quota", () => {
|
||||
const quota = parseCodexUsage({
|
||||
plan_type: "plus",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 80, reset_at: 1_800_000_000 },
|
||||
secondary_window: { used_percent: 25, reset_at: 1_900_000_000 },
|
||||
},
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quota.planLabel, "ChatGPT Plus");
|
||||
assertEquals(quota.weekly?.remainingPercent, 75);
|
||||
assertEquals(quota.fiveHour?.remainingPercent, 20);
|
||||
assertEquals(quota.weekly?.resetAtMs, 1_900_000_000_000);
|
||||
assertEquals(quotaState(quota, 1_700_000_000_000), { status: "ready" });
|
||||
});
|
||||
|
||||
Deno.test("exhausted quota projects a cooling state until the latest reset", () => {
|
||||
const quota = parseCodexUsage({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 100, reset_at: 1_800_000_000 },
|
||||
secondary_window: { used_percent: 100, reset_at: 1_900_000_000 },
|
||||
},
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quotaState(quota, 1_700_000_000_000), {
|
||||
status: "cooling",
|
||||
retryAtMs: 1_900_000_000_000,
|
||||
message: "ChatGPT quota is exhausted",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("official model discovery excludes hidden models and puts the default first", () => {
|
||||
const models = parseOfficialModels({
|
||||
default_model: "gpt-second",
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-first",
|
||||
display_name: "GPT First",
|
||||
supported_in_api: true,
|
||||
visibility: "list",
|
||||
supported_reasoning_levels: [
|
||||
{ effort: "low", description: "Fast responses" },
|
||||
{ effort: "medium", description: "Balanced" },
|
||||
],
|
||||
},
|
||||
{ slug: "gpt-second", supported_in_api: true, visibility: "list" },
|
||||
{ slug: "gpt-hidden", supported_in_api: true, visibility: "hidden" },
|
||||
{ slug: "gpt-internal", supported_in_api: false, visibility: "list" },
|
||||
],
|
||||
});
|
||||
assertEquals(models.map((model) => model.id), ["gpt-second", "gpt-first"]);
|
||||
assertEquals(models[1].capabilities, { images: true });
|
||||
assertEquals(models[1].privateData, { reasoningEfforts: ["low", "medium"] });
|
||||
});
|
||||
|
||||
Deno.test("device OAuth begins with a host-held session and completes with a resource draft", async () => {
|
||||
const accessToken = jwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-oauth" },
|
||||
email: "oauth@example.com",
|
||||
});
|
||||
let requestNumber = 0;
|
||||
const flowContext = context({
|
||||
fetch: (url, init) => {
|
||||
requestNumber += 1;
|
||||
if (requestNumber === 1) {
|
||||
assertEquals(url, "https://auth.openai.com/api/accounts/deviceauth/usercode");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
device_auth_id: "private-device-id",
|
||||
user_code: "ABCD-EFGH",
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (requestNumber === 2) {
|
||||
assertEquals(url, "https://auth.openai.com/api/accounts/deviceauth/token");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
authorization_code: "authorization-code",
|
||||
code_verifier: "pkce-verifier",
|
||||
}),
|
||||
};
|
||||
}
|
||||
assertEquals(url, "https://auth.openai.com/oauth/token");
|
||||
assert(init?.body?.includes("grant_type=authorization_code"));
|
||||
assert(init?.body?.includes("code_verifier=pkce-verifier"));
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({ access_token: accessToken, refresh_token: "refresh-secret" }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const begun = await codexDeviceOAuth.begin(flowContext);
|
||||
assertEquals(begun.userCode, "ABCD-EFGH");
|
||||
assertEquals(begun.pollIntervalMs, 5000);
|
||||
|
||||
const polled = await codexDeviceOAuth.poll(begun.session, flowContext);
|
||||
assert(polled.status === "completed", `expected completed, received ${polled.status}`);
|
||||
assertEquals(polled.resources[0].key, "codex:acct-oauth");
|
||||
assertEquals(requestNumber, 3);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams normalized events from the Codex Responses API", async () => {
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
let requestBody = "";
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: {
|
||||
id: "gpt-test",
|
||||
displayName: "GPT Test",
|
||||
privateData: { reasoningEfforts: ["medium"] },
|
||||
},
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: (url, init) => {
|
||||
assertEquals(url, "https://chatgpt.com/backend-api/codex/responses");
|
||||
requestBody = init?.body ?? "";
|
||||
requestHeaders = init?.headers ?? {};
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"type":"response.output_text.delta","delta":"Hel"}',
|
||||
'data: {"type":"response.output_text.delta","delta":"lo"}',
|
||||
'data: {"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":2,"input_tokens_details":{"cached_tokens":4}}}}',
|
||||
]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
const body = JSON.parse(requestBody) as Record<string, unknown>;
|
||||
assertEquals(body.model, "gpt-test");
|
||||
assertEquals(body.store, false);
|
||||
assertEquals(body.reasoning, { summary: "auto", effort: "medium" });
|
||||
assertEquals(body.instructions, "You are a coding assistant.");
|
||||
assertEquals(body.include, ["reasoning.encrypted_content"]);
|
||||
assert(!("max_output_tokens" in body), "Codex endpoint rejects max_output_tokens");
|
||||
assertEquals(body.service_tier, "priority");
|
||||
assertEquals(body.prompt_cache_key, "conversation-1");
|
||||
// 缓存亲和头与 prompt_cache_key 同源。
|
||||
assertEquals(requestHeaders["session-id"], "conversation-1");
|
||||
assertEquals(requestHeaders["thread-id"], "conversation-1");
|
||||
assertEquals(requestHeaders["x-client-request-id"], "conversation-1");
|
||||
assertEquals(events, [
|
||||
{ type: "text-start" },
|
||||
{ type: "text-delta", text: "Hel" },
|
||||
{ type: "text-delta", text: "lo" },
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
totalTokens: null,
|
||||
cacheReadTokens: 4,
|
||||
cacheWriteTokens: null,
|
||||
reasoningTokens: null,
|
||||
},
|
||||
},
|
||||
{ type: "text-end" },
|
||||
{ type: "done", reason: "stop" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("reasoning replay projects response items to valid input items", () => {
|
||||
const replayRequest = request();
|
||||
replayRequest.messages = [{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
thinking: "",
|
||||
replayState: {
|
||||
providerKind: "openai_responses",
|
||||
value: {
|
||||
items: [{
|
||||
type: "reasoning",
|
||||
id: "item-1",
|
||||
status: "completed",
|
||||
summary: [{ type: "summary_text", text: "why" }],
|
||||
content: [],
|
||||
encrypted_content: "opaque",
|
||||
output_only: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
toolCalls: [],
|
||||
}];
|
||||
|
||||
const body = buildResponsesBody({
|
||||
url: "https://example.com/responses",
|
||||
model: "gpt-test",
|
||||
request: replayRequest,
|
||||
});
|
||||
assertEquals(body.input, [{
|
||||
type: "reasoning",
|
||||
id: "item-1",
|
||||
summary: [{ type: "summary_text", text: "why" }],
|
||||
content: [],
|
||||
encrypted_content: "opaque",
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams incremental tool calls and replays reasoning items", async () => {
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: { id: "gpt-test", displayName: "GPT Test" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read_file"}}',
|
||||
'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\\"path\\":"}',
|
||||
'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"\\"a.ts\\"}"}',
|
||||
'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read_file","arguments":"{\\"path\\":\\"a.ts\\"}"}}',
|
||||
'data: {"type":"response.output_item.done","output_index":1,"item":{"type":"reasoning","encrypted_content":"opaque"}}',
|
||||
'data: {"type":"response.completed","response":{}}',
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
assertEquals(events, [
|
||||
{ type: "tool-call-start", index: 0, callId: "call-1", name: "read_file" },
|
||||
{ type: "tool-call-arguments-delta", index: 0, delta: '{"path":' },
|
||||
{ type: "tool-call-arguments-delta", index: 0, delta: '"a.ts"}' },
|
||||
{ type: "tool-call-end", index: 0 },
|
||||
{
|
||||
type: "replay-state",
|
||||
providerKind: "openai_responses",
|
||||
value: { items: [{ type: "reasoning", encrypted_content: "opaque" }] },
|
||||
},
|
||||
{ type: "done", reason: "tool-use" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke maps quota failures to a cooling resource error", async () => {
|
||||
assert(!isQuotaError("429 rate_limit_reached"));
|
||||
assert(isQuotaError("429 usage_limit_reached: 5-hour limit"));
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: { id: "gpt-test", displayName: "GPT Test" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: () => {} },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 429,
|
||||
headers: {},
|
||||
lines: sse(['{"detail":"usage_limit_reached","reset_after_seconds":600}']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert(result.status === "resource-error", `expected resource-error, received ${result.status}`);
|
||||
assert(result.patch.state?.status === "cooling", "quota failure should cool the resource");
|
||||
assert(
|
||||
result.patch.state.retryAtMs !== undefined && result.patch.state.retryAtMs > Date.now(),
|
||||
"cooling should carry the parsed reset time",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"imports": {
|
||||
"cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
|
||||
"cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
|
||||
"cursor-byok:model": "../../../src/plugin/sdk/model.ts",
|
||||
"cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
|
||||
"cursor-byok:protocol/openai-responses": "../../../src/plugin/sdk/protocol/openai_responses.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
"exclude": ["assets"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { codexDeviceOAuth } from "./oauth.ts";
|
||||
import { codexProvider } from "./provider.ts";
|
||||
import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [codexProvider],
|
||||
resources: [{
|
||||
type: RESOURCE_TYPE,
|
||||
displayName: { "en-US": "ChatGPT accounts", "zh-CN": "ChatGPT 账号" },
|
||||
add: [codexDeviceOAuth],
|
||||
import: credentialImport,
|
||||
present: presentAccount,
|
||||
refresh: refreshAccount,
|
||||
}],
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { JsonValue } from "cursor-byok:plugin";
|
||||
import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model";
|
||||
import { accountData, accountHeaders } from "./resources.ts";
|
||||
|
||||
const MODELS_URL = "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0";
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number | null {
|
||||
const parsed = typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
|
||||
}
|
||||
|
||||
function parseReasoningEfforts(model: Record<string, unknown>): string[] {
|
||||
const source = model.supported_reasoning_levels ??
|
||||
model.supportedReasoningLevels ??
|
||||
model.reasoning_levels ??
|
||||
model.reasoningLevels ??
|
||||
model.supported_reasoning_efforts ??
|
||||
model.supportedReasoningEfforts ??
|
||||
model.reasoning_efforts ??
|
||||
model.reasoningEfforts;
|
||||
if (!Array.isArray(source)) return [];
|
||||
const values = source.flatMap((item) => {
|
||||
if (typeof item === "string") return [item.trim()];
|
||||
const entry = object(item);
|
||||
const value = text(entry?.effort ?? entry?.id ?? entry?.value ?? entry?.name);
|
||||
return value ? [value] : [];
|
||||
}).filter(Boolean);
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function modelId(value: unknown): string | null {
|
||||
if (typeof value === "string") return text(value);
|
||||
const model = object(value);
|
||||
return model ? text(model.slug ?? model.id ?? model.model ?? model.name) : null;
|
||||
}
|
||||
|
||||
export function parseOfficialModels(body: unknown): ModelDefinition[] {
|
||||
const root = object(body);
|
||||
const source = root?.models ?? root?.data ?? body;
|
||||
if (!Array.isArray(source)) {
|
||||
throw new Error("Codex model discovery response does not contain a model list");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const models: ModelDefinition[] = [];
|
||||
for (const raw of source) {
|
||||
const model = object(raw);
|
||||
if (!model || model.supported_in_api === false || model.supportedInApi === false) continue;
|
||||
if (text(model.visibility)?.toLowerCase() === "hidden") continue;
|
||||
const id = modelId(model);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const efforts = parseReasoningEfforts(model);
|
||||
const description = text(model.description);
|
||||
const maxOutputTokens = positiveInteger(
|
||||
model.max_output_tokens ?? model.maxOutputTokens ?? model.max_completion_tokens ??
|
||||
model.maxCompletionTokens,
|
||||
);
|
||||
models.push({
|
||||
id,
|
||||
displayName: text(model.display_name ?? model.displayName ?? model.title ?? model.name) ??
|
||||
id,
|
||||
...(description ? { description } : {}),
|
||||
...(maxOutputTokens !== null ? { maxOutputTokens } : {}),
|
||||
capabilities: { images: true },
|
||||
privateData: { reasoningEfforts: efforts },
|
||||
});
|
||||
}
|
||||
const defaultModel = modelId(
|
||||
root?.default_model ??
|
||||
root?.defaultModel ??
|
||||
root?.default_model_slug ??
|
||||
root?.defaultModelSlug ??
|
||||
root?.primary_model ??
|
||||
root?.primaryModel,
|
||||
);
|
||||
// 把上游默认模型排在最前,让宿主自然选中它。
|
||||
if (defaultModel) {
|
||||
models.sort((left, right) =>
|
||||
Number(right.id === defaultModel) - Number(left.id === defaultModel)
|
||||
);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
export function reasoningEfforts(model: ModelSnapshot): string[] {
|
||||
const data = object(model.privateData);
|
||||
const efforts = data?.reasoningEfforts;
|
||||
return Array.isArray(efforts) ? efforts.filter((item) => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export const codexModels: ModelSupport = {
|
||||
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
|
||||
if (!resource) throw new Error("add a ChatGPT account before syncing Codex models");
|
||||
const data = accountData(resource);
|
||||
const response = await context.network.fetch(MODELS_URL, {
|
||||
method: "GET",
|
||||
headers: accountHeaders(data),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Codex model discovery failed (HTTP ${response.status}): ${response.body}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body) as JsonValue;
|
||||
} catch {
|
||||
throw new Error("Codex model discovery returned invalid JSON");
|
||||
}
|
||||
const models = parseOfficialModels(body);
|
||||
if (models.length === 0) {
|
||||
throw new Error("Codex model discovery returned no supported models");
|
||||
}
|
||||
return models;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
|
||||
import { type CredentialCandidate, credentialDraft } from "./resources.ts";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const DEVICE_CODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
|
||||
const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
|
||||
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
||||
const REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
|
||||
const VERIFICATION_URI = "https://auth.openai.com/codex/device";
|
||||
|
||||
type Session = {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBody(body: string): Record<string, unknown> {
|
||||
try {
|
||||
return object(JSON.parse(body)) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSession(value: JsonValue): Session {
|
||||
const session = object(value);
|
||||
const deviceAuthId = text(session?.deviceAuthId);
|
||||
const userCode = text(session?.userCode);
|
||||
if (!deviceAuthId || !userCode) throw new Error("Codex OAuth session is invalid");
|
||||
return { deviceAuthId, userCode };
|
||||
}
|
||||
|
||||
function errorCode(body: Record<string, unknown>): string {
|
||||
const error = body.error;
|
||||
if (typeof error === "string") return error;
|
||||
const nested = object(error);
|
||||
return text(nested?.code ?? nested?.type ?? body.status ?? body.state) ?? "";
|
||||
}
|
||||
|
||||
function errorMessage(body: Record<string, unknown>): string | null {
|
||||
const error = object(body.error);
|
||||
return text(body.error_description ?? body.message ?? error?.message);
|
||||
}
|
||||
|
||||
function pendingMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return lower.includes("authorization is pending") ||
|
||||
lower.includes("authorization_pending") ||
|
||||
lower.includes("device authorization is pending");
|
||||
}
|
||||
|
||||
async function begin(context: PluginContext): Promise<OAuth2Begin> {
|
||||
const response = await context.network.fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ client_id: CLIENT_ID }),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Failed to request OpenAI Codex device code (HTTP ${response.status}): ${response.body}`,
|
||||
);
|
||||
}
|
||||
const deviceAuthId = text(body.device_auth_id ?? body.device_code);
|
||||
const userCode = text(body.user_code ?? body.usercode);
|
||||
if (!deviceAuthId || !userCode) {
|
||||
throw new Error("OpenAI Codex device authorization response is incomplete");
|
||||
}
|
||||
const session: Session = { deviceAuthId, userCode };
|
||||
return {
|
||||
session: session as unknown as JsonValue,
|
||||
userCode,
|
||||
verificationUrl: VERIFICATION_URI,
|
||||
verificationUrlComplete: VERIFICATION_URI,
|
||||
expiresAtMs: Date.now() + Math.max(1, number(body.expires_in) ?? 900) * 1000,
|
||||
pollIntervalMs: Math.max(1, number(body.interval) ?? 5) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
context: PluginContext,
|
||||
authorizationCode: string,
|
||||
codeVerifier: string,
|
||||
): Promise<CredentialCandidate> {
|
||||
const response = await context.network.fetch(OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: authorizationCode,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: codeVerifier,
|
||||
}).toString(),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
const accessToken = text(body.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
errorMessage(body) ?? `Failed to exchange Codex authorization code (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
return { accessToken, refreshToken: text(body.refresh_token), displayName: null };
|
||||
}
|
||||
|
||||
async function completed(credential: CredentialCandidate): Promise<OAuth2Poll> {
|
||||
return { status: "completed", resources: [await credentialDraft(credential)] };
|
||||
}
|
||||
|
||||
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
|
||||
const session = parseSession(sessionValue);
|
||||
const response = await context.network.fetch(DEVICE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
device_auth_id: session.deviceAuthId,
|
||||
user_code: session.userCode,
|
||||
}),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
// 该端点用 403/404 表示"尚未完成授权"。
|
||||
if (response.status === 403 || response.status === 404) return { status: "pending" };
|
||||
|
||||
const code = errorCode(body);
|
||||
const message = errorMessage(body);
|
||||
if (
|
||||
["authorization_pending", "pending", "waiting", "in_progress", "device_authorization_pending"]
|
||||
.includes(code) ||
|
||||
(message !== null && pendingMessage(message))
|
||||
) {
|
||||
return { status: "pending" };
|
||||
}
|
||||
if (code === "slow_down") return { status: "slow-down" };
|
||||
if (code === "expired_token" || code === "expired") {
|
||||
return { status: "failed", message: message ?? "Device authorization code expired" };
|
||||
}
|
||||
if (code === "access_denied" || code === "denied") {
|
||||
return { status: "denied", message: message ?? undefined };
|
||||
}
|
||||
|
||||
const directToken = text(body.access_token);
|
||||
if (directToken) {
|
||||
return await completed({
|
||||
accessToken: directToken,
|
||||
refreshToken: text(body.refresh_token),
|
||||
displayName: null,
|
||||
});
|
||||
}
|
||||
|
||||
const authorizationCode = text(body.authorization_code);
|
||||
const codeVerifier = text(body.code_verifier);
|
||||
if (response.status >= 200 && response.status < 300 && authorizationCode && codeVerifier) {
|
||||
try {
|
||||
return await completed(
|
||||
await exchangeAuthorizationCode(context, authorizationCode, codeVerifier),
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!code && body.error === undefined && response.status >= 400) return { status: "pending" };
|
||||
return {
|
||||
status: "failed",
|
||||
message: message ??
|
||||
(code
|
||||
? `OAuth error: ${code}`
|
||||
: `Codex device authorization failed (HTTP ${response.status})`),
|
||||
};
|
||||
}
|
||||
|
||||
export const codexDeviceOAuth: OAuth2AddMethod = {
|
||||
type: "oauth2.0",
|
||||
id: "chatgpt-device",
|
||||
displayName: {
|
||||
"en-US": "Sign in with ChatGPT",
|
||||
"zh-CN": "使用 ChatGPT 登录",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Authorize this device with OpenAI, then add the resulting ChatGPT account.",
|
||||
"zh-CN": "在 OpenAI 完成设备授权后,自动添加对应的 ChatGPT 账号。",
|
||||
},
|
||||
begin,
|
||||
poll,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "dev.cursorbyok.examples.codex-auth",
|
||||
"name": "Codex",
|
||||
"version": "0.1.0",
|
||||
"author": "@leookun",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/codex.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": [
|
||||
"auth.openai.com",
|
||||
"chatgpt.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
ProviderInvokeInput,
|
||||
ProviderOutput,
|
||||
ProviderResult,
|
||||
ProviderSupport,
|
||||
} from "cursor-byok:provider";
|
||||
import type { PluginContext } from "cursor-byok:plugin";
|
||||
import { HttpError, streamOpenAiResponses } from "cursor-byok:protocol/openai-responses";
|
||||
import { codexModels, reasoningEfforts } from "./models.ts";
|
||||
import {
|
||||
type AccountData,
|
||||
accountData,
|
||||
chatGptAccountId,
|
||||
quotaExhaustedPatch,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
|
||||
const RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
/** 流内错误只有文本可用,按额度关键词分类。 */
|
||||
export function isQuotaError(error: string): boolean {
|
||||
const message = error.toLowerCase();
|
||||
return message.includes("insufficient_quota") ||
|
||||
message.includes("usage_limit_reached") ||
|
||||
message.includes("exceeded your current quota") ||
|
||||
message.includes("quota_exceeded") ||
|
||||
message.includes("5-hour") ||
|
||||
message.includes("5 hour") ||
|
||||
(message.includes("429") &&
|
||||
(message.includes("quota") || message.includes("usage_limit") ||
|
||||
message.includes("insufficient")));
|
||||
}
|
||||
|
||||
/** HTTP 失败携带结构化状态码,429 时放宽响应体的匹配条件。 */
|
||||
function isQuotaHttpError(error: HttpError): boolean {
|
||||
const body = error.body.toLowerCase();
|
||||
return body.includes("insufficient_quota") ||
|
||||
body.includes("usage_limit_reached") ||
|
||||
body.includes("exceeded your current quota") ||
|
||||
body.includes("quota_exceeded") ||
|
||||
body.includes("5-hour") ||
|
||||
body.includes("5 hour") ||
|
||||
(error.status === 429 &&
|
||||
(body.includes("quota") || body.includes("usage_limit") || body.includes("insufficient")));
|
||||
}
|
||||
|
||||
function invalidResult(message: string, stateMessage: string): ProviderResult {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message,
|
||||
patch: { state: { status: "invalid", message: stateMessage } },
|
||||
};
|
||||
}
|
||||
|
||||
function headers(data: AccountData, cacheKey: string | null): Record<string, string> {
|
||||
const result: Record<string, string> = {
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
originator: "codex_cli_rs",
|
||||
};
|
||||
const accountId = chatGptAccountId(data.accessToken);
|
||||
if (accountId) result["ChatGPT-Account-Id"] = accountId;
|
||||
// Codex 后端的缓存亲和契约:session-id / thread-id / prompt_cache_key
|
||||
// 三者同源(见 codex-rs client.rs);缺头会导致请求落在随机分片上。
|
||||
if (cacheKey !== null) {
|
||||
result["session-id"] = cacheKey;
|
||||
result["thread-id"] = cacheKey;
|
||||
result["x-client-request-id"] = cacheKey;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
input: ProviderInvokeInput,
|
||||
output: ProviderOutput,
|
||||
context: PluginContext,
|
||||
): Promise<ProviderResult> {
|
||||
if (!input.resource) {
|
||||
return { status: "request-error", message: "add a ChatGPT account before calling Codex" };
|
||||
}
|
||||
let data: AccountData;
|
||||
try {
|
||||
data = accountData(input.resource);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return invalidResult(message, message);
|
||||
}
|
||||
const efforts = reasoningEfforts(input.model);
|
||||
const reasoning = input.request.reasoning;
|
||||
const effort = reasoning.effort !== null && efforts.includes(reasoning.effort)
|
||||
? reasoning.effort
|
||||
: null;
|
||||
try {
|
||||
await streamOpenAiResponses(
|
||||
{
|
||||
url: RESPONSES_URL,
|
||||
model: input.model.id,
|
||||
// Codex 订阅端点不接受 max_output_tokens;fast 档位经协议库映射为
|
||||
// service_tier: "priority" 后透传。
|
||||
request: {
|
||||
...input.request,
|
||||
reasoning: { enabled: reasoning.enabled, effort },
|
||||
maxOutputTokens: null,
|
||||
},
|
||||
headers: headers(data, input.request.cacheKey),
|
||||
extraBody: { store: false },
|
||||
},
|
||||
output,
|
||||
context,
|
||||
);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.status === 401) {
|
||||
return invalidResult(error.message, "ChatGPT authorization expired; sign in again");
|
||||
}
|
||||
if (isQuotaHttpError(error)) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: quotaExhaustedPatch(data, error.body),
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: error.message };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isQuotaError(message)) {
|
||||
return { status: "resource-error", message, patch: quotaExhaustedPatch(data, message) };
|
||||
}
|
||||
return { status: "request-error", message };
|
||||
}
|
||||
}
|
||||
|
||||
export const codexProvider: ProviderSupport = {
|
||||
id: "codex",
|
||||
displayName: "OpenAI Codex",
|
||||
description: {
|
||||
"en-US": "ChatGPT subscription access through the official Codex Responses API.",
|
||||
"zh-CN": "通过官方 Codex Responses API 使用 ChatGPT 订阅。",
|
||||
},
|
||||
providerType: "openai",
|
||||
resourceType: RESOURCE_TYPE,
|
||||
models: codexModels,
|
||||
invoke,
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type {
|
||||
ResourceDraft,
|
||||
ResourceImportFile,
|
||||
ResourceImportResult,
|
||||
ResourceImportSupport,
|
||||
ResourceMetric,
|
||||
ResourcePatch,
|
||||
ResourceSnapshot,
|
||||
ResourceState,
|
||||
ResourceView,
|
||||
} from "cursor-byok:resource";
|
||||
|
||||
export const RESOURCE_TYPE = "chatgpt-account";
|
||||
|
||||
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
||||
|
||||
export type QuotaWindow = {
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
resetAtMs: number | null;
|
||||
};
|
||||
|
||||
export type AccountQuota = {
|
||||
planLabel: string | null;
|
||||
weekly: QuotaWindow | null;
|
||||
fiveHour: QuotaWindow | null;
|
||||
limitReached: boolean;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
/** 单条 chatgpt-account 资源的 privateData 形状。 */
|
||||
export type AccountData = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string;
|
||||
quota: AccountQuota | null;
|
||||
};
|
||||
|
||||
export type CredentialCandidate = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const encoded = token.split(".")[1];
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||
return object(JSON.parse(new TextDecoder().decode(bytes)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claim(payload: Record<string, unknown> | null, key: string): string | null {
|
||||
return payload ? text(payload[key]) : null;
|
||||
}
|
||||
|
||||
export function chatGptAccountId(accessToken: string): string | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const auth = object(payload?.["https://api.openai.com/auth"]);
|
||||
return text(auth?.chatgpt_account_id) ?? claim(payload, "chatgpt_account_id");
|
||||
}
|
||||
|
||||
async function tokenFingerprint(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
||||
return Array.from(
|
||||
new Uint8Array(digest).slice(0, 8),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
/** ChatGPT access token 的邮箱通常在 OpenAI 的 profile 声明里,而不是顶层 email。 */
|
||||
function profileEmail(payload: Record<string, unknown> | null): string | null {
|
||||
const profile = object(payload?.["https://api.openai.com/profile"]);
|
||||
return text(profile?.email);
|
||||
}
|
||||
|
||||
export async function accountIdentity(
|
||||
accessToken: string,
|
||||
): Promise<{ key: string; displayName: string }> {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const identity = chatGptAccountId(accessToken) ??
|
||||
claim(payload, "sub") ??
|
||||
claim(payload, "email") ??
|
||||
await tokenFingerprint(accessToken);
|
||||
const displayName = claim(payload, "email") ??
|
||||
profileEmail(payload) ??
|
||||
claim(payload, "preferred_username") ??
|
||||
claim(payload, "name") ??
|
||||
identity;
|
||||
return { key: `codex:${identity}`, displayName };
|
||||
}
|
||||
|
||||
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
|
||||
const identity = await accountIdentity(credential.accessToken);
|
||||
const data: AccountData = {
|
||||
accessToken: credential.accessToken,
|
||||
refreshToken: credential.refreshToken,
|
||||
displayName: credential.displayName ?? identity.displayName,
|
||||
quota: null,
|
||||
};
|
||||
return { key: identity.key, privateData: data as unknown as JsonValue };
|
||||
}
|
||||
|
||||
export function accountData(resource: ResourceSnapshot): AccountData {
|
||||
const data = object(resource.privateData);
|
||||
const accessToken = text(data?.accessToken);
|
||||
if (!accessToken) throw new Error("ChatGPT account resource is missing its access token");
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: text(data?.refreshToken),
|
||||
displayName: text(data?.displayName) ?? "ChatGPT account",
|
||||
quota: (data?.quota ?? null) as AccountQuota | null,
|
||||
};
|
||||
}
|
||||
|
||||
export function accountHeaders(data: AccountData): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
originator: "codex_cli_rs",
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
};
|
||||
const accountId = chatGptAccountId(data.accessToken);
|
||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function resetAtMs(window: Record<string, unknown>, nowMs: number): number | null {
|
||||
const resetAt = window.reset_at ?? window.resetAt;
|
||||
const numeric = number(resetAt);
|
||||
if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
if (typeof resetAt === "string") {
|
||||
const parsed = Date.parse(resetAt);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
const afterSeconds = number(window.reset_after_seconds ?? window.resetAfterSeconds);
|
||||
return afterSeconds === null ? null : nowMs + afterSeconds * 1000;
|
||||
}
|
||||
|
||||
function quotaWindow(value: unknown, nowMs: number): QuotaWindow | null {
|
||||
const window = object(value);
|
||||
if (!window) return null;
|
||||
const used = number(window.used_percent ?? window.usedPercent);
|
||||
const remaining = used === null
|
||||
? number(window.remaining_percent ?? window.remainingPercent)
|
||||
: clampPercent(100 - used);
|
||||
return {
|
||||
usedPercent: used === null
|
||||
? (remaining === null ? null : clampPercent(100 - remaining))
|
||||
: clampPercent(used),
|
||||
remainingPercent: remaining === null ? null : clampPercent(remaining),
|
||||
resetAtMs: resetAtMs(window, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
function planLabel(value: unknown): string | null {
|
||||
const plan = text(value);
|
||||
if (!plan) return null;
|
||||
const labels: Record<string, string> = {
|
||||
plus: "ChatGPT Plus",
|
||||
pro: "ChatGPT Pro",
|
||||
team: "ChatGPT Team",
|
||||
business: "ChatGPT Business",
|
||||
enterprise: "ChatGPT Enterprise",
|
||||
free: "ChatGPT Free",
|
||||
go: "ChatGPT Go",
|
||||
};
|
||||
return labels[plan.toLowerCase()] ?? plan;
|
||||
}
|
||||
|
||||
export function parseCodexUsage(body: unknown, nowMs = Date.now()): AccountQuota {
|
||||
const root = object(body) ?? {};
|
||||
const rateLimit = object(root.rate_limit ?? root.rateLimit) ?? root;
|
||||
const primary = rateLimit.primary_window ?? rateLimit.primaryWindow;
|
||||
const secondary = rateLimit.secondary_window ?? rateLimit.secondaryWindow;
|
||||
const weekly = quotaWindow(secondary ?? primary, nowMs);
|
||||
const fiveHour = secondary === undefined || secondary === null
|
||||
? null
|
||||
: quotaWindow(primary, nowMs);
|
||||
const explicitLimit = rateLimit.limit_reached ?? rateLimit.limitReached;
|
||||
return {
|
||||
planLabel: planLabel(root.plan_type ?? root.planType),
|
||||
weekly,
|
||||
fiveHour,
|
||||
limitReached: typeof explicitLimit === "boolean" ? explicitLimit : [weekly, fiveHour].some(
|
||||
(window) => window?.remainingPercent !== null && window?.remainingPercent === 0,
|
||||
),
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
function windowCoolingUntil(window: QuotaWindow | null, nowMs: number): number | null {
|
||||
if (!window || window.remainingPercent === null || window.remainingPercent > 0) return null;
|
||||
if (window.resetAtMs !== null && window.resetAtMs <= nowMs) return null;
|
||||
return window.resetAtMs ?? nowMs + FIVE_HOURS_MS;
|
||||
}
|
||||
|
||||
export function quotaCoolingUntil(quota: AccountQuota, nowMs = Date.now()): number | null {
|
||||
const resets = [
|
||||
windowCoolingUntil(quota.weekly, nowMs),
|
||||
windowCoolingUntil(quota.fiveHour, nowMs),
|
||||
].filter((value): value is number => value !== null);
|
||||
if (resets.length > 0) return Math.max(...resets);
|
||||
return quota.limitReached ? nowMs + FIVE_HOURS_MS : null;
|
||||
}
|
||||
|
||||
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
|
||||
if (!quota) return { status: "ready" };
|
||||
const coolingUntil = quotaCoolingUntil(quota, nowMs);
|
||||
return coolingUntil === null
|
||||
? { status: "ready" }
|
||||
: { status: "cooling", retryAtMs: coolingUntil, message: "ChatGPT quota is exhausted" };
|
||||
}
|
||||
|
||||
/** 从上游错误文本中提取重置时间;拿不到时回退 5 小时。 */
|
||||
function resetFromError(error: string, nowMs: number): number {
|
||||
const resetAt = error.match(/["']?reset_at["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)?.[1];
|
||||
if (resetAt) {
|
||||
const value = Number(resetAt);
|
||||
if (Number.isFinite(value)) return value > 10_000_000_000 ? value : value * 1000;
|
||||
}
|
||||
const resetAfter = error.match(/["']?reset_after_seconds["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)
|
||||
?.[1];
|
||||
if (resetAfter) {
|
||||
const value = Number(resetAfter);
|
||||
if (Number.isFinite(value)) return nowMs + value * 1000;
|
||||
}
|
||||
return nowMs + FIVE_HOURS_MS;
|
||||
}
|
||||
|
||||
/** 额度耗尽时的资源补丁:标记 5 小时窗口耗尽并按重置时间进入冷却。 */
|
||||
export function quotaExhaustedPatch(
|
||||
data: AccountData,
|
||||
error: string,
|
||||
nowMs = Date.now(),
|
||||
): ResourcePatch {
|
||||
const quota: AccountQuota = {
|
||||
planLabel: data.quota?.planLabel ?? null,
|
||||
weekly: data.quota?.weekly ?? null,
|
||||
fiveHour: {
|
||||
usedPercent: 100,
|
||||
remainingPercent: 0,
|
||||
resetAtMs: resetFromError(error, nowMs),
|
||||
},
|
||||
limitReached: true,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function presentAccount(resource: ResourceSnapshot): ResourceView {
|
||||
const data = accountData(resource);
|
||||
const metrics: ResourceMetric[] = [];
|
||||
const weekly = data.quota?.weekly;
|
||||
if (weekly && weekly.remainingPercent !== null) {
|
||||
metrics.push({
|
||||
id: "weekly",
|
||||
label: { "en-US": "Weekly quota", "zh-CN": "周额度" },
|
||||
unit: "percent",
|
||||
value: weekly.remainingPercent,
|
||||
...(weekly.resetAtMs !== null ? { resetAtMs: weekly.resetAtMs } : {}),
|
||||
});
|
||||
}
|
||||
const fiveHour = data.quota?.fiveHour;
|
||||
if (fiveHour && fiveHour.remainingPercent !== null) {
|
||||
metrics.push({
|
||||
id: "five-hour",
|
||||
label: { "en-US": "5-hour window", "zh-CN": "5 小时窗口" },
|
||||
unit: "percent",
|
||||
value: fiveHour.remainingPercent,
|
||||
...(fiveHour.resetAtMs !== null ? { resetAtMs: fiveHour.resetAtMs } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
// 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。
|
||||
displayName: jwtDisplayName(data.accessToken) ?? data.displayName,
|
||||
...(data.quota?.planLabel ? { description: data.quota.planLabel } : {}),
|
||||
...(metrics.length > 0 ? { metrics } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccount(
|
||||
resource: ResourceSnapshot,
|
||||
context: PluginContext,
|
||||
): Promise<ResourcePatch> {
|
||||
const data = accountData(resource);
|
||||
const response = await context.network.fetch(USAGE_URL, {
|
||||
method: "GET",
|
||||
headers: accountHeaders(data),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
state: { status: "invalid", message: "ChatGPT authorization expired; sign in again" },
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex usage lookup failed (HTTP ${response.status}): ${response.body}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body);
|
||||
} catch {
|
||||
throw new Error("Codex usage lookup returned invalid JSON");
|
||||
}
|
||||
const quota = parseCodexUsage(body);
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota),
|
||||
};
|
||||
}
|
||||
|
||||
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = text(source[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jwtDisplayName(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const payload = decodeJwtPayload(token);
|
||||
return claim(payload, "email") ?? profileEmail(payload) ??
|
||||
claim(payload, "preferred_username") ?? claim(payload, "name");
|
||||
}
|
||||
|
||||
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectCredentials(item, output);
|
||||
return;
|
||||
}
|
||||
const item = object(value);
|
||||
if (!item || item.disabled === true) return;
|
||||
for (const key of ["accounts", "credentials", "items"]) {
|
||||
if (Array.isArray(item[key])) {
|
||||
collectCredentials(item[key], output);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const tokens = object(item.tokens) ?? item;
|
||||
const accessToken = firstText(tokens, ["access_token", "accessToken", "token", "key"]) ??
|
||||
firstText(item, ["access_token", "accessToken", "token", "key", "OPENAI_API_KEY"]);
|
||||
if (!accessToken) return;
|
||||
const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ??
|
||||
firstText(item, ["refresh_token", "refreshToken"]);
|
||||
const idToken = firstText(tokens, ["id_token", "idToken"]) ??
|
||||
firstText(item, ["id_token", "idToken"]);
|
||||
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
|
||||
firstText(tokens, ["email", "display_name", "displayName", "name"]) ??
|
||||
jwtDisplayName(idToken);
|
||||
output.push({ accessToken, refreshToken, displayName });
|
||||
}
|
||||
|
||||
export function parseCredentialFiles(files: ResourceImportFile[]): {
|
||||
credentials: CredentialCandidate[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const credentials: CredentialCandidate[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const file of files) {
|
||||
let content: unknown;
|
||||
try {
|
||||
content = JSON.parse(file.content);
|
||||
} catch {
|
||||
warnings.push(`${file.name}: not valid JSON`);
|
||||
continue;
|
||||
}
|
||||
const found: CredentialCandidate[] = [];
|
||||
collectCredentials(content, found);
|
||||
if (found.length === 0) {
|
||||
warnings.push(`${file.name}: no ChatGPT access token found`);
|
||||
continue;
|
||||
}
|
||||
credentials.push(...found);
|
||||
}
|
||||
return { credentials, warnings };
|
||||
}
|
||||
|
||||
export const credentialImport: ResourceImportSupport = {
|
||||
displayName: {
|
||||
"en-US": "Import Codex credentials",
|
||||
"zh-CN": "导入 Codex 凭证",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Import one or more Codex JSON credential files.",
|
||||
"zh-CN": "导入一个或多个 Codex JSON 凭证文件。",
|
||||
},
|
||||
accept: [".json"],
|
||||
multiple: true,
|
||||
parse: async (files: ResourceImportFile[]): Promise<ResourceImportResult> => {
|
||||
const { credentials, warnings } = parseCredentialFiles(files);
|
||||
if (credentials.length === 0) {
|
||||
throw new Error(warnings.join("; ") || "credential JSON does not contain an access token");
|
||||
}
|
||||
return {
|
||||
resources: await Promise.all(credentials.map(credentialDraft)),
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="230" height="230" viewBox="0 0 230 230" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>grok</title>
|
||||
<rect x="22" y="21" width="187" height="187" rx="42" fill="#000000"/>
|
||||
<g fill="#FFFFFF">
|
||||
<path d="M96.5 137.5 L152 82 L166 96 L110.5 151.5 Z"/>
|
||||
<path d="M64 82 L106 124 L92 138 L64 110 Z"/>
|
||||
<path d="M166 148 L166 96 L152 110 L152 148 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"imports": {
|
||||
"cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
|
||||
"cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
|
||||
"cursor-byok:model": "../../../src/plugin/sdk/model.ts",
|
||||
"cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
|
||||
"cursor-byok:protocol/openai-chat": "../../../src/plugin/sdk/protocol/openai_chat.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
"exclude": ["assets"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import type {
|
||||
JsonValue,
|
||||
NetworkEventStream,
|
||||
NetworkResponse,
|
||||
PluginContext,
|
||||
} from "cursor-byok:plugin";
|
||||
import type { LlmRequest, ModelEvent } from "cursor-byok:provider";
|
||||
import type { ResourceSnapshot } from "cursor-byok:resource";
|
||||
import { grokDeviceOAuth } from "./oauth.ts";
|
||||
import { FALLBACK_MODELS, grokModels, parseGrokModels } from "./models.ts";
|
||||
import { grokProvider, isQuotaError } from "./provider.ts";
|
||||
import {
|
||||
accountIdentity,
|
||||
credentialDraft,
|
||||
parseCredentialFiles,
|
||||
parseGrokUsage,
|
||||
presentAccount,
|
||||
quotaState,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const left = JSON.stringify(actual);
|
||||
const right = JSON.stringify(expected);
|
||||
if (left !== right) throw new Error(`expected ${right}, received ${left}`);
|
||||
}
|
||||
|
||||
function jwt(payload: Record<string, unknown>): string {
|
||||
const encoded = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(
|
||||
/\//g,
|
||||
"_",
|
||||
);
|
||||
return `header.${encoded}.signature`;
|
||||
}
|
||||
|
||||
type RequestInit = { body?: string; headers?: Record<string, string> };
|
||||
type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse;
|
||||
type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream;
|
||||
|
||||
function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext {
|
||||
return {
|
||||
network: {
|
||||
fetch: (url, init) => {
|
||||
if (!handlers.fetch) throw new Error("fetch was not expected");
|
||||
return Promise.resolve(handlers.fetch(url, init));
|
||||
},
|
||||
stream: (url, init) => {
|
||||
if (!handlers.stream) throw new Error("stream was not expected");
|
||||
return Promise.resolve(handlers.stream(url, init));
|
||||
},
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(privateData: JsonValue): ResourceSnapshot {
|
||||
return {
|
||||
id: "resource-1",
|
||||
type: RESOURCE_TYPE,
|
||||
key: "grok:user-1",
|
||||
privateData,
|
||||
state: { status: "ready" },
|
||||
};
|
||||
}
|
||||
|
||||
async function* sse(lines: string[]): AsyncGenerator<string> {
|
||||
for (const line of lines) yield line;
|
||||
}
|
||||
|
||||
function request(): LlmRequest {
|
||||
return {
|
||||
instructions: "You are a coding assistant.",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
||||
tools: [],
|
||||
reasoning: { enabled: true, effort: "medium" },
|
||||
latency: "fast",
|
||||
maxOutputTokens: 32_000,
|
||||
cacheKey: "conversation-1",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("account identity uses the JWT subject and drafts keep tokens private-side", async () => {
|
||||
const token = jwt({ sub: "user-1", email: "person@x.ai" });
|
||||
assertEquals(await accountIdentity(token), {
|
||||
key: "grok:user-1",
|
||||
displayName: "person@x.ai",
|
||||
});
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
assertEquals(draft.key, "grok:user-1");
|
||||
const view = presentAccount(snapshot(draft.privateData));
|
||||
assert(!JSON.stringify(view).includes(token), "resource view exposed an access token");
|
||||
assertEquals(view.displayName, "person@x.ai");
|
||||
});
|
||||
|
||||
Deno.test("credential import accepts Grok credential JSON files", () => {
|
||||
const { credentials, warnings } = parseCredentialFiles([
|
||||
{
|
||||
name: "accounts.json",
|
||||
content: JSON.stringify({
|
||||
accounts: [
|
||||
{ access_token: "token-1", refresh_token: "refresh-1", email: "a@x.ai" },
|
||||
{ access_token: "token-2", disabled: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ name: "broken.json", content: "{not json" },
|
||||
]);
|
||||
assertEquals(credentials, [{
|
||||
accessToken: "token-1",
|
||||
refreshToken: "refresh-1",
|
||||
displayName: "a@x.ai",
|
||||
}]);
|
||||
assertEquals(warnings, ["broken.json: not valid JSON"]);
|
||||
});
|
||||
|
||||
Deno.test("credit usage percent is inverted to remaining and drives cooling", () => {
|
||||
const quota = parseGrokUsage({
|
||||
config: {
|
||||
creditUsagePercent: 34,
|
||||
subscriptionTierDisplay: "SuperGrok",
|
||||
currentPeriod: { end: "2026-09-01T00:00:00Z" },
|
||||
},
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quota.planLabel, "SuperGrok");
|
||||
assertEquals(quota.remainingPercent, 66);
|
||||
assertEquals(quota.resetAtMs, Date.parse("2026-09-01T00:00:00Z"));
|
||||
assertEquals(quotaState(quota, 1_700_000_000_000), { status: "ready" });
|
||||
|
||||
const exhausted = parseGrokUsage({
|
||||
config: { creditUsagePercent: 100, currentPeriod: { end: 1_900_000_000 } },
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quotaState(exhausted, 1_700_000_000_000), {
|
||||
status: "cooling",
|
||||
retryAtMs: 1_900_000_000_000,
|
||||
message: "Grok credits are exhausted",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("missing usage with a billing period counts as unused", () => {
|
||||
const quota = parseGrokUsage({ config: { currentPeriod: { end: 1_900_000_000 } } });
|
||||
assertEquals(quota.remainingPercent, 100);
|
||||
assertEquals(quota.limitReached, false);
|
||||
});
|
||||
|
||||
Deno.test("model discovery parses both language-models and standard list shapes", () => {
|
||||
const richModels = parseGrokModels({
|
||||
models: [
|
||||
{ id: "grok-4", input_modalities: ["text", "image"], context_window: 256_000 },
|
||||
{ id: "grok-3-mini", input_modalities: ["text"] },
|
||||
{ id: "grok-4" },
|
||||
],
|
||||
});
|
||||
assertEquals(richModels.map((model) => model.id), ["grok-4", "grok-3-mini"]);
|
||||
assertEquals(richModels[0].displayName, "Grok 4");
|
||||
assertEquals(richModels[0].capabilities, { images: true });
|
||||
assertEquals(richModels[1].capabilities, { images: false });
|
||||
|
||||
const plainModels = parseGrokModels({ data: [{ id: "grok-4-fast" }] });
|
||||
assertEquals(plainModels.map((model) => model.id), ["grok-4-fast"]);
|
||||
assertEquals(plainModels[0].displayName, "Grok 4 Fast");
|
||||
});
|
||||
|
||||
Deno.test("model discovery falls back to known models when the account cannot list", async () => {
|
||||
const token = jwt({ sub: "user-1" });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const models = await grokModels.list(
|
||||
{ resource: snapshot(draft.privateData) },
|
||||
context({
|
||||
fetch: () => ({
|
||||
status: 403,
|
||||
headers: {},
|
||||
body: JSON.stringify({ code: "personal-team-blocked:spending-limit" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assertEquals(models, FALLBACK_MODELS);
|
||||
});
|
||||
|
||||
Deno.test("device OAuth begins with a host-held session and completes with a resource draft", async () => {
|
||||
const accessToken = jwt({ sub: "user-oauth", email: "oauth@x.ai" });
|
||||
let requestNumber = 0;
|
||||
const flowContext = context({
|
||||
fetch: (url, init) => {
|
||||
requestNumber += 1;
|
||||
if (requestNumber === 1) {
|
||||
assertEquals(url, "https://auth.x.ai/oauth2/device/code");
|
||||
assert(init?.body?.includes("scope="), "device code request must carry the scope");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
device_code: "private-device-code",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "https://accounts.x.ai/activate",
|
||||
verification_uri_complete: "https://accounts.x.ai/activate?code=ABCD-EFGH",
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}),
|
||||
};
|
||||
}
|
||||
assertEquals(url, "https://auth.x.ai/oauth2/token");
|
||||
assert(init?.body?.includes("device_code=private-device-code"));
|
||||
if (requestNumber === 2) {
|
||||
return {
|
||||
status: 400,
|
||||
headers: {},
|
||||
body: JSON.stringify({ error: "authorization_pending" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({ access_token: accessToken, refresh_token: "refresh-secret" }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const begun = await grokDeviceOAuth.begin(flowContext);
|
||||
assertEquals(begun.userCode, "ABCD-EFGH");
|
||||
assertEquals(begun.pollIntervalMs, 5000);
|
||||
|
||||
const pending = await grokDeviceOAuth.poll(begun.session, flowContext);
|
||||
assertEquals(pending.status, "pending");
|
||||
|
||||
const polled = await grokDeviceOAuth.poll(begun.session, flowContext);
|
||||
assert(polled.status === "completed", `expected completed, received ${polled.status}`);
|
||||
assertEquals(polled.resources[0].key, "grok:user-oauth");
|
||||
assertEquals(requestNumber, 3);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams normalized events from the xAI Chat Completions API", async () => {
|
||||
const token = jwt({ sub: "user-1" });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
let requestBody = "";
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await grokProvider.invoke(
|
||||
{
|
||||
model: { id: "grok-4", displayName: "Grok 4" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: (url, init) => {
|
||||
assertEquals(url, "https://api.x.ai/v1/chat/completions");
|
||||
requestBody = init?.body ?? "";
|
||||
requestHeaders = init?.headers ?? {};
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"choices":[{"delta":{"content":"Hel"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"lo"}}]}',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"prompt_tokens_details":{"cached_tokens":4}}}',
|
||||
"data: [DONE]",
|
||||
]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
const body = JSON.parse(requestBody) as Record<string, unknown>;
|
||||
assertEquals(body.model, "grok-4");
|
||||
assertEquals(body.stream, true);
|
||||
assertEquals(body.prompt_cache_key, "conversation-1");
|
||||
assert(!("reasoning_effort" in body), "xAI endpoint rejects reasoning_effort");
|
||||
assert(!("service_tier" in body), "xAI endpoint rejects service_tier");
|
||||
assertEquals(requestHeaders["authorization"], `Bearer ${token}`);
|
||||
assertEquals(events, [
|
||||
{ type: "text-start" },
|
||||
{ type: "text-delta", text: "Hel" },
|
||||
{ type: "text-delta", text: "lo" },
|
||||
{ type: "text-end" },
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
totalTokens: null,
|
||||
cacheReadTokens: 4,
|
||||
cacheWriteTokens: null,
|
||||
reasoningTokens: null,
|
||||
},
|
||||
},
|
||||
{ type: "done", reason: "stop" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams incremental tool calls and reasoning replay state", async () => {
|
||||
const token = jwt({ sub: "user-1" });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await grokProvider.invoke(
|
||||
{
|
||||
model: { id: "grok-4", displayName: "Grok 4" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"choices":[{"delta":{"reasoning_content":"thinking"}}]}',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"read_file","arguments":"{\\"path\\":"}}]}}]}',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"a.ts\\"}"}}]}}]}',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
|
||||
"data: [DONE]",
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
assertEquals(events, [
|
||||
{ type: "thinking-start" },
|
||||
{ type: "thinking-delta", text: "thinking" },
|
||||
{ type: "tool-call-start", index: 0, callId: "call-1", name: "read_file" },
|
||||
{ type: "tool-call-arguments-delta", index: 0, delta: '{"path":' },
|
||||
{ type: "tool-call-arguments-delta", index: 0, delta: '"a.ts"}' },
|
||||
{ type: "thinking-end" },
|
||||
{ type: "tool-call-end", index: 0 },
|
||||
{
|
||||
type: "replay-state",
|
||||
providerKind: "openai_chat",
|
||||
value: { reasoning_content: "thinking" },
|
||||
},
|
||||
{ type: "done", reason: "tool-use" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke maps quota failures to a cooling resource error", async () => {
|
||||
assert(!isQuotaError("400 invalid request"));
|
||||
assert(isQuotaError("429 credits exhausted"));
|
||||
const token = jwt({ sub: "user-1" });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const result = await grokProvider.invoke(
|
||||
{
|
||||
model: { id: "grok-4", displayName: "Grok 4" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: () => {} },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 429,
|
||||
headers: {},
|
||||
lines: sse(['{"error":"credits exhausted"}']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert(result.status === "resource-error", `expected resource-error, received ${result.status}`);
|
||||
assert(result.patch.state?.status === "cooling", "quota failure should cool the resource");
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { grokDeviceOAuth } from "./oauth.ts";
|
||||
import { grokProvider } from "./provider.ts";
|
||||
import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [grokProvider],
|
||||
resources: [{
|
||||
type: RESOURCE_TYPE,
|
||||
displayName: { "en-US": "Grok accounts", "zh-CN": "Grok 账号" },
|
||||
add: [grokDeviceOAuth],
|
||||
import: credentialImport,
|
||||
present: presentAccount,
|
||||
refresh: refreshAccount,
|
||||
}],
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ModelDefinition, ModelSupport } from "cursor-byok:model";
|
||||
import { accountData } from "./resources.ts";
|
||||
|
||||
const LANGUAGE_MODELS_URL = "https://api.x.ai/v1/language-models";
|
||||
const MODELS_URL = "https://api.x.ai/v1/models";
|
||||
|
||||
/** 免费账号无权调用模型列表接口(403 spending-limit);退回已知模型。 */
|
||||
export const FALLBACK_MODELS: ModelDefinition[] = [
|
||||
{
|
||||
id: "grok-4.6",
|
||||
displayName: "Grok 4.6",
|
||||
capabilities: { images: true },
|
||||
},
|
||||
{
|
||||
id: "grok-4.5",
|
||||
displayName: "Grok 4.5",
|
||||
capabilities: { images: true },
|
||||
},
|
||||
];
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function modalities(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((item) => (typeof item === "string" ? [item.toLowerCase()] : []))
|
||||
: [];
|
||||
}
|
||||
|
||||
/** 把模型 ID 变成可读名称,如 grok-4-fast → Grok 4 Fast。 */
|
||||
function displayName(id: string): string {
|
||||
return id
|
||||
.split("-")
|
||||
.map((part) => (/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1)))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** 兼容 /v1/language-models 的 models 数组与 /v1/models 的 data 数组。 */
|
||||
export function parseGrokModels(body: unknown): ModelDefinition[] {
|
||||
const root = object(body);
|
||||
const source = root?.models ?? root?.data ?? body;
|
||||
if (!Array.isArray(source)) {
|
||||
throw new Error("Grok model discovery response does not contain a model list");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const models: ModelDefinition[] = [];
|
||||
for (const raw of source) {
|
||||
const model = object(raw);
|
||||
const id = model ? text(model.id ?? model.name) : null;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const inputs = modalities(model?.input_modalities ?? model?.inputModalities);
|
||||
models.push({
|
||||
id,
|
||||
displayName: displayName(id),
|
||||
capabilities: {
|
||||
images: inputs.length === 0 || inputs.includes("image"),
|
||||
},
|
||||
});
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
export const grokModels: ModelSupport = {
|
||||
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
|
||||
if (!resource) throw new Error("add a Grok account before syncing models");
|
||||
const data = accountData(resource);
|
||||
const headers = {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
};
|
||||
// language-models 带模态与上下文元数据;不可用时回退到标准列表。
|
||||
let response = await context.network.fetch(LANGUAGE_MODELS_URL, { method: "GET", headers });
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
response = await context.network.fetch(MODELS_URL, { method: "GET", headers });
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return FALLBACK_MODELS;
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body);
|
||||
} catch {
|
||||
throw new Error("Grok model discovery returned invalid JSON");
|
||||
}
|
||||
const models = parseGrokModels(body);
|
||||
return models.length > 0 ? models : FALLBACK_MODELS;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
|
||||
import { credentialDraft } from "./resources.ts";
|
||||
|
||||
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
||||
const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
|
||||
const TOKEN_URL = "https://auth.x.ai/oauth2/token";
|
||||
const SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
||||
|
||||
type Session = {
|
||||
deviceCode: string;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBody(body: string): Record<string, unknown> {
|
||||
try {
|
||||
return object(JSON.parse(body)) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSession(value: JsonValue): Session {
|
||||
const session = object(value);
|
||||
const deviceCode = text(session?.deviceCode);
|
||||
if (!deviceCode) throw new Error("Grok OAuth session is invalid");
|
||||
return { deviceCode };
|
||||
}
|
||||
|
||||
async function begin(context: PluginContext): Promise<OAuth2Begin> {
|
||||
const response = await context.network.fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPE }).toString(),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Failed to request xAI device code (HTTP ${response.status}): ${response.body}`,
|
||||
);
|
||||
}
|
||||
const deviceCode = text(body.device_code);
|
||||
const userCode = text(body.user_code);
|
||||
const verificationUrl = text(body.verification_uri);
|
||||
if (!deviceCode || !userCode || !verificationUrl) {
|
||||
throw new Error("xAI device authorization response is incomplete");
|
||||
}
|
||||
const session: Session = { deviceCode };
|
||||
return {
|
||||
session: session as unknown as JsonValue,
|
||||
userCode,
|
||||
verificationUrl,
|
||||
...(text(body.verification_uri_complete)
|
||||
? { verificationUrlComplete: text(body.verification_uri_complete)! }
|
||||
: {}),
|
||||
expiresAtMs: Date.now() + Math.max(1, number(body.expires_in) ?? 900) * 1000,
|
||||
pollIntervalMs: Math.max(1, number(body.interval) ?? 5) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
|
||||
const session = parseSession(sessionValue);
|
||||
const response = await context.network.fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: CLIENT_ID,
|
||||
device_code: session.deviceCode,
|
||||
}).toString(),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const accessToken = text(body.access_token);
|
||||
if (!accessToken) {
|
||||
return { status: "failed", message: "xAI token response is missing access_token" };
|
||||
}
|
||||
return {
|
||||
status: "completed",
|
||||
resources: [
|
||||
await credentialDraft({
|
||||
accessToken,
|
||||
refreshToken: text(body.refresh_token),
|
||||
displayName: null,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
const code = text(body.error) ?? "";
|
||||
const message = text(body.error_description);
|
||||
switch (code) {
|
||||
case "authorization_pending":
|
||||
return { status: "pending" };
|
||||
case "slow_down":
|
||||
return { status: "slow-down" };
|
||||
case "expired_token":
|
||||
return { status: "failed", message: message ?? "Device authorization code expired" };
|
||||
case "access_denied":
|
||||
return { status: "denied", ...(message ? { message } : {}) };
|
||||
default:
|
||||
return {
|
||||
status: "failed",
|
||||
message: message ??
|
||||
(code
|
||||
? `OAuth error: ${code}`
|
||||
: `xAI device authorization failed (HTTP ${response.status})`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const grokDeviceOAuth: OAuth2AddMethod = {
|
||||
type: "oauth2.0",
|
||||
id: "xai-device",
|
||||
displayName: {
|
||||
"en-US": "Sign in with xAI",
|
||||
"zh-CN": "使用 xAI 登录",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Authorize this device with xAI, then add the resulting Grok account.",
|
||||
"zh-CN": "在 xAI 完成设备授权后,自动添加对应的 Grok 账号。",
|
||||
},
|
||||
begin,
|
||||
poll,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "dev.cursorbyok.examples.grok-auth",
|
||||
"name": "Grok",
|
||||
"version": "0.1.0",
|
||||
"author": "@leookun",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/grok.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": [
|
||||
"auth.x.ai",
|
||||
"api.x.ai",
|
||||
"cli-chat-proxy.grok.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
ProviderInvokeInput,
|
||||
ProviderOutput,
|
||||
ProviderResult,
|
||||
ProviderSupport,
|
||||
} from "cursor-byok:provider";
|
||||
import type { PluginContext } from "cursor-byok:plugin";
|
||||
import { HttpError, streamOpenAiChat } from "cursor-byok:protocol/openai-chat";
|
||||
import { grokModels } from "./models.ts";
|
||||
import { type AccountData, accountData, quotaExhaustedPatch, RESOURCE_TYPE } from "./resources.ts";
|
||||
|
||||
const CHAT_URL = "https://api.x.ai/v1/chat/completions";
|
||||
|
||||
/** 流内错误只有文本可用,按积分/额度关键词分类。 */
|
||||
export function isQuotaError(error: string): boolean {
|
||||
const message = error.toLowerCase();
|
||||
return message.includes("insufficient_quota") ||
|
||||
message.includes("credits exhausted") ||
|
||||
message.includes("out of credits") ||
|
||||
message.includes("quota_exceeded") ||
|
||||
(message.includes("429") &&
|
||||
(message.includes("quota") || message.includes("credit") ||
|
||||
message.includes("insufficient")));
|
||||
}
|
||||
|
||||
/** HTTP 失败携带结构化状态码,429 一律按额度耗尽处理并冷却账号。 */
|
||||
function isQuotaHttpError(error: HttpError): boolean {
|
||||
if (error.status === 429) return true;
|
||||
const body = error.body.toLowerCase();
|
||||
return body.includes("insufficient_quota") ||
|
||||
body.includes("credits exhausted") ||
|
||||
body.includes("out of credits") ||
|
||||
// 免费账号触达消费上限时返回 403 spending-limit,属于额度而非授权问题。
|
||||
body.includes("spending-limit") ||
|
||||
body.includes("run out of credits") ||
|
||||
body.includes("quota_exceeded");
|
||||
}
|
||||
|
||||
function invalidResult(message: string, stateMessage: string): ProviderResult {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message,
|
||||
patch: { state: { status: "invalid", message: stateMessage } },
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
input: ProviderInvokeInput,
|
||||
output: ProviderOutput,
|
||||
context: PluginContext,
|
||||
): Promise<ProviderResult> {
|
||||
if (!input.resource) {
|
||||
return { status: "request-error", message: "add a Grok account before calling Grok" };
|
||||
}
|
||||
let data: AccountData;
|
||||
try {
|
||||
data = accountData(input.resource);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return invalidResult(message, message);
|
||||
}
|
||||
try {
|
||||
await streamOpenAiChat(
|
||||
{
|
||||
url: CHAT_URL,
|
||||
model: input.model.id,
|
||||
// xAI 不接受 reasoning_effort 与 service_tier;思考由模型自身决定。
|
||||
request: {
|
||||
...input.request,
|
||||
reasoning: { enabled: false, effort: null },
|
||||
latency: "standard",
|
||||
},
|
||||
headers: { authorization: `Bearer ${data.accessToken}` },
|
||||
},
|
||||
output,
|
||||
context,
|
||||
);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if ((error.status === 401 || error.status === 403) && !isQuotaHttpError(error)) {
|
||||
return invalidResult(error.message, "Grok authorization expired; sign in again");
|
||||
}
|
||||
if (isQuotaHttpError(error)) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: quotaExhaustedPatch(data),
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: error.message };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isQuotaError(message)) {
|
||||
return { status: "resource-error", message, patch: quotaExhaustedPatch(data) };
|
||||
}
|
||||
return { status: "request-error", message };
|
||||
}
|
||||
}
|
||||
|
||||
export const grokProvider: ProviderSupport = {
|
||||
id: "grok",
|
||||
displayName: "xAI Grok",
|
||||
description: {
|
||||
"en-US": "SuperGrok subscription access through the official Grok CLI endpoint.",
|
||||
"zh-CN": "通过官方 Grok CLI 接口使用 SuperGrok 订阅。",
|
||||
},
|
||||
providerType: "xai",
|
||||
resourceType: RESOURCE_TYPE,
|
||||
models: grokModels,
|
||||
invoke,
|
||||
};
|
||||
@@ -0,0 +1,342 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type {
|
||||
ResourceDraft,
|
||||
ResourceImportFile,
|
||||
ResourceImportResult,
|
||||
ResourceImportSupport,
|
||||
ResourceMetric,
|
||||
ResourcePatch,
|
||||
ResourceSnapshot,
|
||||
ResourceState,
|
||||
ResourceView,
|
||||
} from "cursor-byok:resource";
|
||||
|
||||
export const RESOURCE_TYPE = "grok-account";
|
||||
|
||||
const CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
export type AccountQuota = {
|
||||
planLabel: string | null;
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
resetAtMs: number | null;
|
||||
limitReached: boolean;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
/** 单条 grok-account 资源的 privateData 形状。 */
|
||||
export type AccountData = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string;
|
||||
quota: AccountQuota | null;
|
||||
};
|
||||
|
||||
export type CredentialCandidate = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const encoded = token.split(".")[1];
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||
return object(JSON.parse(new TextDecoder().decode(bytes)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claim(payload: Record<string, unknown> | null, key: string): string | null {
|
||||
return payload ? text(payload[key]) : null;
|
||||
}
|
||||
|
||||
async function tokenFingerprint(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
||||
return Array.from(
|
||||
new Uint8Array(digest).slice(0, 8),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
export async function accountIdentity(
|
||||
accessToken: string,
|
||||
): Promise<{ key: string; displayName: string }> {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const identity = claim(payload, "sub") ??
|
||||
claim(payload, "email") ??
|
||||
await tokenFingerprint(accessToken);
|
||||
const displayName = claim(payload, "email") ??
|
||||
claim(payload, "preferred_username") ??
|
||||
claim(payload, "name") ??
|
||||
identity;
|
||||
return { key: `grok:${identity}`, displayName };
|
||||
}
|
||||
|
||||
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
|
||||
const identity = await accountIdentity(credential.accessToken);
|
||||
const data: AccountData = {
|
||||
accessToken: credential.accessToken,
|
||||
refreshToken: credential.refreshToken,
|
||||
displayName: credential.displayName ?? identity.displayName,
|
||||
quota: null,
|
||||
};
|
||||
return { key: identity.key, privateData: data as unknown as JsonValue };
|
||||
}
|
||||
|
||||
export function accountData(resource: ResourceSnapshot): AccountData {
|
||||
const data = object(resource.privateData);
|
||||
const accessToken = text(data?.accessToken);
|
||||
if (!accessToken) throw new Error("Grok account resource is missing its access token");
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: text(data?.refreshToken),
|
||||
displayName: text(data?.displayName) ?? "Grok account",
|
||||
quota: (data?.quota ?? null) as AccountQuota | null,
|
||||
};
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function resetAtMs(value: unknown): number | null {
|
||||
const numeric = number(value);
|
||||
if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 解析 Grok CLI 计费接口的积分响应;creditUsagePercent 表示已用占比。 */
|
||||
export function parseGrokUsage(body: unknown, nowMs = Date.now()): AccountQuota {
|
||||
const root = object(body) ?? {};
|
||||
const config = object(root.config) ?? root;
|
||||
let used = number(config.creditUsagePercent ?? config.credit_usage_percent);
|
||||
if (used === null) {
|
||||
const onDemandUsed = number(config.onDemandUsed ?? config.on_demand_used);
|
||||
const onDemandCap = number(config.onDemandCap ?? config.on_demand_cap);
|
||||
if (onDemandUsed !== null && onDemandCap !== null && onDemandCap > 0) {
|
||||
used = (onDemandUsed / onDemandCap) * 100;
|
||||
}
|
||||
}
|
||||
// 存在计费周期但没有用量字段时视为未使用。
|
||||
if (used === null && (config.currentPeriod ?? config.current_period) !== undefined) {
|
||||
used = 0;
|
||||
}
|
||||
const remaining = used === null ? null : clampPercent(100 - used);
|
||||
const period = object(config.currentPeriod ?? config.current_period);
|
||||
return {
|
||||
planLabel: text(
|
||||
config.subscriptionTierDisplay ?? config.subscription_tier_display ??
|
||||
config.subscriptionTier ?? config.product,
|
||||
),
|
||||
usedPercent: used === null ? null : clampPercent(used),
|
||||
remainingPercent: remaining,
|
||||
resetAtMs: resetAtMs(period?.end ?? config.billingPeriodEnd ?? config.billing_period_end),
|
||||
limitReached: remaining !== null && remaining <= 0,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
|
||||
if (!quota || !quota.limitReached) return { status: "ready" };
|
||||
if (quota.resetAtMs !== null && quota.resetAtMs <= nowMs) return { status: "ready" };
|
||||
return {
|
||||
status: "cooling",
|
||||
retryAtMs: quota.resetAtMs ?? nowMs + ONE_HOUR_MS,
|
||||
message: "Grok credits are exhausted",
|
||||
};
|
||||
}
|
||||
|
||||
/** 额度耗尽时的资源补丁:标记积分耗尽并进入冷却,重置时间未知时回退 1 小时。 */
|
||||
export function quotaExhaustedPatch(data: AccountData, nowMs = Date.now()): ResourcePatch {
|
||||
const quota: AccountQuota = {
|
||||
planLabel: data.quota?.planLabel ?? null,
|
||||
usedPercent: 100,
|
||||
remainingPercent: 0,
|
||||
resetAtMs: data.quota?.resetAtMs !== undefined && data.quota?.resetAtMs !== null &&
|
||||
data.quota.resetAtMs > nowMs
|
||||
? data.quota.resetAtMs
|
||||
: null,
|
||||
limitReached: true,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function accountHeaders(data: AccountData): Record<string, string> {
|
||||
return {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
// Grok CLI 计费接口要求该头标识客户端来源。
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
};
|
||||
}
|
||||
|
||||
function jwtDisplayName(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const payload = decodeJwtPayload(token);
|
||||
return claim(payload, "email") ?? claim(payload, "preferred_username") ??
|
||||
claim(payload, "name");
|
||||
}
|
||||
|
||||
export function presentAccount(resource: ResourceSnapshot): ResourceView {
|
||||
const data = accountData(resource);
|
||||
const metrics: ResourceMetric[] = [];
|
||||
const quota = data.quota;
|
||||
if (quota && quota.remainingPercent !== null) {
|
||||
metrics.push({
|
||||
id: "credits",
|
||||
label: { "en-US": "Credits", "zh-CN": "积分额度" },
|
||||
unit: "percent",
|
||||
value: quota.remainingPercent,
|
||||
...(quota.resetAtMs !== null ? { resetAtMs: quota.resetAtMs } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
// 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。
|
||||
displayName: jwtDisplayName(data.accessToken) ?? data.displayName,
|
||||
...(quota?.planLabel ? { description: quota.planLabel } : {}),
|
||||
...(metrics.length > 0 ? { metrics } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccount(
|
||||
resource: ResourceSnapshot,
|
||||
context: PluginContext,
|
||||
): Promise<ResourcePatch> {
|
||||
const data = accountData(resource);
|
||||
const response = await context.network.fetch(CREDITS_URL, {
|
||||
method: "GET",
|
||||
headers: accountHeaders(data),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
state: { status: "invalid", message: "Grok authorization expired; sign in again" },
|
||||
};
|
||||
}
|
||||
throw new Error(`Grok usage lookup failed (HTTP ${response.status}): ${response.body}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body);
|
||||
} catch {
|
||||
throw new Error("Grok usage lookup returned invalid JSON");
|
||||
}
|
||||
const quota = parseGrokUsage(body);
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota),
|
||||
};
|
||||
}
|
||||
|
||||
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = text(source[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectCredentials(item, output);
|
||||
return;
|
||||
}
|
||||
const item = object(value);
|
||||
if (!item || item.disabled === true) return;
|
||||
for (const key of ["accounts", "credentials", "items"]) {
|
||||
if (Array.isArray(item[key])) {
|
||||
collectCredentials(item[key], output);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const tokens = object(item.tokens) ?? item;
|
||||
const accessToken = firstText(tokens, ["access_token", "accessToken", "token", "key"]) ??
|
||||
firstText(item, ["access_token", "accessToken", "token", "key", "XAI_API_KEY"]);
|
||||
if (!accessToken) return;
|
||||
const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ??
|
||||
firstText(item, ["refresh_token", "refreshToken"]);
|
||||
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
|
||||
firstText(tokens, ["email", "display_name", "displayName", "name"]);
|
||||
output.push({ accessToken, refreshToken, displayName });
|
||||
}
|
||||
|
||||
export function parseCredentialFiles(files: ResourceImportFile[]): {
|
||||
credentials: CredentialCandidate[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const credentials: CredentialCandidate[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const file of files) {
|
||||
let content: unknown;
|
||||
try {
|
||||
content = JSON.parse(file.content);
|
||||
} catch {
|
||||
warnings.push(`${file.name}: not valid JSON`);
|
||||
continue;
|
||||
}
|
||||
const found: CredentialCandidate[] = [];
|
||||
collectCredentials(content, found);
|
||||
if (found.length === 0) {
|
||||
warnings.push(`${file.name}: no Grok access token found`);
|
||||
continue;
|
||||
}
|
||||
credentials.push(...found);
|
||||
}
|
||||
return { credentials, warnings };
|
||||
}
|
||||
|
||||
export const credentialImport: ResourceImportSupport = {
|
||||
displayName: {
|
||||
"en-US": "Import Grok credentials",
|
||||
"zh-CN": "导入 Grok 凭证",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Import one or more Grok JSON credential files.",
|
||||
"zh-CN": "导入一个或多个 Grok JSON 凭证文件。",
|
||||
},
|
||||
accept: [".json"],
|
||||
multiple: true,
|
||||
parse: async (files: ResourceImportFile[]): Promise<ResourceImportResult> => {
|
||||
const { credentials, warnings } = parseCredentialFiles(files);
|
||||
if (credentials.length === 0) {
|
||||
throw new Error(warnings.join("; ") || "credential JSON does not contain an access token");
|
||||
}
|
||||
return {
|
||||
resources: await Promise.all(credentials.map(credentialDraft)),
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -184,7 +184,11 @@ pub async fn append(
|
||||
request: DecodedAppend,
|
||||
parent: Option<TransportParent>,
|
||||
) -> Result<ai::BidiAppendResponse> {
|
||||
let handle = registry.get_or_create(&request.request_id).await?;
|
||||
let replace_closing = request.model_id().is_some();
|
||||
let handle = registry
|
||||
.get_or_create_for_append(&request.request_id, replace_closing)
|
||||
.await?;
|
||||
let _admission = handle.admit()?;
|
||||
if let Some(conversation_id) = request.conversation_id() {
|
||||
handle.set_conversation_id(conversation_id)?;
|
||||
}
|
||||
|
||||
@@ -19,18 +19,26 @@ use crate::{
|
||||
connect,
|
||||
proto::{agent::v1 as agent, aiserver::v1 as ai},
|
||||
},
|
||||
services::{account, analytics, model_catalog, observability::CursorTraceRecorder, tab},
|
||||
services::{account, analytics, knowledge, model_catalog, tab},
|
||||
transport::{TransportParent, TransportRegistry},
|
||||
},
|
||||
Result,
|
||||
};
|
||||
|
||||
pub fn router(registry: TransportRegistry) -> Result<Router> {
|
||||
let proxy = CursorProxy::cursor(registry.store().clone())?;
|
||||
Ok(router_with_proxy(registry, proxy))
|
||||
pub fn router(
|
||||
registry: TransportRegistry,
|
||||
clients: crate::network::NetworkClients,
|
||||
) -> Result<Router> {
|
||||
let proxy = CursorProxy::cursor(clients);
|
||||
let knowledge = knowledge::KnowledgeService::managed()?;
|
||||
Ok(router_with_proxy(registry, proxy, knowledge))
|
||||
}
|
||||
|
||||
fn router_with_proxy(registry: TransportRegistry, proxy: CursorProxy) -> Router {
|
||||
fn router_with_proxy(
|
||||
registry: TransportRegistry,
|
||||
proxy: CursorProxy,
|
||||
knowledge_service: knowledge::KnowledgeService,
|
||||
) -> Router {
|
||||
let web_cache = registry.web_cache().router();
|
||||
Router::new()
|
||||
.route("/__byok-api__/healthz", get(health))
|
||||
@@ -69,6 +77,22 @@ fn router_with_proxy(registry: TransportRegistry, proxy: CursorProxy) -> Router
|
||||
"/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants",
|
||||
post(account::usage_limit_status),
|
||||
)
|
||||
.route(
|
||||
"/aiserver.v1.AiService/KnowledgeBaseAdd",
|
||||
post(knowledge::add),
|
||||
)
|
||||
.route(
|
||||
"/aiserver.v1.AiService/KnowledgeBaseList",
|
||||
post(knowledge::list),
|
||||
)
|
||||
.route(
|
||||
"/aiserver.v1.AiService/KnowledgeBaseUpdate",
|
||||
post(knowledge::update),
|
||||
)
|
||||
.route(
|
||||
"/aiserver.v1.AiService/KnowledgeBaseRemove",
|
||||
post(knowledge::remove),
|
||||
)
|
||||
.route(
|
||||
analytics::BOOTSTRAP_STATSIG_PATH,
|
||||
post(analytics::bootstrap_statsig),
|
||||
@@ -80,6 +104,7 @@ fn router_with_proxy(registry: TransportRegistry, proxy: CursorProxy) -> Router
|
||||
.fallback(proxy::forward)
|
||||
.method_not_allowed_fallback(proxy::forward)
|
||||
.layer(Extension(proxy))
|
||||
.layer(Extension(knowledge_service))
|
||||
.with_state(registry)
|
||||
.merge(web_cache)
|
||||
}
|
||||
@@ -96,16 +121,13 @@ async fn run_sse_handler(
|
||||
let (parts, body) = buffered(request).await?;
|
||||
let request: agent::BidiRequestId = connect::decode_unary(&body)?;
|
||||
let route = registry.wait_route(&request.request_id).await;
|
||||
let trace = CursorTraceRecorder::resume(registry.store().clone(), &request.request_id).await;
|
||||
if let Some(trace) = &trace {
|
||||
trace
|
||||
.request(
|
||||
"run_sse_request",
|
||||
&body,
|
||||
serde_json::json!({"request_id": request.request_id}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let trace = registry.trace(&request.request_id);
|
||||
trace.resume();
|
||||
trace.request(
|
||||
"run_sse_request",
|
||||
body.clone(),
|
||||
serde_json::json!({"request_id": request.request_id}),
|
||||
);
|
||||
match route {
|
||||
crate::cursor::transport::TransportRoute::Local => {
|
||||
run_sse::stream(®istry, &request.request_id).await
|
||||
@@ -116,7 +138,14 @@ async fn run_sse_handler(
|
||||
Request::from_parts(parts, Body::from(body)),
|
||||
)
|
||||
.await?;
|
||||
Ok(run_sse::upstream(registry, request.request_id, generation, response, trace).await)
|
||||
Ok(run_sse::upstream(
|
||||
registry,
|
||||
request.request_id,
|
||||
generation,
|
||||
response,
|
||||
Some(trace),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,8 +161,12 @@ async fn bidi_handler(
|
||||
let first_model = decoded.model_id().map(str::to_owned);
|
||||
let conversation_id = decoded.conversation_id().map(str::to_owned);
|
||||
let trace_metadata = decoded.trace_metadata();
|
||||
let trace = registry.trace(&decoded.request_id);
|
||||
let local = if let Some(model_id) = decoded.model_id() {
|
||||
if registry.store().model(model_id).await?.is_some() {
|
||||
// 插件模型 ID 只在本地有意义,永远不转发到 Cursor 官方上游。
|
||||
if model_id.starts_with(crate::plugin::ADAPTER_ID_PREFIX)
|
||||
|| registry.store().model(model_id).await?.is_some()
|
||||
{
|
||||
tracing::info!(
|
||||
request_id = decoded.request_id,
|
||||
model_id,
|
||||
@@ -153,14 +186,18 @@ async fn bidi_handler(
|
||||
} else if registry.upstream(&decoded.request_id).await {
|
||||
false
|
||||
} else {
|
||||
trace.resume();
|
||||
trace.request(
|
||||
"bidi_request",
|
||||
body.clone(),
|
||||
trace_outcome(trace_metadata, false, "missing_transport", None),
|
||||
);
|
||||
return Err(crate::Error::Protocol(
|
||||
"first BidiAppend message must select a model".into(),
|
||||
));
|
||||
};
|
||||
let trace = if first_model.is_some() {
|
||||
CursorTraceRecorder::begin(
|
||||
registry.store().clone(),
|
||||
&decoded.request_id,
|
||||
if first_model.is_some() {
|
||||
trace.begin(
|
||||
conversation_id.as_deref(),
|
||||
if local {
|
||||
"local_byok"
|
||||
@@ -168,26 +205,61 @@ async fn bidi_handler(
|
||||
"cursor_official"
|
||||
},
|
||||
first_model.as_deref(),
|
||||
)
|
||||
.await
|
||||
);
|
||||
} else {
|
||||
CursorTraceRecorder::resume(registry.store().clone(), &decoded.request_id).await
|
||||
};
|
||||
if let Some(trace) = &trace {
|
||||
trace.request("bidi_request", &body, trace_metadata).await;
|
||||
trace.resume();
|
||||
}
|
||||
if !local {
|
||||
if first_model.is_some() {
|
||||
registry.mark_upstream(&decoded.request_id).await;
|
||||
}
|
||||
trace.request(
|
||||
"bidi_request",
|
||||
body.clone(),
|
||||
trace_outcome(trace_metadata, true, "upstream", None),
|
||||
);
|
||||
return proxy::forward(
|
||||
Extension(proxy),
|
||||
Request::from_parts(parts, Body::from(body)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let parent = parent_headers(&parts.headers)?;
|
||||
bidi::append(®istry, decoded, parent).await?;
|
||||
let parent = match parent_headers(&parts.headers) {
|
||||
Ok(parent) => parent,
|
||||
Err(error) => {
|
||||
trace.request(
|
||||
"bidi_request",
|
||||
body,
|
||||
trace_outcome(
|
||||
trace_metadata,
|
||||
false,
|
||||
"invalid_parent",
|
||||
Some(error.to_string()),
|
||||
),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
match bidi::append(®istry, decoded, parent).await {
|
||||
Ok(_) => trace.request(
|
||||
"bidi_request",
|
||||
body,
|
||||
trace_outcome(trace_metadata, true, "local", None),
|
||||
),
|
||||
Err(error) => {
|
||||
trace.request(
|
||||
"bidi_request",
|
||||
body,
|
||||
trace_outcome(
|
||||
trace_metadata,
|
||||
false,
|
||||
"command_rejected",
|
||||
Some(error.to_string()),
|
||||
),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let mut response = Response::new(axum::body::Body::empty());
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response.headers_mut().insert(
|
||||
@@ -197,6 +269,22 @@ async fn bidi_handler(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn trace_outcome(
|
||||
mut metadata: serde_json::Value,
|
||||
accepted: bool,
|
||||
route_outcome: &str,
|
||||
error: Option<String>,
|
||||
) -> serde_json::Value {
|
||||
if let Some(metadata) = metadata.as_object_mut() {
|
||||
metadata.insert("accepted".into(), accepted.into());
|
||||
metadata.insert("route_outcome".into(), route_outcome.into());
|
||||
if let Some(error) = error {
|
||||
metadata.insert("error".into(), error.into());
|
||||
}
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
async fn buffered(request: Request<Body>) -> Result<(axum::http::request::Parts, Bytes)> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let body = to_bytes(body, usize::MAX)
|
||||
|
||||
@@ -14,8 +14,7 @@ pub const UPSTREAM_URL_HEADER: &str = "x-server-upstream-url";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CursorProxy {
|
||||
client: Option<reqwest::Client>,
|
||||
store: Option<crate::store::Store>,
|
||||
clients: crate::network::NetworkClients,
|
||||
upstream: String,
|
||||
}
|
||||
|
||||
@@ -47,23 +46,15 @@ impl BufferedResponse {
|
||||
}
|
||||
|
||||
impl CursorProxy {
|
||||
pub fn cursor(store: crate::store::Store) -> Result<Self> {
|
||||
Ok(Self {
|
||||
client: None,
|
||||
store: Some(store),
|
||||
pub fn cursor(clients: crate::network::NetworkClients) -> Self {
|
||||
Self {
|
||||
clients,
|
||||
upstream: CURSOR_UPSTREAM.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn client(&self) -> Result<reqwest::Client> {
|
||||
match (&self.client, &self.store) {
|
||||
(Some(client), _) => Ok(client.clone()),
|
||||
(_, Some(store)) => Ok(crate::network::client_builder(store)
|
||||
.await?
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?),
|
||||
_ => unreachable!("Cursor proxy always has a client or store"),
|
||||
}
|
||||
self.clients.cursor_client().await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ pub async fn stream(registry: &TransportRegistry, request_id: &str) -> Result<Re
|
||||
let receiver = handle.subscribe();
|
||||
let trace = handle.trace().cloned();
|
||||
if let Some(trace) = &trace {
|
||||
trace.response_started(StatusCode::OK.as_u16()).await;
|
||||
trace.response_started(StatusCode::OK.as_u16());
|
||||
}
|
||||
let body_stream = local_body_stream(receiver, handle, trace);
|
||||
let mut response = Response::new(Body::from_stream(body_stream));
|
||||
@@ -133,7 +133,7 @@ pub async fn upstream(
|
||||
) -> Response<Body> {
|
||||
let (parts, body) = response.into_parts();
|
||||
if let Some(trace) = &trace {
|
||||
trace.response_started(parts.status.as_u16()).await;
|
||||
trace.response_started(parts.status.as_u16());
|
||||
}
|
||||
let stream = async_stream::stream! {
|
||||
let _guard = UpstreamRunGuard {
|
||||
@@ -180,15 +180,15 @@ impl TraceStreamSink {
|
||||
while let Some(event) = receiver.recv().await {
|
||||
match event {
|
||||
TraceStreamEvent::Chunk(chunk) => {
|
||||
trace.response_chunk(source, &chunk).await;
|
||||
trace.response_chunk(source, chunk);
|
||||
}
|
||||
TraceStreamEvent::Finish(error) => {
|
||||
trace.finish(error.as_deref()).await;
|
||||
trace.finish(error.as_deref());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
trace.finish(None).await;
|
||||
trace.finish(None);
|
||||
});
|
||||
Self {
|
||||
sender: Some(sender),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Builds the top-level server router.
|
||||
|
||||
use crate::{cursor::transport::TransportRegistry, Result};
|
||||
use crate::{cursor::transport::TransportRegistry, network::NetworkClients, Result};
|
||||
|
||||
pub fn router(registry: TransportRegistry) -> Result<axum::Router> {
|
||||
super::cursor::router(registry)
|
||||
pub fn router(registry: TransportRegistry, clients: NetworkClients) -> Result<axum::Router> {
|
||||
super::cursor::router(registry, clients)
|
||||
}
|
||||
|
||||
+23
-3
@@ -13,6 +13,7 @@ use crate::{
|
||||
transport::TransportRegistry,
|
||||
},
|
||||
local_app::CursorHarness,
|
||||
plugin::{PluginRegistry, PluginRuntime},
|
||||
provider::ProviderRouter,
|
||||
search::WebCache,
|
||||
store::Store,
|
||||
@@ -37,19 +38,38 @@ impl App {
|
||||
}
|
||||
let assets = PromptAssets::embedded()?;
|
||||
let compiler = PromptCompiler::new(assets);
|
||||
let plugin_runtime = PluginRuntime::managed()?;
|
||||
let plugins = PluginRegistry::managed(
|
||||
store.clone(),
|
||||
plugin_runtime.clone(),
|
||||
config.app_version.clone(),
|
||||
)?;
|
||||
let clients = crate::network::NetworkClients::new(store.clone());
|
||||
let provider = std::sync::Arc::new(ProviderRouter::new(
|
||||
store.clone(),
|
||||
plugins.clone(),
|
||||
clients.clone(),
|
||||
config.provider_request_timeout,
|
||||
config.provider_stream_idle_timeout,
|
||||
));
|
||||
let registry = TransportRegistry::with_web_cache(
|
||||
let registry = TransportRegistry::with_plugins(
|
||||
store.clone(),
|
||||
provider.clone(),
|
||||
compiler,
|
||||
WebCache::managed()?,
|
||||
plugins.clone(),
|
||||
crate::config::managed_data_dir()?.join("rules"),
|
||||
);
|
||||
let control = control::ControlService::new(store.clone(), provider)?;
|
||||
let control = control::ControlService::new(
|
||||
store.clone(),
|
||||
provider,
|
||||
plugin_runtime,
|
||||
plugins,
|
||||
clients.clone(),
|
||||
config.app_version.clone(),
|
||||
)?;
|
||||
let harness = control.cursor_harness().clone();
|
||||
let mut router = api::router(registry.clone())?;
|
||||
let mut router = api::router(registry.clone(), clients)?;
|
||||
router = match &config.console {
|
||||
Some(ConsoleSource::Directory(directory)) => {
|
||||
router.merge(control::web_router(control.clone(), directory))
|
||||
|
||||
+27
-1
@@ -10,7 +10,8 @@ const DATA_DIR_NAME: &str = ".cursor-byok-v3";
|
||||
const DATABASE_FILE_NAME: &str = "cursor-byok.db";
|
||||
const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2";
|
||||
const V0049_CONFIG_FILE_NAME: &str = "config.yaml";
|
||||
const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(3000);
|
||||
const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(60 * 60);
|
||||
const DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
pub fn managed_data_dir() -> Result<PathBuf> {
|
||||
let home_dir = dirs::home_dir()
|
||||
@@ -45,6 +46,7 @@ pub struct ProviderConfig {
|
||||
pub custom_headers: reqwest::header::HeaderMap,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub request_timeout: Duration,
|
||||
pub allowed_body_fields: Option<std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -52,8 +54,11 @@ pub struct Config {
|
||||
pub listen_addr: SocketAddr,
|
||||
pub database_url: String,
|
||||
pub provider_request_timeout: Duration,
|
||||
pub provider_stream_idle_timeout: Duration,
|
||||
pub console: Option<ConsoleSource>,
|
||||
pub use_persisted_ports: bool,
|
||||
/// 面向用户的应用版本;桌面壳会覆盖为自身版本,用于插件 minAppVersion 门控。
|
||||
pub app_version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -102,8 +107,10 @@ impl Config {
|
||||
listen_addr,
|
||||
database_url: database_url_from_env()?,
|
||||
provider_request_timeout: request_timeout,
|
||||
provider_stream_idle_timeout: DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT,
|
||||
console,
|
||||
use_persisted_ports: false,
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,8 +121,10 @@ impl Config {
|
||||
.expect("desktop listen address is static"),
|
||||
database_url: default_database_url()?,
|
||||
provider_request_timeout: DEFAULT_PROVIDER_REQUEST_TIMEOUT,
|
||||
provider_stream_idle_timeout: DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT,
|
||||
console: None,
|
||||
use_persisted_ports: true,
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -142,3 +151,20 @@ fn database_url_for_dir(data_dir: &std::path::Path) -> Result<String> {
|
||||
.ok_or_else(|| Error::Config("database path is not valid UTF-8".into()))?;
|
||||
Ok(format!("sqlite://{database_path}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_timeout_defaults_match_runtime_boundaries() {
|
||||
assert_eq!(
|
||||
DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT,
|
||||
Duration::from_secs(30 * 60)
|
||||
);
|
||||
assert_eq!(
|
||||
DEFAULT_PROVIDER_REQUEST_TIMEOUT,
|
||||
Duration::from_secs(60 * 60)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use super::ControlService;
|
||||
// 开源项目广告为作者唯一收入来源,PR时请勿去除此广告
|
||||
|
||||
pub(super) const ADS_ENDPOINT: &str = "https://tab.leokun.cn/api/v1/ads?placement=menu";
|
||||
// pub(super) const ADS_ENDPOINT: &str = "http://localhost:8080/api/v1/ads?placement=menu";
|
||||
pub(super) const DEVICE_ID_HEADER: &str = "X-Cursor-Assistant-Device-ID";
|
||||
pub(super) const OS_HEADER: &str = "X-Cursor-Assistant-OS";
|
||||
pub(super) const APP_VERSION_HEADER: &str = "X-Cursor-Assistant-Version";
|
||||
|
||||
@@ -4,6 +4,7 @@ mod calls;
|
||||
mod harness;
|
||||
mod models;
|
||||
mod overview;
|
||||
mod plugins;
|
||||
mod service;
|
||||
mod settings;
|
||||
|
||||
@@ -136,6 +137,45 @@ pub fn api_router(service: ControlService) -> Router {
|
||||
)
|
||||
.route("/__byok-api__/api/llm-calls", get(calls::list))
|
||||
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
|
||||
.route("/__byok-api__/api/plugins", get(plugins::list))
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/runtime",
|
||||
get(plugins::runtime_status)
|
||||
.post(plugins::initialize_runtime)
|
||||
.delete(plugins::cancel_runtime_initialization),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/oauth/{session_id}/poll",
|
||||
post(plugins::oauth_poll),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}",
|
||||
axum::routing::delete(plugins::remove),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/add/{method_id}/begin",
|
||||
post(plugins::oauth_begin),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/import",
|
||||
post(plugins::import),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/export",
|
||||
get(plugins::export_resources),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}",
|
||||
axum::routing::delete(plugins::delete_resource),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}/refresh",
|
||||
post(plugins::refresh_resource),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/plugins/{plugin_id}/providers/{provider_id}/models/sync",
|
||||
post(plugins::sync_models),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/settings/observability",
|
||||
get(settings::get).put(settings::update),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Exposes plugin discovery, resource lifecycle, model sync, and runtime endpoints.
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
plugin::{
|
||||
ImportResponse, OAuthBeginResponse, OAuthPollResponse, PluginDescriptor,
|
||||
PluginRuntimeStatus,
|
||||
},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::ControlService;
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<PluginDescriptor>>> {
|
||||
Ok(Json(service.plugins().await))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(service): State<ControlService>,
|
||||
Path(plugin_id): Path<String>,
|
||||
) -> Result<StatusCode> {
|
||||
service.remove_plugin_configuration(&plugin_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn oauth_begin(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type, method_id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<OAuthBeginResponse>> {
|
||||
Ok(Json(
|
||||
service
|
||||
.plugin_oauth_begin(&plugin_id, &resource_type, &method_id)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn oauth_poll(
|
||||
State(service): State<ControlService>,
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<Json<OAuthPollResponse>> {
|
||||
Ok(Json(service.plugin_oauth_poll(&session_id).await?))
|
||||
}
|
||||
|
||||
pub async fn import(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type)): Path<(String, String)>,
|
||||
Json(files): Json<serde_json::Value>,
|
||||
) -> Result<Json<ImportResponse>> {
|
||||
Ok(Json(
|
||||
service
|
||||
.plugin_import(&plugin_id, &resource_type, files)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// 以附件形式返回账号资源导出文件,便于浏览器直接下载。
|
||||
pub async fn export_resources(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type)): Path<(String, String)>,
|
||||
) -> Result<axum::response::Response> {
|
||||
let value = service
|
||||
.plugin_export_resources(&plugin_id, &resource_type)
|
||||
.await?;
|
||||
let body = serde_json::to_vec_pretty(&value)?;
|
||||
let response = axum::response::Response::builder()
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{plugin_id}-{resource_type}.json\""),
|
||||
)
|
||||
.body(axum::body::Body::from(body))
|
||||
.expect("static export response");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn refresh_resource(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>,
|
||||
) -> Result<StatusCode> {
|
||||
service
|
||||
.plugin_refresh_resource(&plugin_id, &resource_type, &resource_id)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_resource(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>,
|
||||
) -> Result<StatusCode> {
|
||||
service
|
||||
.plugin_delete_resource(&plugin_id, &resource_type, &resource_id)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn sync_models(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, provider_id)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let count = service.plugin_sync_models(&plugin_id, &provider_id).await?;
|
||||
Ok(Json(serde_json::json!({ "models": count })))
|
||||
}
|
||||
|
||||
pub async fn runtime_status(
|
||||
State(service): State<ControlService>,
|
||||
) -> Result<Json<PluginRuntimeStatus>> {
|
||||
Ok(Json(service.plugin_runtime_status()))
|
||||
}
|
||||
|
||||
pub async fn initialize_runtime(
|
||||
State(service): State<ControlService>,
|
||||
) -> Result<Json<PluginRuntimeStatus>> {
|
||||
Ok(Json(service.initialize_plugin_runtime()))
|
||||
}
|
||||
|
||||
pub async fn cancel_runtime_initialization(
|
||||
State(service): State<ControlService>,
|
||||
) -> Result<Json<PluginRuntimeStatus>> {
|
||||
Ok(Json(service.cancel_plugin_runtime_initialization()))
|
||||
}
|
||||
+128
-14
@@ -25,6 +25,7 @@ use crate::{
|
||||
ModelRequest, ModelSpec, ModelType, Overview, ProjectedContent, ProjectedMessage,
|
||||
PromptSpec, ProviderType, Role,
|
||||
},
|
||||
plugin::{PluginDescriptor, PluginRegistry, PluginRuntime, PluginRuntimeStatus},
|
||||
provider::{is_valid_response_event, ModelEvent, Provider},
|
||||
store::{
|
||||
DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store,
|
||||
@@ -38,6 +39,10 @@ pub struct ControlService {
|
||||
store: Store,
|
||||
cursor_harness: CursorHarness,
|
||||
provider: Arc<dyn Provider>,
|
||||
plugin_runtime: PluginRuntime,
|
||||
plugins: PluginRegistry,
|
||||
clients: crate::network::NetworkClients,
|
||||
app_version: String,
|
||||
model_tests: Arc<Mutex<BTreeMap<String, CancellationToken>>>,
|
||||
}
|
||||
|
||||
@@ -143,11 +148,22 @@ pub struct ObservabilitySettings {
|
||||
}
|
||||
|
||||
impl ControlService {
|
||||
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Result<Self> {
|
||||
pub fn new(
|
||||
store: Store,
|
||||
provider: Arc<dyn Provider>,
|
||||
plugin_runtime: PluginRuntime,
|
||||
plugins: PluginRegistry,
|
||||
clients: crate::network::NetworkClients,
|
||||
app_version: String,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
cursor_harness: CursorHarness::new(store.clone())?,
|
||||
store,
|
||||
provider,
|
||||
plugin_runtime,
|
||||
plugins,
|
||||
clients,
|
||||
app_version,
|
||||
model_tests: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
})
|
||||
}
|
||||
@@ -156,18 +172,103 @@ impl ControlService {
|
||||
&self.cursor_harness
|
||||
}
|
||||
|
||||
pub async fn plugins(&self) -> Vec<PluginDescriptor> {
|
||||
self.plugins.plugins().await
|
||||
}
|
||||
|
||||
pub async fn plugin_oauth_begin(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
method_id: &str,
|
||||
) -> Result<crate::plugin::OAuthBeginResponse> {
|
||||
self.plugins
|
||||
.oauth_begin(plugin_id, resource_type, method_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_oauth_poll(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<crate::plugin::OAuthPollResponse> {
|
||||
self.plugins.oauth_poll(session_id).await
|
||||
}
|
||||
|
||||
pub async fn plugin_import(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
files: serde_json::Value,
|
||||
) -> Result<crate::plugin::ImportResponse> {
|
||||
self.plugins
|
||||
.import_resources(plugin_id, resource_type, files)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_export_resources(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.plugins
|
||||
.export_resources(plugin_id, resource_type)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_refresh_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<()> {
|
||||
self.plugins
|
||||
.refresh_resource(plugin_id, resource_type, resource_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_delete_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
resource_type: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<()> {
|
||||
self.plugins
|
||||
.delete_resource(plugin_id, resource_type, resource_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_sync_models(&self, plugin_id: &str, provider_id: &str) -> Result<usize> {
|
||||
self.plugins.sync_models(plugin_id, provider_id).await
|
||||
}
|
||||
|
||||
pub async fn remove_plugin_configuration(&self, plugin_id: &str) -> Result<()> {
|
||||
self.plugins.remove(plugin_id).await
|
||||
}
|
||||
|
||||
pub fn plugin_runtime_status(&self) -> PluginRuntimeStatus {
|
||||
self.plugin_runtime.status()
|
||||
}
|
||||
|
||||
pub fn initialize_plugin_runtime(&self) -> PluginRuntimeStatus {
|
||||
self.plugin_runtime.initialize(self.store.clone())
|
||||
}
|
||||
|
||||
pub fn cancel_plugin_runtime_initialization(&self) -> PluginRuntimeStatus {
|
||||
self.plugin_runtime.cancel_initialization()
|
||||
}
|
||||
|
||||
pub(super) async fn ads(
|
||||
&self,
|
||||
disabled_ad_ids: Option<&str>,
|
||||
language: &str,
|
||||
) -> Result<AdRuntime> {
|
||||
let client = crate::network::client(&self.store).await?;
|
||||
let client = self.clients.default_client().await?;
|
||||
let installation_id = self.store.installation_id().await?;
|
||||
let mut request = client
|
||||
.get(ADS_ENDPOINT)
|
||||
.header(DEVICE_ID_HEADER, installation_id)
|
||||
.header(OS_HEADER, std::env::consts::OS)
|
||||
.header(APP_VERSION_HEADER, env!("CARGO_PKG_VERSION"))
|
||||
.header(APP_VERSION_HEADER, &self.app_version)
|
||||
.header(LANGUAGE_HEADER, language)
|
||||
.timeout(std::time::Duration::from_secs(60));
|
||||
if let Some(disabled_ad_ids) = disabled_ad_ids.filter(|value| !value.is_empty()) {
|
||||
@@ -186,7 +287,7 @@ impl ControlService {
|
||||
}
|
||||
|
||||
pub(super) async fn dismiss_ad(&self, ad_id: &str, input: &AdDismissalInput) -> Result<()> {
|
||||
let client = crate::network::client(&self.store).await?;
|
||||
let client = self.clients.default_client().await?;
|
||||
let installation_id = self.store.installation_id().await?;
|
||||
let mut endpoint = Url::parse(ADS_ENDPOINT).map_err(|error| {
|
||||
Error::Config(format!("advertisement endpoint is invalid: {error}"))
|
||||
@@ -201,7 +302,7 @@ impl ControlService {
|
||||
.post(endpoint)
|
||||
.header(DEVICE_ID_HEADER, installation_id)
|
||||
.header(OS_HEADER, std::env::consts::OS)
|
||||
.header(APP_VERSION_HEADER, env!("CARGO_PKG_VERSION"))
|
||||
.header(APP_VERSION_HEADER, &self.app_version)
|
||||
.json(input)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
@@ -293,14 +394,20 @@ impl ControlService {
|
||||
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
|
||||
const TEST_PROMPT: &str = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation.";
|
||||
|
||||
let configured = self
|
||||
.store
|
||||
.model(model_hash)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
|
||||
let mut model = ModelSpec::new(model_hash);
|
||||
configured.configure(&mut model);
|
||||
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
|
||||
if model_hash.starts_with(crate::plugin::ADAPTER_ID_PREFIX) {
|
||||
let descriptor = self.plugins.model_descriptor(model_hash).await?;
|
||||
model.display_name = Some(descriptor.display_name);
|
||||
model.max_output_tokens = Some(descriptor.max_output_tokens.unwrap_or(65_536));
|
||||
} else {
|
||||
let configured = self
|
||||
.store
|
||||
.model(model_hash)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
|
||||
configured.configure(&mut model);
|
||||
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
|
||||
}
|
||||
let call_id = format!("model-test-{}", uuid::Uuid::new_v4());
|
||||
let invocation = ModelInvocation {
|
||||
call_id: call_id.clone(),
|
||||
@@ -409,7 +516,7 @@ impl ControlService {
|
||||
}
|
||||
|
||||
pub async fn discover_models(&self, input: &ModelDiscoveryInput) -> Result<DiscoveredModels> {
|
||||
let client = crate::network::client(&self.store).await?;
|
||||
let client = self.clients.default_client().await?;
|
||||
let base_url = crate::model::normalize_request_url(&input.base_url)?;
|
||||
discover_models_from_endpoint(
|
||||
&client,
|
||||
@@ -576,7 +683,9 @@ impl ControlService {
|
||||
}
|
||||
|
||||
pub async fn set_proxy_settings(&self, settings: ProxySettingsInput) -> Result<ProxySettings> {
|
||||
self.store.set_proxy_settings(settings).await
|
||||
let settings = self.store.set_proxy_settings(settings).await?;
|
||||
self.clients.invalidate().await;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub async fn tab_settings(&self) -> Result<TabSettings> {
|
||||
@@ -699,6 +808,11 @@ async fn discover_models_from_endpoint(
|
||||
ProviderType::Anthropic => {
|
||||
anthropic_models(client, base_url, api_key, custom_headers).await?
|
||||
}
|
||||
ProviderType::Plugin => {
|
||||
return Err(Error::Config(
|
||||
"plugin providers discover models through their plugin".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
models.sort();
|
||||
models.dedup();
|
||||
|
||||
@@ -278,19 +278,17 @@ impl CheckpointBuilder {
|
||||
),
|
||||
});
|
||||
if let Some(trace) = handle.trace() {
|
||||
trace
|
||||
.artifact(
|
||||
"checkpoint",
|
||||
"byok_server",
|
||||
&checkpoint.encode_to_vec(),
|
||||
serde_json::json!({
|
||||
"root_message_count": checkpoint.root_prompt_messages_json.len(),
|
||||
"turn_count": checkpoint.turns.len(),
|
||||
"pending_tool_call_count": checkpoint.pending_tool_calls.len(),
|
||||
"emit_status": if result.is_ok() { "sent" } else { "error" },
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
trace.artifact(
|
||||
"checkpoint",
|
||||
"byok_server",
|
||||
&checkpoint.encode_to_vec(),
|
||||
serde_json::json!({
|
||||
"root_message_count": checkpoint.root_prompt_messages_json.len(),
|
||||
"turn_count": checkpoint.turns.len(),
|
||||
"pending_tool_call_count": checkpoint.pending_tool_calls.len(),
|
||||
"emit_status": if result.is_ok() { "sent" } else { "error" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -102,6 +102,12 @@ pub fn decode_pending(value: &str) -> Result<RecoveredToolRound> {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, call)| {
|
||||
let argument_error = wire
|
||||
.pointer("/providerOptions/cursor/pendingToolExecutionContracts")
|
||||
.and_then(|contracts| contracts.get(&call.call_id))
|
||||
.and_then(|contract| contract.get("argumentError"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
Ok(ToolCall {
|
||||
index,
|
||||
call_id: call.call_id,
|
||||
@@ -109,6 +115,7 @@ pub fn decode_pending(value: &str) -> Result<RecoveredToolRound> {
|
||||
name: call.name,
|
||||
arguments_text: serde_json::to_string(&call.arguments)?,
|
||||
arguments: call.arguments,
|
||||
argument_error,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
@@ -71,6 +71,7 @@ pub fn staged_tool_round(
|
||||
allowed_tools,
|
||||
dynamic_tools,
|
||||
started_at_ms,
|
||||
tool_calls: Some(calls),
|
||||
}),
|
||||
)?)?)
|
||||
}
|
||||
@@ -96,6 +97,7 @@ pub fn staged_final(
|
||||
allowed_tools,
|
||||
dynamic_tools,
|
||||
started_at_ms,
|
||||
tool_calls: None,
|
||||
}),
|
||||
)?)?)
|
||||
}
|
||||
@@ -105,6 +107,7 @@ pub(super) struct PendingContext<'a> {
|
||||
allowed_tools: &'a [String],
|
||||
dynamic_tools: &'a HashSet<String>,
|
||||
started_at_ms: u64,
|
||||
tool_calls: Option<&'a [ToolCall]>,
|
||||
}
|
||||
|
||||
pub(super) fn wire_message(
|
||||
@@ -132,16 +135,23 @@ pub(super) fn wire_message(
|
||||
calls
|
||||
.iter()
|
||||
.map(|call| {
|
||||
(
|
||||
call.call_id.clone(),
|
||||
json!({
|
||||
"toolCallId": call.call_id,
|
||||
"outerToolName": call.name,
|
||||
"toolIdentifier": tool_identifier(&call.name, pending.dynamic_tools),
|
||||
"isDynamic": pending.dynamic_tools.contains(&call.name),
|
||||
"allowedToolNames": pending.allowed_tools,
|
||||
}),
|
||||
)
|
||||
let mut contract = json!({
|
||||
"toolCallId": call.call_id,
|
||||
"outerToolName": call.name,
|
||||
"toolIdentifier": tool_identifier(&call.name, pending.dynamic_tools),
|
||||
"isDynamic": pending.dynamic_tools.contains(&call.name),
|
||||
"allowedToolNames": pending.allowed_tools,
|
||||
});
|
||||
if let Some(error) = pending
|
||||
.tool_calls
|
||||
.and_then(|calls| {
|
||||
calls.iter().find(|candidate| candidate.call_id == call.call_id)
|
||||
})
|
||||
.and_then(|call| call.argument_error.as_deref())
|
||||
{
|
||||
contract["argumentError"] = Value::String(error.into());
|
||||
}
|
||||
(call.call_id.clone(), contract)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
|
||||
name: "Read".into(),
|
||||
arguments_text: r#"{"path":"/a"}"#.into(),
|
||||
arguments: json!({"path":"/a"}),
|
||||
argument_error: Some("Read arguments are not valid JSON".into()),
|
||||
},
|
||||
ToolCall {
|
||||
index: 1,
|
||||
@@ -38,6 +39,7 @@ fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
|
||||
name: "Grep".into(),
|
||||
arguments_text: r#"{"pattern":"x"}"#.into(),
|
||||
arguments: json!({"pattern":"x"}),
|
||||
argument_error: None,
|
||||
},
|
||||
];
|
||||
let pending = staged_tool_round(
|
||||
@@ -55,6 +57,10 @@ fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
|
||||
wire["providerOptions"]["cursor"]["pendingToolExecutionContracts"]["a"]["toolIdentifier"],
|
||||
"READ"
|
||||
);
|
||||
assert_eq!(
|
||||
wire["providerOptions"]["cursor"]["pendingToolExecutionContracts"]["a"]["argumentError"],
|
||||
"Read arguments are not valid JSON"
|
||||
);
|
||||
assert_eq!(wire["role"], "assistant");
|
||||
assert_eq!(
|
||||
wire["providerOptions"]["cursor"]["pendingToolExecutionContracts"]
|
||||
@@ -77,6 +83,10 @@ fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
|
||||
assert_eq!(recovered.assistant.replay_state, Some(replay_state));
|
||||
assert_eq!(recovered.calls.len(), 2);
|
||||
assert_eq!(recovered.calls[0].call_id, "a");
|
||||
assert_eq!(
|
||||
recovered.calls[0].argument_error.as_deref(),
|
||||
Some("Read arguments are not valid JSON")
|
||||
);
|
||||
assert_eq!(recovered.calls[1].call_id, "b");
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@ impl StepBuffer {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn finish_model_attempt(&mut self) {
|
||||
self.finish_text();
|
||||
self.finish_thinking(Duration::ZERO);
|
||||
}
|
||||
|
||||
pub fn discard_model_output(&mut self) {
|
||||
self.text.clear();
|
||||
self.thinking.clear();
|
||||
@@ -101,6 +106,28 @@ impl StepBuffer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_attempt_output_is_retained_for_the_next_checkpoint() {
|
||||
let mut buffer = StepBuffer::default();
|
||||
buffer.text_delta("partial answer");
|
||||
buffer.thinking_delta("partial reasoning");
|
||||
|
||||
buffer.finish_model_attempt();
|
||||
|
||||
let steps = buffer.take().steps;
|
||||
assert_eq!(steps.len(), 2);
|
||||
assert!(matches!(
|
||||
&steps[0].message,
|
||||
Some(pb::conversation_step::Message::AssistantMessage(message))
|
||||
if message.text == "partial answer"
|
||||
));
|
||||
assert!(matches!(
|
||||
&steps[1].message,
|
||||
Some(pb::conversation_step::Message::ThinkingMessage(message))
|
||||
if message.text == "partial reasoning"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_model_output_is_not_persisted_as_checkpoint_steps() {
|
||||
let mut buffer = StepBuffer::default();
|
||||
|
||||
@@ -3,7 +3,7 @@ use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::{checkpoint::PendingSteps, protocol::proto::agent::v1 as pb},
|
||||
model::CanonicalMessage,
|
||||
model::{estimate_context_tokens, project_messages, CanonicalMessage, PromptSpec},
|
||||
store::{BlobEdge, BlobId},
|
||||
Error, Result,
|
||||
};
|
||||
@@ -85,6 +85,12 @@ impl CheckpointBuilder {
|
||||
.push(archive_id.as_bytes().to_vec());
|
||||
}
|
||||
self.base.self_summary_count = self.base.self_summary_count.saturating_add(1);
|
||||
let projected = project_messages(messages)?;
|
||||
let prompt = PromptSpec {
|
||||
instructions: self.instructions.clone(),
|
||||
tools: self.tool_definitions.clone(),
|
||||
};
|
||||
self.record_context_tokens(Some(estimate_context_tokens(&prompt, &projected)));
|
||||
if let Some(details) = self.base.token_details.as_mut() {
|
||||
details.breakdown = Some(crate::cursor::services::usage::breakdown(
|
||||
details.used_tokens,
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
protocol::proto::agent::v1 as pb, services::context_sync::RequestContextSynchronizer,
|
||||
tools::runtime::McpRoute,
|
||||
},
|
||||
model::ToolDefinition,
|
||||
model::{normalize_tool_name, ToolDefinition},
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
@@ -146,6 +146,37 @@ async fn decode_part<T: Message + Default>(
|
||||
.map_err(|error| Error::Protocol(format!("invalid {name} context Blob: {error}")))
|
||||
}
|
||||
|
||||
/// 把本地 md 规则目录(rules 服务的存储)合并进请求上下文,
|
||||
/// 使 BYOK 运行在 IDE 未携带这些规则时也能消费它们。
|
||||
/// 与 IDE 已发规则按内容去重;读取失败只告警,不影响运行。
|
||||
pub fn merge_local_rules(context: &mut pb::RequestContext, rules_dir: &Path) {
|
||||
let records = match crate::cursor::services::knowledge::RuleStore::open(rules_dir.into())
|
||||
.and_then(|store| store.list())
|
||||
{
|
||||
Ok(records) => records,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "cannot read local rules; continuing without them");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let existing = context
|
||||
.rules
|
||||
.iter()
|
||||
.chain(context.non_file_rules.iter())
|
||||
.map(|rule| rule.content.trim().to_owned())
|
||||
.chain(context.cloud_rule.iter().map(|rule| rule.trim().to_owned()))
|
||||
.collect::<HashSet<_>>();
|
||||
for record in records {
|
||||
if record.knowledge.trim().is_empty() || existing.contains(record.knowledge.trim()) {
|
||||
continue;
|
||||
}
|
||||
context.non_file_rules.push(pb::CursorRule {
|
||||
content: record.knowledge,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_context(request: &pb::AgentRunRequest) -> Option<&pb::RequestContext> {
|
||||
let action = request.action.as_ref()?;
|
||||
action
|
||||
@@ -462,7 +493,7 @@ pub fn dynamic_mcp(
|
||||
})?),
|
||||
};
|
||||
let parameters = normalize_mcp_parameters(&wire.name, parameters)?;
|
||||
let name = model_tool_name(&wire.name);
|
||||
let name = normalize_tool_name(&wire.name);
|
||||
let definition = ToolDefinition {
|
||||
name: name.clone(),
|
||||
description: wire.description.clone(),
|
||||
@@ -522,18 +553,6 @@ fn invalid_mcp_parameters(tool_name: &str) -> Error {
|
||||
))
|
||||
}
|
||||
|
||||
fn model_tool_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
|
||||
character
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prost_value(value: &prost_types::Value) -> Value {
|
||||
use prost_types::value::Kind;
|
||||
match value.kind.as_ref() {
|
||||
@@ -563,3 +582,48 @@ fn xml(value: &str) -> String {
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule(content: &str) -> pb::CursorRule {
|
||||
pb::CursorRule {
|
||||
content: content.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_local_rules_appends_and_dedupes_by_content() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join("a.md"), "shared rule").unwrap();
|
||||
std::fs::write(directory.path().join("b.md"), "local only rule").unwrap();
|
||||
std::fs::write(directory.path().join("c.md"), " \n").unwrap();
|
||||
|
||||
let mut context = pb::RequestContext {
|
||||
non_file_rules: vec![rule(" shared rule ")],
|
||||
..Default::default()
|
||||
};
|
||||
merge_local_rules(&mut context, directory.path());
|
||||
|
||||
let contents = context
|
||||
.non_file_rules
|
||||
.iter()
|
||||
.map(|rule| rule.content.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
contents,
|
||||
[" shared rule ", "local only rule"],
|
||||
"IDE-sent duplicate is kept once and blank local rules are skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_local_rules_survives_a_missing_directory() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut context = pb::RequestContext::default();
|
||||
merge_local_rules(&mut context, &directory.path().join("nested/rules"));
|
||||
assert!(context.non_file_rules.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(crate) struct PrepareDependencies<'a> {
|
||||
pub checkpoint: &'a CheckpointBuilder,
|
||||
pub blob_sync: &'a BlobSynchronizer,
|
||||
pub context_sync: &'a RequestContextSynchronizer,
|
||||
pub local_rules_dir: Option<&'a std::path::Path>,
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare(
|
||||
@@ -64,6 +65,7 @@ pub(crate) async fn prepare(
|
||||
checkpoint,
|
||||
blob_sync,
|
||||
context_sync,
|
||||
local_rules_dir,
|
||||
} = dependencies;
|
||||
checkpoint
|
||||
.import_prefetched(&request.pre_fetched_blobs)
|
||||
@@ -115,11 +117,13 @@ pub(crate) async fn prepare(
|
||||
"selected_source": "root_prompt_messages_json",
|
||||
});
|
||||
let encoded = serde_json::to_vec(&summary)?;
|
||||
trace
|
||||
.artifact("history_projection", "byok_server", &encoded, summary)
|
||||
.await;
|
||||
trace.artifact("history_projection", "byok_server", &encoded, summary);
|
||||
}
|
||||
let request_context = context::hydrate(request, context_sync).await?;
|
||||
let mut request_context = context::hydrate(request, context_sync).await?;
|
||||
if let Some(rules_dir) = local_rules_dir {
|
||||
context::merge_local_rules(&mut request_context, rules_dir);
|
||||
}
|
||||
let request_context = request_context;
|
||||
let ActionProjection {
|
||||
mode: mode_number,
|
||||
mut turn_user,
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
//! Defines commands accepted by a Conversation runtime.
|
||||
|
||||
use crate::cursor::protocol::proto::agent::v1 as pb;
|
||||
use crate::{cursor::protocol::proto::agent::v1 as pb, Error};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RunFinish {
|
||||
TurnCompleted,
|
||||
Transport(TransportFinish),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransportFinish {
|
||||
Success,
|
||||
Failed(Error),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransportCommand {
|
||||
@@ -8,6 +21,9 @@ pub enum TransportCommand {
|
||||
seqno: i64,
|
||||
message: Box<pb::AgentClientMessage>,
|
||||
},
|
||||
RunFinished {
|
||||
generation: u64,
|
||||
finish: RunFinish,
|
||||
},
|
||||
Disconnect,
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
protocol::proto::agent::v1 as pb,
|
||||
services::blob_sync::BlobSynchronizer,
|
||||
tools::{
|
||||
codec,
|
||||
codec, compat,
|
||||
runtime::CursorToolRuntime,
|
||||
stream::ToolCallStream,
|
||||
tool_call_result::{ToolCompletion, ToolResultReceiver},
|
||||
@@ -34,7 +34,7 @@ use crate::{
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{CompiledMessages, ConversationRegistry, MessageDelivery};
|
||||
use super::{CompiledMessages, ConversationRegistry, MessageDelivery, RunFinish, TransportFinish};
|
||||
use crate::cursor::transport::TransportHandle;
|
||||
|
||||
pub struct ConversationOutput {
|
||||
@@ -110,7 +110,7 @@ impl ConversationOutput {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(mut self) -> Result<()> {
|
||||
pub async fn run(mut self) -> Result<RunFinish> {
|
||||
let result = self.run_inner().await;
|
||||
if let Err(error) = &result {
|
||||
if !self.superseded.is_cancelled() {
|
||||
@@ -143,7 +143,7 @@ impl ConversationOutput {
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_inner(&mut self) -> Result<()> {
|
||||
async fn run_inner(&mut self) -> Result<RunFinish> {
|
||||
if self.context.compacting {
|
||||
self.handle.emit(&events::summary_started())?;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ impl ConversationOutput {
|
||||
if self.superseded.is_cancelled() {
|
||||
worker.abort();
|
||||
self.abort_execs().await;
|
||||
return Ok(());
|
||||
return Ok(RunFinish::Transport(TransportFinish::Cancelled));
|
||||
}
|
||||
let input = if let Ok(action) = self.runtime_actions.try_recv() {
|
||||
Input::RuntimeAction(Some(Box::new(action)))
|
||||
@@ -187,7 +187,7 @@ impl ConversationOutput {
|
||||
_ = self.superseded.cancelled() => {
|
||||
worker.abort();
|
||||
self.abort_execs().await;
|
||||
return Ok(());
|
||||
return Ok(RunFinish::Transport(TransportFinish::Cancelled));
|
||||
}
|
||||
action = self.runtime_actions.recv() => Input::RuntimeAction(action.map(Box::new)),
|
||||
event = self.core.events.recv() => Input::Event(event),
|
||||
@@ -264,6 +264,32 @@ impl ConversationOutput {
|
||||
streams.clear();
|
||||
presentation.discard_model_output();
|
||||
}
|
||||
RunEvent::ModelAttemptFailed { attempt, message } => {
|
||||
tracing::warn!(
|
||||
run_id = %self.run.run_id(),
|
||||
attempt,
|
||||
%message,
|
||||
"retrying model call from current checkpoint"
|
||||
);
|
||||
presentation.finish_model_attempt();
|
||||
for call in calls.values_mut() {
|
||||
if call.arguments.is_null() {
|
||||
call.arguments = serde_json::from_str(&call.arguments_text)
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
}
|
||||
let completion = compat::failure_with_message(
|
||||
call,
|
||||
format!("Model attempt failed before tool completion: {message}"),
|
||||
);
|
||||
self.handle
|
||||
.emit(&codec::tool_completed(call, &completion))?;
|
||||
presentation.tool_completed(&completion);
|
||||
}
|
||||
response_text.clear();
|
||||
response_thinking.clear();
|
||||
calls.clear();
|
||||
streams.clear();
|
||||
}
|
||||
RunEvent::TextStart => {}
|
||||
RunEvent::TextEnd => {
|
||||
if !self.context.compacting {
|
||||
@@ -312,6 +338,7 @@ impl ConversationOutput {
|
||||
name: name.clone(),
|
||||
arguments_text: String::new(),
|
||||
arguments: serde_json::Value::Null,
|
||||
argument_error: None,
|
||||
};
|
||||
self.emit_model_event(
|
||||
crate::provider::ModelEvent::ToolCallStart {
|
||||
@@ -335,15 +362,50 @@ impl ConversationOutput {
|
||||
let stream = streams.get_mut(&index).ok_or_else(|| {
|
||||
Error::Protocol(format!("missing Cursor tool stream: {index}"))
|
||||
})?;
|
||||
for message in stream.arguments_delta(call, &delta)? {
|
||||
self.handle.emit(&message)?;
|
||||
match stream.arguments_delta(call, &delta) {
|
||||
Ok(messages) => {
|
||||
for message in messages {
|
||||
self.handle.emit(&message)?;
|
||||
}
|
||||
}
|
||||
Err(Error::Protocol(message)) => {
|
||||
tracing::warn!(
|
||||
call_id = %call.call_id,
|
||||
%message,
|
||||
"ignoring invalid streaming tool arguments until completion"
|
||||
);
|
||||
}
|
||||
Err(Error::Json(error)) => {
|
||||
tracing::warn!(
|
||||
call_id = %call.call_id,
|
||||
%error,
|
||||
"ignoring invalid streaming tool arguments until completion"
|
||||
);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
RunEvent::ToolCallEnd { index } => {
|
||||
let call = calls.get_mut(&index).ok_or_else(|| {
|
||||
Error::Protocol(format!("unknown completed tool index: {index}"))
|
||||
})?;
|
||||
call.arguments = serde_json::from_str(&call.arguments_text)?;
|
||||
// A tool call with no arguments streams no argument text.
|
||||
// Treat empty text as an empty object, matching the model
|
||||
// cycle, instead of failing the run on `from_str("")`.
|
||||
call.arguments = if call.arguments_text.trim().is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::from_str(&call.arguments_text)
|
||||
.unwrap_or_else(|_| serde_json::json!({}))
|
||||
};
|
||||
}
|
||||
RunEvent::UsageSnapshot(usage) => {
|
||||
if !self.context.compacting {
|
||||
if let Some(output_tokens) = usage.output_tokens {
|
||||
self.handle.emit(&events::token_delta(output_tokens))?;
|
||||
}
|
||||
context_tokens = usage.context_input_tokens;
|
||||
}
|
||||
}
|
||||
RunEvent::Usage(usage) => {
|
||||
if !self.context.compacting {
|
||||
@@ -352,10 +414,7 @@ impl ConversationOutput {
|
||||
}
|
||||
}
|
||||
if !self.context.compacting {
|
||||
context_tokens = usage
|
||||
.input_tokens
|
||||
.zip(usage.output_tokens)
|
||||
.and_then(|(input, output)| input.checked_add(output));
|
||||
context_tokens = usage.context_input_tokens;
|
||||
}
|
||||
match &mut turn_usage {
|
||||
Some(total) => *total += usage,
|
||||
@@ -424,21 +483,28 @@ impl ConversationOutput {
|
||||
streams.clear();
|
||||
}
|
||||
if let CommitCause::RuntimeEvent { event_id } = &state.cause {
|
||||
if let Some(injection_id) = event_id.strip_prefix("inject-context:") {
|
||||
if let Some(pending) = self.pending_injections.remove(injection_id)
|
||||
{
|
||||
let delivered_at_ms = crate::cursor::tools::runtime::now_ms()
|
||||
.min(i64::MAX as u64)
|
||||
as i64;
|
||||
self.handle.emit(&events::context_injection_delivered(
|
||||
injection_id.to_owned(),
|
||||
pending.delivery_batch_id.clone(),
|
||||
delivered_at_ms,
|
||||
))?;
|
||||
if let Some(user_message) = pending.user_message {
|
||||
self.handle
|
||||
.emit(&events::user_message_appended(user_message))?;
|
||||
}
|
||||
// Injections key `pending_injections` by their raw
|
||||
// injection id and commit under `inject-context:{id}`,
|
||||
// while runtime user messages key it by (and commit
|
||||
// under) the full `user-message:{id}` event id. Strip
|
||||
// the injection prefix when present and otherwise use
|
||||
// the event id verbatim so both are cleared and emit
|
||||
// their delivered/appended events.
|
||||
let injection_id = event_id
|
||||
.strip_prefix("inject-context:")
|
||||
.unwrap_or(event_id.as_str());
|
||||
if let Some(pending) = self.pending_injections.remove(injection_id) {
|
||||
let delivered_at_ms = crate::cursor::tools::runtime::now_ms()
|
||||
.min(i64::MAX as u64)
|
||||
as i64;
|
||||
self.handle.emit(&events::context_injection_delivered(
|
||||
injection_id.to_owned(),
|
||||
pending.delivery_batch_id.clone(),
|
||||
delivered_at_ms,
|
||||
))?;
|
||||
if let Some(user_message) = pending.user_message {
|
||||
self.handle
|
||||
.emit(&events::user_message_appended(user_message))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,6 +575,7 @@ impl ConversationOutput {
|
||||
.map_err(|_| Error::Protocol("checkpoint worker stopped".into()))?
|
||||
{
|
||||
Ok(checkpoint) => {
|
||||
context_tokens = checkpoint_context_tokens(&checkpoint);
|
||||
compaction_checkpoint = Some(checkpoint);
|
||||
state.barrier.complete(Ok(()));
|
||||
}
|
||||
@@ -652,7 +719,7 @@ impl ConversationOutput {
|
||||
if self.superseded.is_cancelled() {
|
||||
worker.abort();
|
||||
self.abort_execs().await;
|
||||
return Ok(());
|
||||
return Ok(RunFinish::Transport(TransportFinish::Cancelled));
|
||||
}
|
||||
return match outcome {
|
||||
RunOutcome::Completed => {
|
||||
@@ -668,8 +735,7 @@ impl ConversationOutput {
|
||||
for _ in 0..3 {
|
||||
self.checkpoint.publish(&self.handle, &checkpoint).await?;
|
||||
}
|
||||
finish_success(&self.handle);
|
||||
return Ok(());
|
||||
return Ok(RunFinish::TurnCompleted);
|
||||
}
|
||||
let checkpoints = final_checkpoint.take().ok_or_else(|| {
|
||||
Error::Protocol("Completed without final state".into())
|
||||
@@ -685,18 +751,19 @@ impl ConversationOutput {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoints.settled)),
|
||||
})?;
|
||||
finish_success(&self.handle);
|
||||
Ok(())
|
||||
Ok(RunFinish::TurnCompleted)
|
||||
}
|
||||
RunOutcome::Cancelled => {
|
||||
worker.abort();
|
||||
self.abort_execs().await;
|
||||
finish_cancelled(&self.handle)
|
||||
Ok(RunFinish::Transport(TransportFinish::Cancelled))
|
||||
}
|
||||
RunOutcome::Failed(failure) => {
|
||||
worker.abort();
|
||||
self.abort_execs().await;
|
||||
finish_failed(&self.handle, &cursor_error(failure))
|
||||
Ok(RunFinish::Transport(TransportFinish::Failed(cursor_error(
|
||||
failure,
|
||||
))))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1015,10 +1082,34 @@ pub(crate) fn finish_cancelled(handle: &TransportHandle) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checkpoint_context_tokens(checkpoint: &pb::ConversationStateStructure) -> Option<u64> {
|
||||
checkpoint
|
||||
.token_details
|
||||
.as_ref()
|
||||
.map(|details| u64::from(details.used_tokens))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::accept_tool_completion;
|
||||
use crate::{run::CommandResult, Error};
|
||||
use super::{accept_tool_completion, checkpoint_context_tokens};
|
||||
use crate::{cursor::protocol::proto::agent::v1 as pb, run::CommandResult, Error};
|
||||
|
||||
#[test]
|
||||
fn compacted_checkpoint_replaces_the_in_memory_context_usage() {
|
||||
let compacted = pb::ConversationStateStructure {
|
||||
token_details: Some(pb::ConversationTokenDetails {
|
||||
used_tokens: 20_000,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(checkpoint_context_tokens(&compacted), Some(20_000));
|
||||
assert_eq!(
|
||||
checkpoint_context_tokens(&pb::ConversationStateStructure::default()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_and_ended_runs_ignore_known_tool_completions() {
|
||||
|
||||
@@ -26,6 +26,8 @@ pub(crate) struct ConversationDependencies {
|
||||
pub provider: Arc<dyn Provider>,
|
||||
pub compiler: PromptCompiler,
|
||||
pub web_cache: WebCache,
|
||||
/// 本地 rules 服务的 md 存储目录;编译请求上下文时合并其中的规则。
|
||||
pub local_rules_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
struct RegistryInner {
|
||||
@@ -47,6 +49,7 @@ impl ConversationRegistry {
|
||||
provider: Arc<dyn Provider>,
|
||||
compiler: PromptCompiler,
|
||||
web_cache: WebCache,
|
||||
local_rules_dir: Option<std::path::PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RegistryInner {
|
||||
@@ -58,6 +61,7 @@ impl ConversationRegistry {
|
||||
provider,
|
||||
compiler,
|
||||
web_cache,
|
||||
local_rules_dir,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -11,25 +11,29 @@ use crate::{
|
||||
protocol::proto::agent::v1 as pb,
|
||||
services::{blob_sync::BlobSynchronizer, context_sync::RequestContextSynchronizer},
|
||||
tools::{
|
||||
codec,
|
||||
codec, compat,
|
||||
runtime::CursorToolRuntime,
|
||||
tool_call_result::{tool_result_channel, ToolResultReceiver, ToolResultSender},
|
||||
ClientToolEvent, ToolDispatcher,
|
||||
},
|
||||
transport::{OrderedInbox, TransportHandle},
|
||||
},
|
||||
run::{CommandResult, RunEngine, RunHandle},
|
||||
run::{CommandResult, RunEngine, RunHandle, RunPhase},
|
||||
};
|
||||
|
||||
use super::{
|
||||
CompiledMessages, ConversationDependencies, ConversationOutput, ConversationOutputDependencies,
|
||||
ConversationRegistry, MessageDelivery, TransportCommand,
|
||||
ConversationRegistry, MessageDelivery, RunFinish, TransportCommand, TransportFinish,
|
||||
};
|
||||
|
||||
pub struct ConversationRuntime;
|
||||
|
||||
const CONTINUATION_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RunGeneration {
|
||||
id: u64,
|
||||
request: pb::AgentRunRequest,
|
||||
superseded: CancellationToken,
|
||||
finished: CancellationToken,
|
||||
run: Arc<parking_lot::Mutex<Option<RunHandle>>>,
|
||||
@@ -41,6 +45,14 @@ struct RunGeneration {
|
||||
|
||||
struct FinishGeneration(CancellationToken);
|
||||
|
||||
struct TransportActorGuard(TransportHandle);
|
||||
|
||||
impl Drop for TransportActorGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.close_transport();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FinishGeneration {
|
||||
fn drop(&mut self) {
|
||||
self.0.cancel();
|
||||
@@ -54,6 +66,7 @@ impl ConversationRuntime {
|
||||
mut receiver: mpsc::Receiver<TransportCommand>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let _actor_guard = TransportActorGuard(handle.clone());
|
||||
let dependencies = registry.dependencies().clone();
|
||||
let blob_sync = BlobSynchronizer::new(
|
||||
handle.request_id().into(),
|
||||
@@ -65,19 +78,70 @@ impl ConversationRuntime {
|
||||
let context_sync =
|
||||
RequestContextSynchronizer::new(handle.clone(), dependencies.store.clone());
|
||||
let mut current = None::<RunGeneration>;
|
||||
let mut next_generation = 1_u64;
|
||||
let mut pending_finish = None::<(u64, TransportFinish)>;
|
||||
let mut draining = false;
|
||||
let mut waiting_for_action = false;
|
||||
loop {
|
||||
let command = match receiver.recv().await {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
handle.mark_disconnected();
|
||||
if let Some(generation) = current.as_ref() {
|
||||
generation.superseded.cancel();
|
||||
if let Some(run) = generation.run.lock().clone() {
|
||||
run.cancel();
|
||||
let command = if draining {
|
||||
if !handle.admissions_drained() {
|
||||
tokio::select! {
|
||||
command = receiver.recv() => match command {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
finish_pending(&handle, ¤t, pending_finish.take());
|
||||
break;
|
||||
}
|
||||
},
|
||||
_ = handle.wait_admissions_drained() => continue,
|
||||
}
|
||||
} else {
|
||||
handle.mark_draining();
|
||||
match receiver.try_recv() {
|
||||
Ok(command) => command,
|
||||
Err(mpsc::error::TryRecvError::Empty)
|
||||
| Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
finish_pending(&handle, ¤t, pending_finish.take());
|
||||
break;
|
||||
}
|
||||
}
|
||||
super::finish_cancelled(&handle).ok();
|
||||
break;
|
||||
}
|
||||
} else if waiting_for_action {
|
||||
tokio::select! {
|
||||
command = receiver.recv() => match command {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
handle.mark_disconnected();
|
||||
super::finish_success(&handle);
|
||||
break;
|
||||
}
|
||||
},
|
||||
_ = tokio::time::sleep(CONTINUATION_IDLE_TIMEOUT) => {
|
||||
let Some(generation) = current.as_ref() else {
|
||||
super::finish_success(&handle);
|
||||
break;
|
||||
};
|
||||
handle.begin_close();
|
||||
pending_finish = Some((generation.id, TransportFinish::Success));
|
||||
draining = true;
|
||||
waiting_for_action = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match receiver.recv().await {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
handle.mark_disconnected();
|
||||
if let Some(generation) = current.as_ref() {
|
||||
generation.superseded.cancel();
|
||||
if let Some(run) = generation.run.lock().clone() {
|
||||
run.cancel();
|
||||
}
|
||||
}
|
||||
super::finish_cancelled(&handle).ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
match command {
|
||||
@@ -92,11 +156,41 @@ impl ConversationRuntime {
|
||||
let _ = handle.emit(&codec::abort(id));
|
||||
}
|
||||
}
|
||||
super::finish_cancelled(&handle).ok();
|
||||
let turn_completed = waiting_for_action
|
||||
|| current.as_ref().is_some_and(|generation| {
|
||||
generation
|
||||
.run
|
||||
.lock()
|
||||
.as_ref()
|
||||
.is_none_or(|run| run.phase() != RunPhase::Running)
|
||||
});
|
||||
if turn_completed {
|
||||
super::finish_success(&handle);
|
||||
} else {
|
||||
super::finish_cancelled(&handle).ok();
|
||||
}
|
||||
break;
|
||||
}
|
||||
TransportCommand::Close => {
|
||||
break;
|
||||
TransportCommand::RunFinished { generation, finish } => {
|
||||
if !current
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.id == generation)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match finish {
|
||||
RunFinish::TurnCompleted => {
|
||||
pending_finish = None;
|
||||
draining = false;
|
||||
waiting_for_action = true;
|
||||
}
|
||||
RunFinish::Transport(finish) => {
|
||||
waiting_for_action = false;
|
||||
handle.begin_close();
|
||||
pending_finish = Some((generation, finish));
|
||||
draining = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
TransportCommand::Append { seqno, message } => {
|
||||
for (_seqno, message) in inbox.push(seqno, *message) {
|
||||
@@ -105,6 +199,12 @@ impl ConversationRuntime {
|
||||
Some(pb::agent_client_message::Message::RunRequest(
|
||||
request,
|
||||
)) => {
|
||||
waiting_for_action = false;
|
||||
if draining {
|
||||
handle.reopen();
|
||||
draining = false;
|
||||
pending_finish = None;
|
||||
}
|
||||
if let Some(conversation_id) =
|
||||
request.conversation_id.as_deref()
|
||||
{
|
||||
@@ -117,60 +217,21 @@ impl ConversationRuntime {
|
||||
"invalid Cursor conversation id"
|
||||
);
|
||||
let _ = super::finish_failed(&handle, &error);
|
||||
let _ =
|
||||
handle.command(TransportCommand::Close).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let previous_finished =
|
||||
if let Some(previous) = current.take() {
|
||||
previous.superseded.cancel();
|
||||
if let Some(run) = previous.run.lock().clone() {
|
||||
run.cancel();
|
||||
}
|
||||
for id in previous
|
||||
.tool_runtime
|
||||
.interrupt_for_run_replacement()
|
||||
.await
|
||||
{
|
||||
let _ = handle.emit(&codec::abort(id));
|
||||
}
|
||||
Some(previous.finished.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (results, result_receiver) = tool_result_channel();
|
||||
let (runtime_actions, runtime_action_receiver) =
|
||||
mpsc::unbounded_channel::<compile::RuntimeAction>();
|
||||
let tool_runtime = tool_runtime_factory.next_run();
|
||||
let tools = ToolDispatcher::with_results(
|
||||
tool_runtime.clone(),
|
||||
results.clone(),
|
||||
dependencies.store.clone(),
|
||||
dependencies.web_cache.clone(),
|
||||
);
|
||||
let generation = RunGeneration {
|
||||
superseded: CancellationToken::new(),
|
||||
finished: CancellationToken::new(),
|
||||
run: Arc::new(parking_lot::Mutex::new(None)),
|
||||
results,
|
||||
runtime_actions,
|
||||
tool_runtime,
|
||||
tools,
|
||||
};
|
||||
current = Some(generation.clone());
|
||||
spawn_run_request(
|
||||
registry.clone(),
|
||||
handle.clone(),
|
||||
start_generation(
|
||||
®istry,
|
||||
&handle,
|
||||
&dependencies,
|
||||
&blob_sync,
|
||||
&context_sync,
|
||||
&tool_runtime_factory,
|
||||
&mut current,
|
||||
&mut next_generation,
|
||||
request,
|
||||
dependencies.clone(),
|
||||
blob_sync.clone(),
|
||||
context_sync.clone(),
|
||||
generation,
|
||||
previous_finished,
|
||||
result_receiver,
|
||||
runtime_action_receiver,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some(pb::agent_client_message::Message::ExecClientMessage(
|
||||
message,
|
||||
@@ -262,17 +323,18 @@ impl ConversationRuntime {
|
||||
.take_exec(throw.id)
|
||||
.await
|
||||
{
|
||||
Some(pending) => generation.results.send_error(
|
||||
crate::Error::Protocol(format!(
|
||||
"Exec {} failed: {}",
|
||||
pending.call.call_id, throw.error
|
||||
)),
|
||||
Some(pending) => generation.results.send(
|
||||
compat::failure_with_message(
|
||||
&pending.call,
|
||||
format!(
|
||||
"Exec {} failed: {}",
|
||||
pending.call.call_id, throw.error
|
||||
),
|
||||
),
|
||||
),
|
||||
None => generation.results.send_error(
|
||||
crate::Error::Protocol(format!(
|
||||
"unknown ExecClientThrow id: {}",
|
||||
throw.id
|
||||
)),
|
||||
None => tracing::warn!(
|
||||
id = throw.id,
|
||||
"ignoring failure for unknown tool execution"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -330,27 +392,48 @@ impl ConversationRuntime {
|
||||
// return an explicit Protocol Error rather than falling through silently.
|
||||
Some(
|
||||
pb::agent_client_message::Message::ConversationAction(
|
||||
action,
|
||||
conversation_action,
|
||||
),
|
||||
) => match action.action {
|
||||
) => match conversation_action.action.clone() {
|
||||
Some(
|
||||
pb::conversation_action::Action::UserMessageAction(
|
||||
action,
|
||||
),
|
||||
) => {
|
||||
let Some(generation) = current.as_ref() else {
|
||||
let delivered_to_active_run =
|
||||
current.as_ref().is_some_and(|generation| {
|
||||
generation.run.lock().as_ref().is_some_and(
|
||||
|run| run.phase() == RunPhase::Running,
|
||||
) && generation
|
||||
.runtime_actions
|
||||
.send(compile::RuntimeAction::UserMessage(
|
||||
action.clone(),
|
||||
))
|
||||
.is_ok()
|
||||
});
|
||||
if delivered_to_active_run {
|
||||
continue;
|
||||
}
|
||||
let Some(previous) = current.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if generation
|
||||
.runtime_actions
|
||||
.send(compile::RuntimeAction::UserMessage(action))
|
||||
.is_err()
|
||||
{
|
||||
generation.results.send_error(crate::Error::Protocol(
|
||||
"UserMessageAction arrived without an active Run"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let mut request = previous.request.clone();
|
||||
request.action = Some(conversation_action);
|
||||
request.conversation_state = None;
|
||||
request.pre_fetched_blobs.clear();
|
||||
waiting_for_action = false;
|
||||
start_generation(
|
||||
®istry,
|
||||
&handle,
|
||||
&dependencies,
|
||||
&blob_sync,
|
||||
&context_sync,
|
||||
&tool_runtime_factory,
|
||||
&mut current,
|
||||
&mut next_generation,
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some(pb::conversation_action::Action::CancelAction(_)) => {
|
||||
if let Some(generation) = current.as_ref() {
|
||||
@@ -427,6 +510,95 @@ impl ConversationRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn start_generation(
|
||||
registry: &ConversationRegistry,
|
||||
handle: &TransportHandle,
|
||||
dependencies: &ConversationDependencies,
|
||||
blob_sync: &BlobSynchronizer,
|
||||
context_sync: &RequestContextSynchronizer,
|
||||
tool_runtime_factory: &CursorToolRuntime,
|
||||
current: &mut Option<RunGeneration>,
|
||||
next_generation: &mut u64,
|
||||
request: pb::AgentRunRequest,
|
||||
) {
|
||||
let previous_finished = if let Some(previous) = current.take() {
|
||||
previous.superseded.cancel();
|
||||
if let Some(run) = previous.run.lock().clone() {
|
||||
run.cancel();
|
||||
}
|
||||
for id in previous.tool_runtime.interrupt_for_run_replacement().await {
|
||||
let _ = handle.emit(&codec::abort(id));
|
||||
}
|
||||
Some(previous.finished.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (results, result_receiver) = tool_result_channel();
|
||||
let (runtime_actions, runtime_action_receiver) =
|
||||
mpsc::unbounded_channel::<compile::RuntimeAction>();
|
||||
let tool_runtime = tool_runtime_factory.next_run();
|
||||
let tools = ToolDispatcher::with_results(
|
||||
tool_runtime.clone(),
|
||||
results.clone(),
|
||||
dependencies.store.clone(),
|
||||
dependencies.web_cache.clone(),
|
||||
);
|
||||
let generation = RunGeneration {
|
||||
id: *next_generation,
|
||||
request: request.clone(),
|
||||
superseded: CancellationToken::new(),
|
||||
finished: CancellationToken::new(),
|
||||
run: Arc::new(parking_lot::Mutex::new(None)),
|
||||
results,
|
||||
runtime_actions,
|
||||
tool_runtime,
|
||||
tools,
|
||||
};
|
||||
*next_generation = next_generation.saturating_add(1);
|
||||
*current = Some(generation.clone());
|
||||
spawn_run_request(
|
||||
registry.clone(),
|
||||
handle.clone(),
|
||||
request,
|
||||
dependencies.clone(),
|
||||
blob_sync.clone(),
|
||||
context_sync.clone(),
|
||||
generation,
|
||||
previous_finished,
|
||||
result_receiver,
|
||||
runtime_action_receiver,
|
||||
);
|
||||
}
|
||||
|
||||
fn finish_pending(
|
||||
handle: &TransportHandle,
|
||||
current: &Option<RunGeneration>,
|
||||
pending: Option<(u64, TransportFinish)>,
|
||||
) {
|
||||
let Some((generation, finish)) = pending else {
|
||||
return;
|
||||
};
|
||||
if current
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.id == generation)
|
||||
{
|
||||
finish_transport(handle, finish);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_transport(handle: &TransportHandle, finish: TransportFinish) {
|
||||
match finish {
|
||||
TransportFinish::Success => super::finish_success(handle),
|
||||
TransportFinish::Failed(error) => {
|
||||
let _ = super::finish_failed(handle, &error);
|
||||
}
|
||||
TransportFinish::Cancelled => {
|
||||
let _ = super::finish_cancelled(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_run_request(
|
||||
registry: ConversationRegistry,
|
||||
@@ -471,6 +643,7 @@ fn spawn_run_request(
|
||||
checkpoint: &checkpoint,
|
||||
blob_sync: &blob_sync,
|
||||
context_sync: &context_sync,
|
||||
local_rules_dir: dependencies.local_rules_dir.as_deref(),
|
||||
},
|
||||
) => prepared,
|
||||
};
|
||||
@@ -485,8 +658,12 @@ fn spawn_run_request(
|
||||
%error,
|
||||
"failed to prepare Cursor Run"
|
||||
);
|
||||
let _ = super::finish_failed(&handle, &error);
|
||||
let _ = handle.command(TransportCommand::Close).await;
|
||||
let _ = handle
|
||||
.command(TransportCommand::RunFinished {
|
||||
generation: generation.id,
|
||||
finish: RunFinish::Transport(TransportFinish::Failed(error)),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -518,8 +695,12 @@ fn spawn_run_request(
|
||||
{
|
||||
CommandResult::Applied | CommandResult::Duplicate => {
|
||||
if !generation.superseded.is_cancelled() {
|
||||
super::finish_success(&handle);
|
||||
let _ = handle.command(TransportCommand::Close).await;
|
||||
let _ = handle
|
||||
.command(TransportCommand::RunFinished {
|
||||
generation: generation.id,
|
||||
finish: RunFinish::Transport(TransportFinish::Success),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -543,8 +724,12 @@ fn spawn_run_request(
|
||||
}
|
||||
CommandResult::StaleTarget => {
|
||||
if !generation.superseded.is_cancelled() {
|
||||
super::finish_success(&handle);
|
||||
let _ = handle.command(TransportCommand::Close).await;
|
||||
let _ = handle
|
||||
.command(TransportCommand::RunFinished {
|
||||
generation: generation.id,
|
||||
finish: RunFinish::Transport(TransportFinish::Success),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -603,16 +788,21 @@ fn spawn_run_request(
|
||||
tool_runtime: generation.tool_runtime.clone(),
|
||||
},
|
||||
);
|
||||
if let Err(error) = output.run().await {
|
||||
if !generation.superseded.is_cancelled() {
|
||||
tracing::error!(
|
||||
request_id = handle.request_id(),
|
||||
%error,
|
||||
"Cursor session failed"
|
||||
);
|
||||
let _ = super::finish_failed(&handle, &error);
|
||||
let finish = match output.run().await {
|
||||
Ok(finish) => finish,
|
||||
Err(error) => {
|
||||
if generation.superseded.is_cancelled() {
|
||||
RunFinish::Transport(TransportFinish::Cancelled)
|
||||
} else {
|
||||
tracing::error!(
|
||||
request_id = handle.request_id(),
|
||||
%error,
|
||||
"Cursor session failed"
|
||||
);
|
||||
RunFinish::Transport(TransportFinish::Failed(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = core_run.await;
|
||||
registry.release(&conversation_id, &run_id).await;
|
||||
if generation
|
||||
@@ -624,7 +814,12 @@ fn spawn_run_request(
|
||||
*generation.run.lock() = None;
|
||||
}
|
||||
if !generation.superseded.is_cancelled() {
|
||||
let _ = handle.command(TransportCommand::Close).await;
|
||||
let _ = handle
|
||||
.command(TransportCommand::RunFinished {
|
||||
generation: generation.id,
|
||||
finish,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user