mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d6a501a0 | ||
|
|
82f3ae8d59 | ||
|
|
31dfa8d295 | ||
|
|
995a12df44 | ||
|
|
7058d0193d | ||
|
|
eff642fdcf | ||
|
|
2c95d7f836 | ||
|
|
75babb7334 | ||
|
|
f6be6c2b27 | ||
|
|
ded534fecf | ||
|
|
5450fc76e2 | ||
|
|
df053c3720 |
@@ -213,6 +213,36 @@ jobs:
|
||||
--output legacy-update/update.json \
|
||||
--notes "Cursor BYOK v${VERSION}"
|
||||
|
||||
- name: Normalize Tauri updater download URLs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
mkdir -p tauri-update
|
||||
release_id=$(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||
--jq ".[] | select(.tag_name == \"v${VERSION}\") | .id"
|
||||
)
|
||||
test -n "${release_id}"
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > tauri-update/release.json
|
||||
asset_id=$(node -p 'require("./tauri-update/release.json").assets.find(({ name }) => name === "latest.json")?.id ?? ""')
|
||||
test -n "${asset_id}"
|
||||
gh api \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
||||
> tauri-update/latest.json
|
||||
node scripts/release/normalize-tauri-update.mjs \
|
||||
--manifest tauri-update/latest.json \
|
||||
--release tauri-update/release.json \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--version "${VERSION}"
|
||||
|
||||
- name: Upload normalized Tauri updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: gh release upload "v${VERSION}" tauri-update/latest.json --clobber
|
||||
|
||||
- name: Upload legacy updater assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
Generated
+101
-3
@@ -926,7 +926,7 @@ dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cursor-server",
|
||||
@@ -1905,6 +1905,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1912,7 +1921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1926,6 +1935,12 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -2727,6 +2742,22 @@ dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tungstenite"
|
||||
version = "0.30.0"
|
||||
@@ -3640,6 +3671,23 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -4038,12 +4086,49 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -5043,9 +5128,11 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -5056,6 +5143,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -6924,6 +7012,16 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
|
||||
Generated
+2
-32
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
@@ -18,13 +18,11 @@
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"echarts": "^6.1.0",
|
||||
"keepalive-for-react": "^5.0.11",
|
||||
"keepalive-for-react-router": "^5.0.7",
|
||||
"monaco-editor": "0.56.0",
|
||||
"react": "^19.2.8",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
@@ -427,12 +425,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
|
||||
@@ -1817,18 +1809,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -2998,16 +2978,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-chartjs-2": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
||||
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,13 +28,11 @@
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"echarts": "^6.1.0",
|
||||
"keepalive-for-react": "^5.0.11",
|
||||
"keepalive-for-react-router": "^5.0.7",
|
||||
"monaco-editor": "0.56.0",
|
||||
"react": "^19.2.8",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:app:allow-set-dock-visibility",
|
||||
"allow-open-terminal-with-command",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"autostart:default",
|
||||
|
||||
@@ -186,9 +186,12 @@ pub fn run() {
|
||||
let listener = tauri::async_runtime::block_on(server.bind())?;
|
||||
let address = listener.local_addr()?;
|
||||
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
|
||||
let silent_start = tauri::async_runtime::block_on(server.store().desktop_settings())
|
||||
.map(|settings| settings.silent_start)
|
||||
.unwrap_or(false);
|
||||
let desktop_settings =
|
||||
tauri::async_runtime::block_on(server.store().desktop_settings())
|
||||
.unwrap_or_default();
|
||||
#[cfg(target_os = "macos")]
|
||||
app.handle()
|
||||
.set_dock_visibility(desktop_settings.show_dock_icon)?;
|
||||
let shutdown = CancellationToken::new();
|
||||
let server_shutdown = shutdown.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -206,7 +209,7 @@ pub fn run() {
|
||||
exiting: AtomicBool::new(false),
|
||||
});
|
||||
let window = create_main_window(app.handle(), address)?;
|
||||
if silent_start && started_by_autostart {
|
||||
if desktop_settings.silent_start && started_by_autostart {
|
||||
tracing::info!("silent autostart enabled; keeping the main window hidden");
|
||||
} else {
|
||||
window.show()?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface TabSettings {
|
||||
|
||||
export interface DesktopSettings {
|
||||
silent_start: boolean;
|
||||
show_dock_icon: boolean;
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
@@ -291,7 +292,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) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) {
|
||||
@@ -299,8 +301,8 @@ export const api = {
|
||||
params.set("end_ms", String(filter.endMs));
|
||||
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
|
||||
}
|
||||
const query = params.size ? `?${params}` : "";
|
||||
return request<Overview>(`/overview${query}`);
|
||||
const query = params.toString();
|
||||
return request<Overview>(`/overview${query ? `?${query}` : ""}`);
|
||||
},
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -1,5 +1,7 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { init, Rect, type ElementEvent } from "zrender";
|
||||
import type { Locale } from "../../i18n/runtime";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||
import styles from "./ContributionCalendarChart.module.scss";
|
||||
|
||||
@@ -54,8 +56,6 @@ const CALENDAR_CONFIG = {
|
||||
axisLabelGap: 8,
|
||||
axisLabelWidth: 28,
|
||||
} as const;
|
||||
const tokenFormatter = new Intl.NumberFormat("zh-CN");
|
||||
|
||||
function parseDate(date: string) {
|
||||
return new Date(`${date}T00:00:00Z`);
|
||||
}
|
||||
@@ -72,9 +72,10 @@ function isCellExtra(value: unknown): value is CellExtra {
|
||||
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
|
||||
}
|
||||
|
||||
function buildCalendarLayout(data: ContributionDay[]) {
|
||||
function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
|
||||
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
|
||||
const firstDate = parseDate(data[0].date);
|
||||
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
|
||||
@@ -88,20 +89,22 @@ function buildCalendarLayout(data: ContributionDay[]) {
|
||||
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
|
||||
const date = parseDate(cell.date);
|
||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
|
||||
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
|
||||
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column });
|
||||
return ticks;
|
||||
}, []);
|
||||
return { cells, columnCount, monthTicks };
|
||||
}
|
||||
|
||||
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
|
||||
const { locale } = useI18n();
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
|
||||
const scheduleDrawRef = useRef<() => void>(() => undefined);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
|
||||
const layout = useMemo(() => buildCalendarLayout(data), [data]);
|
||||
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
|
||||
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
||||
layoutRef.current = layout;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BarChart, LineChart } from "echarts/charts";
|
||||
import { BarChart, GaugeChart, LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
|
||||
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { useEffect, useRef, type MouseEventHandler } from "react";
|
||||
import styles from "./EChart.module.scss";
|
||||
|
||||
use([BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
|
||||
use([BarChart, GaugeChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
type EChartProps = {
|
||||
option: EChartsCoreOption;
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import type { IconifyIcon } from "@iconify/react/offline";
|
||||
import { useEffect, useRef } from "react";
|
||||
import Sortable from "sortablejs";
|
||||
import type { Model } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Card } from "../ui/Card";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { claudeIcon, dragIcon, openAiIcon } from "../ui/icons";
|
||||
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export function CursorModelCards({
|
||||
models,
|
||||
disabled,
|
||||
testingModelHashes,
|
||||
testResults,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: {
|
||||
export type CursorModelGrouping = "flat" | "provider" | "type";
|
||||
|
||||
export type CursorModelGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: IconifyIcon;
|
||||
models: Model[];
|
||||
};
|
||||
|
||||
type CursorModelCardsProps = {
|
||||
models: Model[];
|
||||
grouping: CursorModelGrouping;
|
||||
disabled: boolean;
|
||||
testingModelHashes: Set<string>;
|
||||
testResults: Map<string, CursorModelTestState>;
|
||||
@@ -28,7 +29,54 @@ export function CursorModelCards({
|
||||
onDuplicate: (model: Model) => void;
|
||||
onDelete: (model: Model) => void;
|
||||
onReorder: (modelHashes: string[]) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
|
||||
sortable: boolean;
|
||||
};
|
||||
|
||||
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
|
||||
const groups = new Map<string, CursorModelGroup>();
|
||||
for (const model of models) {
|
||||
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
|
||||
const group = groups.get(descriptor.key);
|
||||
if (group) {
|
||||
group.models.push(model);
|
||||
} else {
|
||||
groups.set(descriptor.key, { ...descriptor, models: [model] });
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
|
||||
<ModelGrid {...props} sortable />
|
||||
</div>;
|
||||
|
||||
return <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
|
||||
<div className={styles.modelGroupHeader}>
|
||||
<Icon icon={group.icon} size="1.1em" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
<ModelGrid {...props} models={group.models} sortable={false} />
|
||||
</section>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ModelGrid({
|
||||
models,
|
||||
sortable: sortableEnabled,
|
||||
disabled,
|
||||
testingModelHashes,
|
||||
testResults,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: ModelGridProps) {
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const sortable = useRef<Sortable | null>(null);
|
||||
const currentModels = useRef(models);
|
||||
@@ -37,7 +85,7 @@ export function CursorModelCards({
|
||||
reorder.current = onReorder;
|
||||
|
||||
useEffect(() => {
|
||||
if (!grid.current) return;
|
||||
if (!sortableEnabled || !grid.current) return;
|
||||
sortable.current = Sortable.create(grid.current, {
|
||||
animation: 160,
|
||||
dataIdAttr: "data-model-hash",
|
||||
@@ -72,7 +120,7 @@ export function CursorModelCards({
|
||||
sortable.current?.destroy();
|
||||
sortable.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [sortableEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
sortable.current?.option("disabled", disabled);
|
||||
@@ -84,14 +132,14 @@ export function CursorModelCards({
|
||||
const result = testResults.get(model.model_hash);
|
||||
const testing = testingModelHashes.has(model.model_hash);
|
||||
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
|
||||
<button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
|
||||
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
|
||||
<Icon icon={dragIcon} size="1.25em" />
|
||||
</button>
|
||||
</button>}
|
||||
<div className={styles.modelCardContent}>
|
||||
<div className={styles.modelCardTop}>
|
||||
<div className={styles.modelCardName}>
|
||||
<strong>{model.display_name}</strong>
|
||||
<span>{model.model_id}</span>
|
||||
<span className={styles.modelCardNameText}>{model.display_name}</span>
|
||||
<span className={styles.modelCardModelId}>{model.model_id}</span>
|
||||
</div>
|
||||
<span className={styles.modelTypeBadge}>
|
||||
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
|
||||
@@ -102,7 +150,7 @@ export function CursorModelCards({
|
||||
<CursorModelTestResult state={result} testing={testing} />
|
||||
</div>
|
||||
<div className={styles.modelCardActions}>
|
||||
<Button size="small" disabled={disabled} onClick={() => onTest(model)}>{testing ? t("测试中…") : t("测试")}</Button>
|
||||
<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>
|
||||
@@ -112,3 +160,27 @@ export function CursorModelCards({
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function providerGroup(model: Model) {
|
||||
const label = providerDomain(model.base_url);
|
||||
return { key: label, label, icon: flatColorOrganizationIcon };
|
||||
}
|
||||
|
||||
function providerDomain(baseUrl: string) {
|
||||
const value = baseUrl.trim();
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${value}`).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function typeGroup(model: Model) {
|
||||
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
|
||||
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
|
||||
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ModelInput, ModelType } from "../../api";
|
||||
import { defaultCustomHeadersText } from "../../utils/modelDefaults";
|
||||
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
|
||||
@@ -7,6 +8,7 @@ import { JsonEditor } from "../ui/JsonEditor";
|
||||
import { Combobox, Select } from "../ui/Select";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import { claudeIcon, openAiIcon } from "../ui/icons";
|
||||
import { CursorPresetChips } from "./CursorPresetChips";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
@@ -24,7 +26,7 @@ export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
base_url: "",
|
||||
use_full_url: false,
|
||||
api_key: "",
|
||||
tooltip_data: t("备注"),
|
||||
tooltip_data: "",
|
||||
model_id: "",
|
||||
reasoning_effort: null,
|
||||
openai_endpoint: "/v1/responses",
|
||||
@@ -53,13 +55,64 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
onDiscover: () => void;
|
||||
}) {
|
||||
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
|
||||
const setType = (type: ModelType) => setModel({
|
||||
type,
|
||||
openai_endpoint: type === "openai" ? draft.model.openai_endpoint || "/v1/responses" : "",
|
||||
anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
|
||||
});
|
||||
const setType = (type: ModelType) => {
|
||||
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
|
||||
// 避免出现「类型是 Anthropic、URL 却是 OpenAI chat/completions」的错配
|
||||
const other: ModelType = type === "anthropic" ? "openai" : "anthropic";
|
||||
const preset = modelPresets.find((candidate) => trimTrailingSlash(presetEndpoint(candidate, other).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()));
|
||||
const endpoint = preset ? presetEndpoint(preset, type) : null;
|
||||
onChange({
|
||||
...draft,
|
||||
model: {
|
||||
...draft.model,
|
||||
type,
|
||||
...(endpoint ? {
|
||||
base_url: endpoint.baseUrl,
|
||||
use_full_url: endpoint.useFullUrl,
|
||||
custom_headers_enabled: endpoint.customHeaders !== null,
|
||||
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
|
||||
} : {}),
|
||||
openai_endpoint: type === "openai" ? (endpoint?.openaiEndpoint || draft.model.openai_endpoint || "/v1/responses") : "",
|
||||
anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
|
||||
},
|
||||
customHeadersText: endpoint?.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
|
||||
});
|
||||
};
|
||||
const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value));
|
||||
const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim());
|
||||
// 选中预设后,把该服务商已知的模型 id 并入下拉,方便直接选(仍可用「获取模型」发现)
|
||||
const presetModelOptions = modelPresets
|
||||
.filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()))
|
||||
.flatMap((preset) => preset.models.map((item) => item.model_id));
|
||||
const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])];
|
||||
const applyPreset = (preset: ModelPreset) => {
|
||||
const endpoint = presetEndpoint(preset, draft.model.type);
|
||||
const first = preset.models[0];
|
||||
// 切到别家服务商时清空 API Key(不同家的 Key 不能串用);同一家内切换协议则保留
|
||||
const currentBase = trimTrailingSlash(draft.model.base_url.trim());
|
||||
const sameProvider = [preset.endpoints.anthropic, preset.endpoints.openai]
|
||||
.some((candidate) => trimTrailingSlash(candidate.baseUrl) === currentBase);
|
||||
onChange({
|
||||
...draft,
|
||||
model: {
|
||||
...draft.model,
|
||||
base_url: endpoint.baseUrl,
|
||||
use_full_url: endpoint.useFullUrl,
|
||||
openai_endpoint: draft.model.type === "openai" ? endpoint.openaiEndpoint : draft.model.openai_endpoint,
|
||||
custom_headers_enabled: endpoint.customHeaders !== null,
|
||||
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
|
||||
api_key: sameProvider ? draft.model.api_key : "",
|
||||
model_id: first?.model_id ?? draft.model.model_id,
|
||||
display_name: first?.display_name ?? draft.model.display_name,
|
||||
tooltip_data: !draft.model.tooltip_data.trim() || draft.model.tooltip_data === t("备注") ? preset.name : draft.model.tooltip_data,
|
||||
context_window_tokens: first?.context_window_tokens ?? draft.model.context_window_tokens,
|
||||
...(draft.model.type === "openai"
|
||||
? { max_completion_tokens: first?.max_output_tokens ?? draft.model.max_completion_tokens }
|
||||
: { anthropic_max_tokens: first?.max_output_tokens ?? draft.model.anthropic_max_tokens }),
|
||||
},
|
||||
customHeadersText: endpoint.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
|
||||
});
|
||||
};
|
||||
const requestUrlPlaceholder = draft.model.use_full_url
|
||||
? draft.model.type === "anthropic"
|
||||
? "https://api.anthropic.com/v1/messages"
|
||||
@@ -71,6 +124,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
: "https://api.openai.com";
|
||||
|
||||
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 },
|
||||
@@ -87,18 +141,18 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
</div>
|
||||
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
|
||||
|
||||
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={modelOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
|
||||
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
|
||||
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
|
||||
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
|
||||
|
||||
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
{draft.model.type === "openai" ? <>
|
||||
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
|
||||
</> : <>
|
||||
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
|
||||
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用 adaptive thinking")} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
</>}
|
||||
|
||||
<ToggleJsonField
|
||||
|
||||
@@ -6,11 +6,13 @@ import styles from "./CursorModelTestResult.module.scss";
|
||||
|
||||
export type CursorModelTestState =
|
||||
| { status: "success"; result: ModelConnectivityResult }
|
||||
| { status: "error"; error: string };
|
||||
| { status: "error"; error: string }
|
||||
| { status: "cancelled" };
|
||||
|
||||
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
|
||||
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
|
||||
const success = state.status === "success";
|
||||
const summary = success
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--vscode-editorWidget-border, rgba(255, 255, 255, 0.16));
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vscode-focusBorder, #49b0ff);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--vscode-focusBorder, #49b0ff);
|
||||
color: var(--vscode-focusBorder, #49b0ff);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ModelType } from "../../api";
|
||||
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
|
||||
import styles from "./CursorPresetChips.module.scss";
|
||||
|
||||
/** 常用服务商预设:点击按当前协议类型自动填充对应端点与默认模型 */
|
||||
export function CursorPresetChips({ type, baseUrl, onPick }: { type: ModelType; baseUrl: string; onPick: (preset: ModelPreset) => void }) {
|
||||
return <div className={styles.wrap}>
|
||||
<span className={styles.label}>{t("常用预设")}</span>
|
||||
<div className={styles.chips}>
|
||||
{modelPresets.map((preset) => {
|
||||
const active = trimTrailingSlash(baseUrl) === trimTrailingSlash(presetEndpoint(preset, type).baseUrl);
|
||||
return <button
|
||||
type="button"
|
||||
key={preset.key}
|
||||
className={active ? `${styles.chip} ${styles.active}` : styles.chip}
|
||||
title={preset.keyHint}
|
||||
onClick={() => onPick(preset)}
|
||||
>
|
||||
<img className={styles.icon} src={preset.icon} alt="" />
|
||||
{preset.name}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -5,6 +5,29 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.groupActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
|
||||
button {
|
||||
height: 25px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--vscode-sideBar-border);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child { border-right: 0; }
|
||||
&:hover:not(:disabled) { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
|
||||
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
}
|
||||
|
||||
.gateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -39,13 +62,34 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
}
|
||||
.modelGroups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.modelGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.modelGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
opacity: 0.6;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.modelCard {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.modelCardContent {
|
||||
height: 150px;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -64,19 +108,19 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
strong,
|
||||
span {
|
||||
.modelCardNameText,
|
||||
.modelCardModelId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
strong {
|
||||
.modelCardNameText {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
span {
|
||||
.modelCardModelId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
}
|
||||
.modelTypeBadge {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
top: var(--app-content-top);
|
||||
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: type.$font-size-lg;
|
||||
padding-left:0;
|
||||
font-size: type.$font-size-xxl;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
-webkit-user-select: none;
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: type.$font-size-lg;
|
||||
padding-left: 0;
|
||||
font-size: type.$font-size-xxl;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--cache-hit-track-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||
--cache-hit-value-color: var(--vscode-gitDecoration-addedResourceForeground, #4ade80);
|
||||
position: relative;
|
||||
width: 132px;
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
height: 82px;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
.chart {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -1,75 +1,62 @@
|
||||
import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { Doughnut } from "react-chartjs-2";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EChart } from "../charts/EChart";
|
||||
import styles from "./CacheHitRateChart.module.scss";
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip);
|
||||
const valueColor = "#40c463";
|
||||
const trackColor = "rgba(139, 148, 158, 0.20)";
|
||||
|
||||
type SegmentRadius = number | {
|
||||
outerStart: number;
|
||||
outerEnd: number;
|
||||
innerStart: number;
|
||||
innerEnd: number;
|
||||
};
|
||||
|
||||
function chartColor(context: ScriptableContext<"doughnut">) {
|
||||
const styles = getComputedStyle(context.chart.canvas);
|
||||
const variable = context.dataIndex === 0 ? "--cache-hit-value-color" : "--cache-hit-track-color";
|
||||
return styles.getPropertyValue(variable).trim();
|
||||
}
|
||||
|
||||
function segmentBorderRadius(percentage: number, dataIndex: number): SegmentRadius {
|
||||
const radius = 5;
|
||||
|
||||
if (percentage <= 0) {
|
||||
return dataIndex === 1
|
||||
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
|
||||
: 0;
|
||||
}
|
||||
|
||||
if (percentage >= 100) {
|
||||
return dataIndex === 0
|
||||
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
|
||||
: 0;
|
||||
}
|
||||
|
||||
return dataIndex === 0
|
||||
? { outerStart: radius, outerEnd: 0, innerStart: radius, innerEnd: 0 }
|
||||
: { outerStart: 0, outerEnd: radius, innerStart: 0, innerEnd: radius };
|
||||
}
|
||||
|
||||
const options: ChartOptions<"doughnut"> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "82%",
|
||||
rotation: -90,
|
||||
circumference: 180,
|
||||
animation: { duration: 450 },
|
||||
events: [],
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
export function CacheHitRateChart({ rate }: { rate: number }) {
|
||||
export function CacheHitRateChart({ rate, animationKey = 0 }: { rate: number; animationKey?: number }) {
|
||||
const finiteRate = Number.isFinite(rate) ? rate : 0;
|
||||
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
|
||||
const [displayedPercentage, setDisplayedPercentage] = useState(0);
|
||||
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
|
||||
const data = useMemo(() => ({
|
||||
labels: [t("命中"), t("未命中")],
|
||||
datasets: [{
|
||||
data: [percentage, Math.max(0, 100 - percentage)],
|
||||
backgroundColor: chartColor,
|
||||
borderWidth: 0,
|
||||
hoverBorderWidth: 0,
|
||||
selfJoin: false,
|
||||
borderRadius: (context: ScriptableContext<"doughnut">) => segmentBorderRadius(percentage, context.dataIndex),
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedPercentage(0);
|
||||
let frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => setDisplayedPercentage(percentage));
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [animationKey, percentage]);
|
||||
|
||||
const option = useMemo<EChartsCoreOption>(() => ({
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: displayedPercentage > 0 ? 1_000 : 0,
|
||||
animationEasing: "cubicOut",
|
||||
animationEasingUpdate: "cubicOut",
|
||||
series: [{
|
||||
type: "gauge",
|
||||
min: 0,
|
||||
max: 100,
|
||||
startAngle: 180,
|
||||
endAngle: 0,
|
||||
center: ["50%", "50%"],
|
||||
radius: "90%",
|
||||
silent: true,
|
||||
pointer: { show: false },
|
||||
progress: {
|
||||
show: true,
|
||||
roundCap: true,
|
||||
width: 11,
|
||||
itemStyle: { color: displayedPercentage > 0 ? valueColor : "transparent" },
|
||||
},
|
||||
axisLine: {
|
||||
roundCap: true,
|
||||
lineStyle: { width: 11, color: [[1, trackColor]] },
|
||||
},
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
anchor: { show: false },
|
||||
title: { show: false },
|
||||
detail: { show: false },
|
||||
data: [{ value: displayedPercentage }],
|
||||
}],
|
||||
}), [percentage]);
|
||||
}), [displayedPercentage]);
|
||||
|
||||
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
|
||||
<Doughnut className={styles.canvas} data={data} options={options} />
|
||||
<EChart className={styles.chart} option={option} />
|
||||
<div className={styles.label}>{label}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ function InfoTooltip({ content }: { content: string }) {
|
||||
</>;
|
||||
}
|
||||
|
||||
export function HomeMetrics({ data }: { data: HomeMetricsData }) {
|
||||
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
|
||||
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
||||
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
||||
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
||||
@@ -148,7 +148,7 @@ export function HomeMetrics({ data }: { data: HomeMetricsData }) {
|
||||
<section className={styles.root} aria-label={t("调用统计")}>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
|
||||
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} />
|
||||
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} animationKey={refreshVersion} />
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
border-radius: 5px;
|
||||
|
||||
button {
|
||||
height: 30px;
|
||||
height: 25px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const localViewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const localContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
const thumbRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
@@ -107,6 +108,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
|
||||
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
|
||||
viewportRef: localViewportRef,
|
||||
contentRef: localContentRef,
|
||||
trackRef,
|
||||
thumbRef,
|
||||
contentHeight,
|
||||
@@ -132,7 +134,10 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
)
|
||||
|
||||
const setContentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => assignRef(contentRef, node),
|
||||
(node: HTMLDivElement | null) => {
|
||||
localContentRef.current = node
|
||||
assignRef(contentRef, node)
|
||||
},
|
||||
[contentRef]
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ScrollMetrics extends ScrollAreaState {
|
||||
|
||||
interface UseScrollableModelOptions {
|
||||
viewportRef: RefObject<HTMLDivElement | null>
|
||||
contentRef: RefObject<HTMLDivElement | null>
|
||||
trackRef: RefObject<HTMLDivElement | null>
|
||||
thumbRef: RefObject<HTMLDivElement | null>
|
||||
contentHeight?: number
|
||||
@@ -131,6 +132,7 @@ function applyThumbStyle(
|
||||
export function useScrollableModel(options: UseScrollableModelOptions) {
|
||||
const {
|
||||
viewportRef,
|
||||
contentRef,
|
||||
trackRef,
|
||||
thumbRef,
|
||||
contentHeight,
|
||||
@@ -319,28 +321,30 @@ export function useScrollableModel(options: UseScrollableModelOptions) {
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
const content = contentRef.current
|
||||
const track = trackRef.current
|
||||
if (!element || !track) return
|
||||
if (!element || !content || !track) return
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
const viewportEntry = entries.find((entry) => entry.target === element)
|
||||
if (viewportEntry) {
|
||||
onViewportResizeRef.current?.({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
width: viewportEntry.contentRect.width,
|
||||
height: viewportEntry.contentRect.height,
|
||||
})
|
||||
}
|
||||
snapshot(false)
|
||||
})
|
||||
|
||||
resizeObserver.observe(element)
|
||||
resizeObserver.observe(content)
|
||||
resizeObserver.observe(track)
|
||||
snapshot(false)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [snapshot, trackRef, viewportRef])
|
||||
}, [contentRef, snapshot, trackRef, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
snapshot(false)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
currentAppVersion,
|
||||
hasDockVisibilitySetting,
|
||||
hasNativeAppLifecycle,
|
||||
readAutostart,
|
||||
readSilentStart,
|
||||
readDesktopSettings,
|
||||
writeAutostart,
|
||||
writeDockIconVisibility,
|
||||
writeSilentStart,
|
||||
} from "../../native/appLifecycle";
|
||||
import { updateStore, useUpdateStore } from "../../store/updateStore";
|
||||
@@ -17,12 +19,14 @@ import styles from "./AppLifecycleSettingsCard.module.scss";
|
||||
export function AppLifecycleSettingsCard() {
|
||||
const message = useMessage();
|
||||
const native = hasNativeAppLifecycle();
|
||||
const dockVisibilitySetting = hasDockVisibilitySetting();
|
||||
const { availableVersion, checking, installing } = useUpdateStore();
|
||||
const [version, setVersion] = useState("…");
|
||||
const [autostart, setAutostart] = useState(false);
|
||||
const [loadingAutostart, setLoadingAutostart] = useState(native);
|
||||
const [silentStart, setSilentStart] = useState(false);
|
||||
const [loadingSilentStart, setLoadingSilentStart] = useState(native);
|
||||
const [dockIconVisible, setDockIconVisible] = useState(true);
|
||||
const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -32,10 +36,17 @@ export function AppLifecycleSettingsCard() {
|
||||
.then((enabled) => { if (!disposed) setAutostart(enabled); })
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
|
||||
.finally(() => { if (!disposed) setLoadingAutostart(false); });
|
||||
void readSilentStart()
|
||||
.then((silent) => { if (!disposed) setSilentStart(silent); })
|
||||
void readDesktopSettings()
|
||||
.then((settings) => {
|
||||
if (disposed) return;
|
||||
setSilentStart(settings.silent_start);
|
||||
setDockIconVisible(settings.show_dock_icon);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!disposed) setLoadingSilentStart(false); });
|
||||
.finally(() => {
|
||||
if (disposed) return;
|
||||
setLoadingDesktopSettings(false);
|
||||
});
|
||||
}
|
||||
return () => { disposed = true; };
|
||||
}, [message, native]);
|
||||
@@ -55,14 +66,27 @@ export function AppLifecycleSettingsCard() {
|
||||
|
||||
const toggleSilentStart = async (enabled: boolean) => {
|
||||
try {
|
||||
setLoadingSilentStart(true);
|
||||
setLoadingDesktopSettings(true);
|
||||
await writeSilentStart(enabled);
|
||||
setSilentStart(await readSilentStart());
|
||||
setSilentStart(enabled);
|
||||
message(enabled ? t("已开启静默启动") : t("已关闭静默启动"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoadingSilentStart(false);
|
||||
setLoadingDesktopSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDockIcon = async (visible: boolean) => {
|
||||
try {
|
||||
setLoadingDesktopSettings(true);
|
||||
await writeDockIconVisibility(visible);
|
||||
setDockIconVisible(visible);
|
||||
message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoadingDesktopSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,11 +127,23 @@ export function AppLifecycleSettingsCard() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={silentStart}
|
||||
disabled={!native || loadingSilentStart}
|
||||
disabled={!native || loadingDesktopSettings}
|
||||
label={t("静默启动")}
|
||||
onChange={(enabled) => void toggleSilentStart(enabled)}
|
||||
/>
|
||||
</div>}
|
||||
{dockVisibilitySetting && <div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("在 Dock 栏显示")}</strong>
|
||||
<small>{t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")}</small>
|
||||
</div>
|
||||
<Switch
|
||||
checked={dockIconVisible}
|
||||
disabled={loadingDesktopSettings}
|
||||
label={t("在 Dock 栏显示")}
|
||||
onChange={(visible) => void toggleDockIcon(visible)}
|
||||
/>
|
||||
</div>}
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("软件更新")}</strong>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
|
||||
@@ -47,6 +47,16 @@
|
||||
.input {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
|
||||
&[type="number"] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
&[type="number"]::-webkit-inner-spin-button,
|
||||
&[type="number"]::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.secret {
|
||||
|
||||
@@ -34,12 +34,14 @@
|
||||
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
|
||||
|
||||
header {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border);
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 630;
|
||||
}
|
||||
footer {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
@@ -52,6 +54,10 @@
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.fullHeight {
|
||||
height: calc(100vh - var(--window-header-height) - 24px);
|
||||
}
|
||||
|
||||
.banner {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 18px;
|
||||
@@ -59,14 +65,8 @@
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 0 1 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - var(--window-header-height) - 112px);
|
||||
}
|
||||
|
||||
.bodyViewport {
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--window-header-height) - 112px);
|
||||
}
|
||||
|
||||
.bodyContent {
|
||||
|
||||
@@ -12,6 +12,7 @@ type ModalProps = {
|
||||
banner?: ReactNode;
|
||||
busy?: boolean;
|
||||
wide?: boolean;
|
||||
fullHeight?: boolean;
|
||||
role?: "dialog" | "alertdialog";
|
||||
ariaDescribedBy?: string;
|
||||
initialFocus?: "first" | "submit";
|
||||
@@ -36,7 +37,7 @@ function focusableElements(root: HTMLElement) {
|
||||
.filter((element) => element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, 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("保存") }: ModalProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const submitButton = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
@@ -87,10 +88,10 @@ export function Modal({ id, open, title, children, banner, busy, wide, role = "d
|
||||
if (!open) return null;
|
||||
return createPortal(<div className={styles.mask}>
|
||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
|
||||
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide, fullHeight && styles.fullHeight].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
|
||||
<header id={titleId}>{title}</header>
|
||||
{banner && <div className={styles.banner}>{banner}</div>}
|
||||
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
|
||||
<ScrollableContent alwaysShowVertical className={styles.body} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
|
||||
<footer>
|
||||
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
|
||||
{secondaryAction}
|
||||
|
||||
@@ -15,7 +15,6 @@ export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1
|
||||
|
||||
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
|
||||
// can be compared in context before settling on one visual family.
|
||||
export const homeIcon = icon('<g fill="currentColor"><path d="M13 15.4C13 13.3258 13 12.2887 13.659 11.6444C14.318 11 15.3787 11 17.5 11C19.6213 11 20.682 11 21.341 11.6444C22 12.2887 22 13.3258 22 15.4V17.6C22 19.6742 22 20.7113 21.341 21.3556C20.682 22 19.6213 22 17.5 22C15.3787 22 14.318 22 13.659 21.3556C13 20.7113 13 19.6742 13 17.6V15.4Z"/><path d="M2 8.6C2 10.6742 2 11.7113 2.65901 12.3556C3.31802 13 4.37868 13 6.5 13C8.62132 13 9.68198 13 10.341 12.3556C11 11.7113 11 10.6742 11 8.6V6.4C11 4.32582 11 3.28873 10.341 2.64437C9.68198 2 8.62132 2 6.5 2C4.37868 2 3.31802 2 2.65901 2.64437C2 3.28873 2 4.32582 2 6.4V8.6Z"/><path d="M13 5.5C13 4.4128 13 3.8692 13.1713 3.44041C13.3996 2.86867 13.8376 2.41443 14.389 2.17761C14.8024 2 15.3266 2 16.375 2H18.625C19.6734 2 20.1976 2 20.611 2.17761C21.1624 2.41443 21.6004 2.86867 21.8287 3.44041C22 3.8692 22 4.4128 22 5.5C22 6.5872 22 7.1308 21.8287 7.55959C21.6004 8.13133 21.1624 8.58557 20.611 8.82239C20.1976 9 19.6734 9 18.625 9H16.375C15.3266 9 14.8024 9 14.389 8.82239C13.8376 8.58557 13.3996 8.13133 13.1713 7.55959C13 7.1308 13 6.5872 13 5.5Z"/><path d="M2 18.5C2 19.5872 2 20.1308 2.17127 20.5596C2.39963 21.1313 2.83765 21.5856 3.38896 21.8224C3.80245 22 4.32663 22 5.375 22H7.625C8.67337 22 9.19755 22 9.61104 21.8224C10.1624 21.5856 10.6004 21.1313 10.8287 20.5596C11 20.1308 11 19.5872 11 18.5C11 17.4128 11 16.8692 10.8287 16.4404C10.6004 15.8687 10.1624 15.4144 9.61104 15.1776C9.19755 15 8.67337 15 7.625 15H5.375C4.32663 15 3.80245 15 3.38896 15.1776C2.83765 15.4144 2.39963 15.8687 2.17127 16.4404C2 16.8692 2 17.4128 2 18.5Z"/></g>');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@
|
||||
"20e14248fd4fb981": "{label} must be an integer greater than 0",
|
||||
"217cfe7db1e3d10a": "Use system language",
|
||||
"22c6b4eb4caee6ae": "Proxy settings saved",
|
||||
"22d7895ea5fca72e": "By provider",
|
||||
"23e49479e15e6770": "Version {version} is available",
|
||||
"24a0a24864454575": "Existing, skipped",
|
||||
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
|
||||
@@ -70,7 +71,6 @@
|
||||
"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}",
|
||||
"393df9bb13ea4900": "Hit",
|
||||
"393e1241552b1870": "Request",
|
||||
"398f8e6c6f0a0b97": "Continue selecting or typing",
|
||||
"39f52eee100131d7": "Cached input",
|
||||
@@ -78,11 +78,13 @@
|
||||
"3a3f595df70ec8ff": "Clear storage",
|
||||
"3a5040b68abf75f9": "Select all",
|
||||
"3a8c76b2ce785f96": "Review and import",
|
||||
"3b67824289b5fa1e": "Dock icon hidden",
|
||||
"3c94b4c75940c178": "Input Tokens",
|
||||
"3cfae5728b92b334": "Token usage: {tokens}",
|
||||
"3d13868593ae4eeb": "Display language",
|
||||
"3da0bf1610ff5db5": "Recommended",
|
||||
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
|
||||
"3fd118e2ffe0b2b6": "Cancel all tests",
|
||||
"3fd47edce45b3603": "Close",
|
||||
"4125fc7ba333524c": "Default light",
|
||||
"42655ed8e4108ae2": "Input (non-cached)",
|
||||
@@ -98,6 +100,7 @@
|
||||
"4927a53bcc886afb": "Loading…",
|
||||
"497c85690c4cc0fc": "No data",
|
||||
"499c729eb09aa2a6": "Context window tokens",
|
||||
"49be72e6045c007d": "Cancel test",
|
||||
"4a8d6841b4023edf": "Confirm import",
|
||||
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
|
||||
"4d0680f9efaef147": "Unread",
|
||||
@@ -119,6 +122,7 @@
|
||||
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
|
||||
"5a3bd99fa69a40c1": "Use public service",
|
||||
"5b17f59d33bde39e": "Error: {error}",
|
||||
"5ba65a74c4e792c5": "By type",
|
||||
"5c55a67935af8f45": "All",
|
||||
"5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
|
||||
"5d59857bf039cac9": "Cursor Assistant v{version}",
|
||||
@@ -139,6 +143,7 @@
|
||||
"656ab25e264cc4e4": "No models are available to Cursor yet",
|
||||
"65a6318e07ec1e07": "Tools",
|
||||
"65cb9a7b4f620b6b": "Prompt {tokens}",
|
||||
"680680288a6d2ad2": "Show in Dock",
|
||||
"68ad603fafe4e0d6": "Import legacy configuration",
|
||||
"68ea5dd4d7af20e6": "System settings",
|
||||
"6a9906c79f26c0ba": "Start time",
|
||||
@@ -147,6 +152,7 @@
|
||||
"6d1876364ac6457d": "Proxy mode",
|
||||
"6e86570183c3cdd0": "You're up to date",
|
||||
"7005693f4f050bce": "Cache I/O {cost}",
|
||||
"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.",
|
||||
"788db1cfec2a3db5": "Theme",
|
||||
@@ -162,6 +168,7 @@
|
||||
"7f3c8312816fe26a": "Refreshing…",
|
||||
"7f68ebad19ba6bcd": "Check for updates",
|
||||
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
|
||||
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
|
||||
"83fcfb4c1f2c1641": "Fetch models",
|
||||
"842b9f11cdd96bda": "Launch at login",
|
||||
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
||||
@@ -202,6 +209,7 @@
|
||||
"a363743025795ec7": "I've initialized it — refresh",
|
||||
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
|
||||
"a49ffd73bc85333d": "Average",
|
||||
"a4d222236dc1003d": "Failed to cancel test: {error}",
|
||||
"a5fb6189a8ad011d": "Open tutorial",
|
||||
"a621ab606db2a11f": "Password",
|
||||
"a693d69af48bfe48": "Save and test",
|
||||
@@ -211,15 +219,18 @@
|
||||
"a98585871c5313ff": "Display name",
|
||||
"ab9084a640fbb864": "Deselect all",
|
||||
"abecab6701177721": "Launch at login enabled",
|
||||
"ac58d0f9a3f8d389": "Enter model notes",
|
||||
"ac69f68b7010ec79": "Download and install",
|
||||
"ad6a60ee93d3ba3e": "Loading call details…",
|
||||
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
|
||||
"aecb952b1e6cce36": "Hide the Dock icon when disabled. You can still open the app from the menu bar icon.",
|
||||
"aee88743413144a2": "Refresh",
|
||||
"b06325c5660f0c29": "Direct",
|
||||
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
|
||||
"b4411558b932266f": "Provider type",
|
||||
"b502b1d414664337": "Prompt: {tokens}",
|
||||
"b5141d3d19e9a048": "Yes",
|
||||
"b6725f218ebaef26": "Dock icon shown",
|
||||
"b75a46aad3e7c132": "Non-cached input: {tokens}",
|
||||
"b79354009c614ae9": "Statistics",
|
||||
"b86967982067d295": " (estimated)",
|
||||
@@ -227,6 +238,7 @@
|
||||
"b8c9b486c83b5778": "Hide ad",
|
||||
"b9670c85a4ab939e": "Route",
|
||||
"b9af2de88d903be7": "Proxy address",
|
||||
"ba5865fbc734e672": "For example: Primary model",
|
||||
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
||||
"bb2b7736433ae867": "Cursor tracing",
|
||||
"bb7efdcb6af6e805": "Default dark",
|
||||
@@ -251,11 +263,15 @@
|
||||
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
|
||||
"d3716cc5a2f5a810": "Server address",
|
||||
"d3d21191f32e79a5": "Processing…",
|
||||
"d58c88688e1a949d": "Presets",
|
||||
"d60669bb26a22f5d": "Leave blank to use the default",
|
||||
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
|
||||
"d86fa42c3848c680": "Use system proxy",
|
||||
"d8c47e9776cf1082": "Main menu",
|
||||
"da521d1c1cbd36af": "Authorization is required to install the certificate",
|
||||
"da7ae985487c38e6": "Last hour",
|
||||
"daede9881787abe7": "Notes",
|
||||
"db340a9896306d08": "Test cancelled",
|
||||
"dbd3596e4a86f3c2": "Configured models",
|
||||
"ddde16f8839da3ce": "Total requests",
|
||||
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
|
||||
@@ -290,7 +306,6 @@
|
||||
"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",
|
||||
"f3a76d896853c1df": "Miss",
|
||||
"f4694c46b1e19602": "Final request type",
|
||||
"f4dcb6a3ceb32247": "Page {page} of {count}",
|
||||
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
|
||||
"217cfe7db1e3d10a": "跟随系统",
|
||||
"22c6b4eb4caee6ae": "代理设置已保存",
|
||||
"22d7895ea5fca72e": "按供应商",
|
||||
"23e49479e15e6770": "发现新版本 {version}",
|
||||
"24a0a24864454575": "已存在,跳过",
|
||||
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
|
||||
@@ -70,7 +71,6 @@
|
||||
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
|
||||
"378bb0eec39fa8a2": "最后一页",
|
||||
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
|
||||
"393df9bb13ea4900": "命中",
|
||||
"393e1241552b1870": "请求",
|
||||
"398f8e6c6f0a0b97": "继续选择或输入",
|
||||
"39f52eee100131d7": "缓存输入",
|
||||
@@ -78,11 +78,13 @@
|
||||
"3a3f595df70ec8ff": "清理存储空间",
|
||||
"3a5040b68abf75f9": "全选",
|
||||
"3a8c76b2ce785f96": "查看并导入",
|
||||
"3b67824289b5fa1e": "已隐藏 Dock 栏图标",
|
||||
"3c94b4c75940c178": "输入 Token",
|
||||
"3cfae5728b92b334": "Token 用量:{tokens}",
|
||||
"3d13868593ae4eeb": "界面语言",
|
||||
"3da0bf1610ff5db5": "推荐内容",
|
||||
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
|
||||
"3fd118e2ffe0b2b6": "取消全部测试",
|
||||
"3fd47edce45b3603": "关闭",
|
||||
"4125fc7ba333524c": "默认亮色",
|
||||
"42655ed8e4108ae2": "输入(非缓存)",
|
||||
@@ -98,6 +100,7 @@
|
||||
"4927a53bcc886afb": "加载中…",
|
||||
"497c85690c4cc0fc": "暂无数据",
|
||||
"499c729eb09aa2a6": "上下文窗口 Token",
|
||||
"49be72e6045c007d": "取消测试",
|
||||
"4a8d6841b4023edf": "确认导入",
|
||||
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
|
||||
"4d0680f9efaef147": "未读",
|
||||
@@ -119,6 +122,7 @@
|
||||
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
|
||||
"5a3bd99fa69a40c1": "使用公益服务",
|
||||
"5b17f59d33bde39e": "错误:{error}",
|
||||
"5ba65a74c4e792c5": "按类型",
|
||||
"5c55a67935af8f45": "全部",
|
||||
"5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
||||
@@ -139,6 +143,7 @@
|
||||
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
|
||||
"65a6318e07ec1e07": "工具数",
|
||||
"65cb9a7b4f620b6b": "提示词 {tokens}",
|
||||
"680680288a6d2ad2": "在 Dock 栏显示",
|
||||
"68ad603fafe4e0d6": "导入旧版配置",
|
||||
"68ea5dd4d7af20e6": "系统设置",
|
||||
"6a9906c79f26c0ba": "开始时间",
|
||||
@@ -147,6 +152,7 @@
|
||||
"6d1876364ac6457d": "代理方式",
|
||||
"6e86570183c3cdd0": "当前已是最新版本",
|
||||
"7005693f4f050bce": "缓存读写 {cost}",
|
||||
"72644ec4389da2f7": "默认平铺",
|
||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
|
||||
"788db1cfec2a3db5": "主题",
|
||||
@@ -162,6 +168,7 @@
|
||||
"7f3c8312816fe26a": "刷新中…",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"811a3b22a5a7f2d5": "无法连接本地管理服务",
|
||||
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"842b9f11cdd96bda": "开机启动",
|
||||
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
||||
@@ -202,6 +209,7 @@
|
||||
"a363743025795ec7": "我已初始化,刷新",
|
||||
"a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。",
|
||||
"a49ffd73bc85333d": "平均",
|
||||
"a4d222236dc1003d": "取消测试失败:{error}",
|
||||
"a5fb6189a8ad011d": "打开教程",
|
||||
"a621ab606db2a11f": "密码",
|
||||
"a693d69af48bfe48": "保存并测试",
|
||||
@@ -211,15 +219,18 @@
|
||||
"a98585871c5313ff": "显示名称",
|
||||
"ab9084a640fbb864": "全不选",
|
||||
"abecab6701177721": "已开启开机启动",
|
||||
"ac58d0f9a3f8d389": "请输入模型备注",
|
||||
"ac69f68b7010ec79": "下载并安装",
|
||||
"ad6a60ee93d3ba3e": "正在加载调用详情…",
|
||||
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
|
||||
"aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。",
|
||||
"aee88743413144a2": "刷新",
|
||||
"b06325c5660f0c29": "直连",
|
||||
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
|
||||
"b4411558b932266f": "上游类型",
|
||||
"b502b1d414664337": "提示词:{tokens}",
|
||||
"b5141d3d19e9a048": "是",
|
||||
"b6725f218ebaef26": "已显示 Dock 栏图标",
|
||||
"b75a46aad3e7c132": "非缓存输入:{tokens}",
|
||||
"b79354009c614ae9": "统计数据",
|
||||
"b86967982067d295": "(估算)",
|
||||
@@ -227,6 +238,7 @@
|
||||
"b8c9b486c83b5778": "不再显示广告",
|
||||
"b9670c85a4ab939e": "路由",
|
||||
"b9af2de88d903be7": "代理地址",
|
||||
"ba5865fbc734e672": "例如:主力模型",
|
||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||
"bb2b7736433ae867": "Cursor 追踪",
|
||||
"bb7efdcb6af6e805": "默认暗色",
|
||||
@@ -251,11 +263,15 @@
|
||||
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
|
||||
"d3716cc5a2f5a810": "服务器地址",
|
||||
"d3d21191f32e79a5": "处理中…",
|
||||
"d58c88688e1a949d": "常用预设",
|
||||
"d60669bb26a22f5d": "留空使用默认值",
|
||||
"d6b1f203680f5496": "留空使用 adaptive thinking",
|
||||
"d86fa42c3848c680": "使用系统代理",
|
||||
"d8c47e9776cf1082": "主菜单",
|
||||
"da521d1c1cbd36af": "需要授权安装证书",
|
||||
"da7ae985487c38e6": "近1小时",
|
||||
"daede9881787abe7": "备注",
|
||||
"db340a9896306d08": "测试已取消",
|
||||
"dbd3596e4a86f3c2": "配置模型",
|
||||
"ddde16f8839da3ce": "总请求",
|
||||
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
|
||||
@@ -290,7 +306,6 @@
|
||||
"ee239f3943293f87": "周日",
|
||||
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||
"f2bdc88464c51c2e": "显示 API Key",
|
||||
"f3a76d896853c1df": "未命中",
|
||||
"f4694c46b1e19602": "最终请求类型",
|
||||
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
|
||||
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { desktopPlatform } from "../native/platform";
|
||||
import styles from "./AppFrame.module.scss";
|
||||
import { AppHeader } from "./AppHeader";
|
||||
|
||||
@@ -17,13 +18,3 @@ export function AppFrame() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type DesktopPlatform = "macos" | "windows" | "linux";
|
||||
|
||||
function desktopPlatform(): DesktopPlatform {
|
||||
// return "windows";
|
||||
const agent = navigator.userAgent;
|
||||
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
|
||||
if (/Windows/.test(agent)) return "windows";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import appIcon from "../../src-tauri/icons/32x32.png";
|
||||
import { currentAppVersion } from "../native/appLifecycle";
|
||||
import type { DesktopPlatform } from "./AppFrame";
|
||||
import type { DesktopPlatform } from "../native/platform";
|
||||
import { WindowControls } from "./WindowControls";
|
||||
import styles from "./AppHeader.module.scss";
|
||||
|
||||
|
||||
@@ -156,11 +156,14 @@
|
||||
|
||||
.actions {
|
||||
height: var(--app-toolbar-height);
|
||||
min-width: var(--app-toolbar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 2px 2px;
|
||||
justify-content: center;
|
||||
padding: 6px 6px;
|
||||
gap: 3px;
|
||||
box-shadow: 0 0px 6px 4px var(--vscode-editor-background);
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
.pageActions {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getVersion, setDockVisibility } from "@tauri-apps/api/app";
|
||||
import { 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";
|
||||
import { api } from "../api";
|
||||
import { api, type DesktopSettings } from "../api";
|
||||
import { desktopPlatform } from "./platform";
|
||||
|
||||
export function hasNativeAppLifecycle(): boolean {
|
||||
return isTauri();
|
||||
}
|
||||
|
||||
export function hasDockVisibilitySetting(): boolean {
|
||||
return hasNativeAppLifecycle() && desktopPlatform() === "macos";
|
||||
}
|
||||
|
||||
export async function currentAppVersion(): Promise<string> {
|
||||
return hasNativeAppLifecycle() ? getVersion() : "dev";
|
||||
}
|
||||
@@ -21,12 +26,24 @@ export async function writeAutostart(enabled: boolean): Promise<void> {
|
||||
await (enabled ? enable() : disable());
|
||||
}
|
||||
|
||||
export async function readSilentStart(): Promise<boolean> {
|
||||
return (await api.desktopSettings()).silent_start;
|
||||
export async function readDesktopSettings(): Promise<DesktopSettings> {
|
||||
return api.desktopSettings();
|
||||
}
|
||||
|
||||
export async function writeSilentStart(silentStart: boolean): Promise<void> {
|
||||
await api.setDesktopSettings({ silent_start: silentStart });
|
||||
const settings = await readDesktopSettings();
|
||||
await api.setDesktopSettings({ ...settings, silent_start: silentStart });
|
||||
}
|
||||
|
||||
export async function writeDockIconVisibility(visible: boolean): Promise<void> {
|
||||
const settings = await readDesktopSettings();
|
||||
await setDockVisibility(visible);
|
||||
try {
|
||||
await api.setDesktopSettings({ ...settings, show_dock_icon: visible });
|
||||
} catch (cause) {
|
||||
await setDockVisibility(settings.show_dock_icon).catch(() => {});
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdate(): Promise<Update | null> {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type DesktopPlatform = "macos" | "windows" | "linux";
|
||||
|
||||
export function desktopPlatform(): DesktopPlatform {
|
||||
const agent = navigator.userAgent;
|
||||
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
|
||||
if (/Windows/.test(agent)) return "windows";
|
||||
return "linux";
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, type Model, type ModelInput } from "../api";
|
||||
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
|
||||
import { CursorModelCards } from "../components/cursor/CursorModelCards";
|
||||
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "../components/cursor/CursorModelCards";
|
||||
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
|
||||
import styles from "../components/cursor/CursorSettings.module.scss";
|
||||
@@ -31,7 +31,19 @@ export function CursorSettingsPage() {
|
||||
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
|
||||
const [savingAndTesting, setSavingAndTesting] = useState(false);
|
||||
const [batchTesting, setBatchTesting] = useState(false);
|
||||
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
|
||||
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
|
||||
const caReady = cursorHarness?.ca === "ready";
|
||||
const providerGroups = cursorModelGroups(models, "provider");
|
||||
const typeGroups = cursorModelGroups(models, "type");
|
||||
const canGroupByProvider = providerGroups.length > 1;
|
||||
const canGroupByType = typeGroups.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
if ((grouping === "provider" && !canGroupByProvider) || (grouping === "type" && !canGroupByType)) {
|
||||
setGrouping("flat");
|
||||
}
|
||||
}, [canGroupByProvider, canGroupByType, grouping]);
|
||||
|
||||
useEffect(() => {
|
||||
if (caCommand) void api.copyCursorText(caCommand);
|
||||
@@ -93,19 +105,45 @@ export function CursorSettingsPage() {
|
||||
message(errorText(cause));
|
||||
}
|
||||
};
|
||||
const testModel = async (model: Model, notify = true) => {
|
||||
const cancelModelTest = async (modelHash: string) => {
|
||||
const active = activeModelTests.current.get(modelHash);
|
||||
if (!active || active.cancelling) return;
|
||||
active.cancelling = true;
|
||||
try {
|
||||
await api.cancelModelTest(modelHash, active.testId);
|
||||
active.controller.abort();
|
||||
} catch (cause) {
|
||||
active.cancelling = false;
|
||||
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
|
||||
}
|
||||
};
|
||||
const cancelAllModelTests = async () => {
|
||||
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
|
||||
};
|
||||
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
|
||||
if (activeModelTests.current.has(model.model_hash)) {
|
||||
await cancelModelTest(model.model_hash);
|
||||
return "cancelled";
|
||||
}
|
||||
const active = { testId: crypto.randomUUID(), controller: new AbortController(), cancelling: false };
|
||||
activeModelTests.current.set(model.model_hash, active);
|
||||
setTestingModelHashes((current) => new Set(current).add(model.model_hash));
|
||||
try {
|
||||
const result = await api.testModel(model.model_hash);
|
||||
const result = await api.testModel(model.model_hash, active.testId, active.controller.signal);
|
||||
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result }));
|
||||
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
|
||||
return true;
|
||||
return "success";
|
||||
} catch (cause) {
|
||||
if (active.cancelling || active.controller.signal.aborted) {
|
||||
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "cancelled" }));
|
||||
return "cancelled";
|
||||
}
|
||||
const error = errorText(cause);
|
||||
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
|
||||
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
|
||||
return false;
|
||||
return "failure";
|
||||
} finally {
|
||||
if (activeModelTests.current.get(model.model_hash) === active) activeModelTests.current.delete(model.model_hash);
|
||||
setTestingModelHashes((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(model.model_hash);
|
||||
@@ -115,29 +153,33 @@ export function CursorSettingsPage() {
|
||||
};
|
||||
const saveAndTest = async () => {
|
||||
setSavingAndTesting(true);
|
||||
let saved: Model | null = null;
|
||||
try {
|
||||
const saved = await persist();
|
||||
if (!saved) return;
|
||||
setEditing(saved);
|
||||
await testModel(saved);
|
||||
await appStore.refresh();
|
||||
saved = await persist();
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setSavingAndTesting(false);
|
||||
}
|
||||
if (!saved) return;
|
||||
setEditing(saved);
|
||||
await testModel(saved);
|
||||
await appStore.refresh();
|
||||
};
|
||||
const testAllModels = async () => {
|
||||
if (!models.length || batchTesting) return;
|
||||
setBatchTesting(true);
|
||||
try {
|
||||
const results = await Promise.all(models.map((model) => testModel(model, false)));
|
||||
const successful = results.filter(Boolean).length;
|
||||
const failed = models.length - successful;
|
||||
message(failed === 0
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
|
||||
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
|
||||
{ duration: failed === 0 ? 2400 : 5000 });
|
||||
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("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
|
||||
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
|
||||
} finally {
|
||||
setBatchTesting(false);
|
||||
}
|
||||
@@ -166,7 +208,8 @@ export function CursorSettingsPage() {
|
||||
|
||||
const list = <CursorModelCards
|
||||
models={models}
|
||||
disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting}
|
||||
grouping={grouping}
|
||||
disabled={cursorBusy}
|
||||
testingModelHashes={testingModelHashes}
|
||||
testResults={modelTestResults}
|
||||
onTest={(model) => void testModel(model)}
|
||||
@@ -194,13 +237,24 @@ export function CursorSettingsPage() {
|
||||
</CursorCaGate></CursorCaProvider>;
|
||||
|
||||
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
|
||||
const editorTesting = savingAndTesting || Boolean(editing && testingModelHashes.has(editing.model_hash));
|
||||
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
|
||||
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
|
||||
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);
|
||||
|
||||
return <>
|
||||
{models.length > 0 && <PageActions position="left"><button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button></PageActions>}
|
||||
{models.length > 0 && <PageActions position="left">
|
||||
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
|
||||
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
|
||||
</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: Math.max(380, Math.ceil(models.length / 3) * 196), content }]} />
|
||||
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button>}>
|
||||
<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>}>
|
||||
{draft && <>
|
||||
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
</>}
|
||||
|
||||
@@ -118,7 +118,7 @@ export function HomePage() {
|
||||
{
|
||||
key: "metrics",
|
||||
estimatedHeight: 130,
|
||||
content: <HomeMetrics data={metrics} />,
|
||||
content: <HomeMetrics data={metrics} refreshVersion={refreshVersion} />,
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
sideBar-border: #00000024,
|
||||
list-activeSelectionBackground: #0000000f,
|
||||
list-activeSelectionForeground: #191919,
|
||||
list-hoverBackground: #E6E6E67A,
|
||||
list-hoverBackground: #f3f3f3,
|
||||
input-background: #ffffff,
|
||||
input-foreground: #191919,
|
||||
input-border: #00000024,
|
||||
|
||||
@@ -3,5 +3,5 @@ $font-size-xs: 13px;
|
||||
$font-size-base: 14px;
|
||||
$font-size-lg: 16px;
|
||||
$font-size-xl: 18px;
|
||||
$font-size-xxl: 24px;
|
||||
$font-size-xxl: 20px;
|
||||
$font-size-3xl: 30px;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { ModelType } from "../api";
|
||||
import deepseekIcon from "../assets/provider-icons/deepseek.svg";
|
||||
import huoshanIcon from "../assets/provider-icons/huoshan.png";
|
||||
import kimiIcon from "../assets/provider-icons/kimi.svg";
|
||||
import minimaxIcon from "../assets/provider-icons/minimax.svg";
|
||||
import zhipuIcon from "../assets/provider-icons/zhipu.svg";
|
||||
import { defaultCustomHeaders } from "./modelDefaults";
|
||||
|
||||
export interface ModelPresetEntry {
|
||||
model_id: string;
|
||||
display_name: string;
|
||||
context_window_tokens: number | null;
|
||||
max_output_tokens: number | null;
|
||||
}
|
||||
|
||||
/** 一个服务商在某种协议(anthropic / openai)下的接入端点 */
|
||||
export interface ModelPresetEndpoint {
|
||||
baseUrl: string;
|
||||
/** true 时 baseUrl 即完整请求 URL;false 时由请求协议追加标准端点路径 */
|
||||
useFullUrl: boolean;
|
||||
/** openai 协议的请求端点(useFullUrl 为 true 时忽略) */
|
||||
openaiEndpoint: string;
|
||||
/** 非空时启用自定义 Headers(claude-cli 伪装头) */
|
||||
customHeaders: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface ModelPreset {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
keyHint: string;
|
||||
/** 五家服务商均同时提供 Anthropic 与 OpenAI 兼容协议 */
|
||||
endpoints: { anthropic: ModelPresetEndpoint; openai: ModelPresetEndpoint };
|
||||
models: ModelPresetEntry[];
|
||||
}
|
||||
|
||||
const entry = (
|
||||
modelId: string,
|
||||
displayName: string,
|
||||
contextWindowTokens: number | null,
|
||||
maxOutputTokens: number | null,
|
||||
): ModelPresetEntry => ({
|
||||
model_id: modelId,
|
||||
display_name: displayName,
|
||||
context_window_tokens: contextWindowTokens,
|
||||
max_output_tokens: maxOutputTokens,
|
||||
});
|
||||
|
||||
const claudeHeaders = { ...defaultCustomHeaders };
|
||||
/** anthropic 协议:填 Base URL,自动追加 /v1/messages */
|
||||
const anthropic = (baseUrl: string): ModelPresetEndpoint => ({ baseUrl, useFullUrl: false, openaiEndpoint: "", customHeaders: claudeHeaders });
|
||||
/** openai 协议:填 Base URL,自动追加 /v1/chat/completions */
|
||||
const openaiChat = (baseUrl: string): ModelPresetEndpoint => ({ baseUrl, useFullUrl: false, openaiEndpoint: "/v1/chat/completions", customHeaders: null });
|
||||
/** openai 协议:路径不规则,直接给完整请求 URL */
|
||||
const openaiFullUrl = (url: string): ModelPresetEndpoint => ({ baseUrl: url, useFullUrl: true, openaiEndpoint: "/v1/chat/completions", customHeaders: null });
|
||||
|
||||
export const modelPresets: ModelPreset[] = [
|
||||
{
|
||||
key: "zhipu",
|
||||
name: "智谱 GLM",
|
||||
icon: zhipuIcon,
|
||||
keyHint: "bigmodel.cn → GLM Coding Plan → API Key(套餐 Key 与普通 Key 不通用)",
|
||||
endpoints: {
|
||||
anthropic: anthropic("https://open.bigmodel.cn/api/anthropic"),
|
||||
openai: openaiFullUrl("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"),
|
||||
},
|
||||
models: [
|
||||
entry("glm-5.3", "GLM 5.3", 1000000, 65536),
|
||||
entry("glm-5.2", "GLM 5.2", 200000, 32768),
|
||||
entry("glm-4.7", "GLM 4.7", 200000, 32768),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "kimi",
|
||||
name: "Kimi (Moonshot)",
|
||||
icon: kimiIcon,
|
||||
keyHint: "Kimi Code 编程套餐页获取 API Key(api.kimi.com/coding 端点)",
|
||||
endpoints: {
|
||||
anthropic: anthropic("https://api.kimi.com/coding"),
|
||||
openai: openaiChat("https://api.kimi.com/coding"),
|
||||
},
|
||||
models: [
|
||||
entry("k3", "Kimi K3", 1048576, 65536),
|
||||
entry("kimi-for-coding", "K2.7 Coding", 262144, 32768),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "deepseek",
|
||||
name: "DeepSeek",
|
||||
icon: deepseekIcon,
|
||||
keyHint: "platform.deepseek.com → API Keys",
|
||||
endpoints: {
|
||||
anthropic: anthropic("https://api.deepseek.com/anthropic"),
|
||||
openai: openaiChat("https://api.deepseek.com"),
|
||||
},
|
||||
models: [
|
||||
entry("deepseek-v4-pro", "DeepSeek V4 Pro", 1000000, 65536),
|
||||
entry("deepseek-v4-flash", "DeepSeek V4 Flash", 1000000, null),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "volcengine",
|
||||
name: "火山引擎方舟",
|
||||
icon: huoshanIcon,
|
||||
keyHint: "火山方舟 Coding Plan(ark-code-latest 路由多款代码模型)",
|
||||
endpoints: {
|
||||
anthropic: anthropic("https://ark.cn-beijing.volces.com/api/coding"),
|
||||
openai: openaiFullUrl("https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"),
|
||||
},
|
||||
models: [entry("ark-code-latest", "Ark Code Latest", 256000, 32768)],
|
||||
},
|
||||
{
|
||||
key: "minimax",
|
||||
name: "MiniMax",
|
||||
icon: minimaxIcon,
|
||||
keyHint: "platform.minimaxi.com → 订阅 Coding Plan → API Key",
|
||||
endpoints: {
|
||||
anthropic: anthropic("https://api.minimaxi.com/anthropic"),
|
||||
openai: { baseUrl: "https://api.minimaxi.com", useFullUrl: false, openaiEndpoint: "/v1/responses", customHeaders: null },
|
||||
},
|
||||
models: [
|
||||
entry("MiniMax-M3", "MiniMax M3", 1000000, 65536),
|
||||
entry("MiniMax-M2.7", "MiniMax M2.7", 205000, 32768),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const trimTrailingSlash = (url: string) => url.replace(/\/+$/, "");
|
||||
|
||||
export const presetEndpoint = (preset: ModelPreset, type: ModelType): ModelPresetEndpoint => preset.endpoints[type];
|
||||
@@ -29,24 +29,37 @@ impl ModelAssets {
|
||||
}
|
||||
|
||||
pub fn ensure(cache_root: &Path) -> Result<Self> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.build()
|
||||
.map_err(|error| Error::ModelAsset(error.to_string()))?;
|
||||
Self::ensure_with_client(cache_root, &client)
|
||||
}
|
||||
|
||||
pub fn ensure_with_client(
|
||||
cache_root: &Path,
|
||||
client: &reqwest::blocking::Client,
|
||||
) -> Result<Self> {
|
||||
let directory = cache_root.join("models/potion-code-16M-v2");
|
||||
fs::create_dir_all(&directory).map_err(|error| Error::io(&directory, error))?;
|
||||
let model = Self::model_path(cache_root);
|
||||
let tokenizer = directory.join("tokenizer.json");
|
||||
ensure_asset(&model, MODEL_URL, MODEL_SHA256)?;
|
||||
ensure_asset(&tokenizer, TOKENIZER_URL, TOKENIZER_SHA256)?;
|
||||
ensure_asset(client, &model, MODEL_URL, MODEL_SHA256)?;
|
||||
ensure_asset(client, &tokenizer, TOKENIZER_URL, TOKENIZER_SHA256)?;
|
||||
Ok(Self { model, tokenizer })
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_asset(path: &Path, url: &str, expected: &str) -> Result<()> {
|
||||
fn ensure_asset(
|
||||
client: &reqwest::blocking::Client,
|
||||
path: &Path,
|
||||
url: &str,
|
||||
expected: &str,
|
||||
) -> Result<()> {
|
||||
if path.is_file() && digest(path)? == expected {
|
||||
return Ok(());
|
||||
}
|
||||
let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
|
||||
let response = reqwest::blocking::Client::builder()
|
||||
.build()
|
||||
.map_err(|error| Error::ModelAsset(error.to_string()))?
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.and_then(reqwest::blocking::Response::error_for_status)
|
||||
|
||||
@@ -35,12 +35,22 @@ static EMBEDDERS: LazyLock<Mutex<HashMap<PathBuf, Arc<StaticEmbedder>>>> =
|
||||
|
||||
impl SearchEngine {
|
||||
pub fn load_default(config: SembleConfig) -> Result<Self> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.build()
|
||||
.map_err(|error| Error::ModelAsset(error.to_string()))?;
|
||||
Self::load_default_with_client(config, &client)
|
||||
}
|
||||
|
||||
pub fn load_default_with_client(
|
||||
config: SembleConfig,
|
||||
client: &reqwest::blocking::Client,
|
||||
) -> Result<Self> {
|
||||
let model_path = ModelAssets::model_path(&config.cache_dir);
|
||||
let cached = { EMBEDDERS.lock().get(&model_path).cloned() };
|
||||
let embedder = if let Some(embedder) = cached {
|
||||
embedder
|
||||
} else {
|
||||
let assets = ModelAssets::ensure(&config.cache_dir)?;
|
||||
let assets = ModelAssets::ensure_with_client(&config.cache_dir, client)?;
|
||||
let embedder = Arc::new(StaticEmbedder::load(&assets.model, &assets.tokenizer)?);
|
||||
EMBEDDERS
|
||||
.lock()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
function readOptions(args) {
|
||||
const options = new Map();
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const name = args[index];
|
||||
const value = args[index + 1];
|
||||
if (!name?.startsWith("--") || value === undefined) {
|
||||
throw new Error(`invalid argument near ${name ?? "end of command"}`);
|
||||
}
|
||||
options.set(name.slice(2), value);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function required(options, name) {
|
||||
const value = options.get(name)?.trim();
|
||||
if (!value) throw new Error(`--${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeTauriUpdate(manifest, release, repository, version) {
|
||||
if (manifest.version !== version) {
|
||||
throw new Error(
|
||||
`updater manifest version ${manifest.version ?? "is missing"}; expected ${version}`,
|
||||
);
|
||||
}
|
||||
if (release.tag_name !== `v${version}`) {
|
||||
throw new Error(
|
||||
`GitHub release tag ${release.tag_name ?? "is missing"}; expected v${version}`,
|
||||
);
|
||||
}
|
||||
if (!manifest.platforms || typeof manifest.platforms !== "object") {
|
||||
throw new Error("updater manifest has no platforms");
|
||||
}
|
||||
|
||||
const assetsBySourceUrl = new Map();
|
||||
const publicAssetUrls = new Set();
|
||||
for (const asset of release.assets ?? []) {
|
||||
if (!asset?.id || !asset?.name) continue;
|
||||
const publicUrl = `https://github.com/${repository}/releases/download/v${version}/${encodeURIComponent(asset.name)}`;
|
||||
assetsBySourceUrl.set(
|
||||
`https://api.github.com/repos/${repository}/releases/assets/${asset.id}`,
|
||||
publicUrl,
|
||||
);
|
||||
if (asset.browser_download_url) {
|
||||
assetsBySourceUrl.set(asset.browser_download_url, publicUrl);
|
||||
}
|
||||
publicAssetUrls.add(publicUrl);
|
||||
}
|
||||
|
||||
for (const [platform, entry] of Object.entries(manifest.platforms)) {
|
||||
if (!entry?.signature || !entry?.url) {
|
||||
throw new Error(`updater platform ${platform} is missing its URL or signature`);
|
||||
}
|
||||
const publicUrl = assetsBySourceUrl.get(entry.url) ?? entry.url;
|
||||
if (!publicAssetUrls.has(publicUrl)) {
|
||||
throw new Error(`updater platform ${platform} references an unknown release asset`);
|
||||
}
|
||||
entry.url = publicUrl;
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = readOptions(process.argv.slice(2));
|
||||
const manifestPath = resolve(required(options, "manifest"));
|
||||
const releasePath = resolve(required(options, "release"));
|
||||
const repository = required(options, "repository");
|
||||
const version = required(options, "version").replace(/^v/, "");
|
||||
|
||||
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
|
||||
throw new Error(`invalid GitHub repository: ${repository}`);
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error(`invalid semantic version: ${version}`);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
const release = JSON.parse(await readFile(releasePath, "utf8"));
|
||||
const normalized = normalizeTauriUpdate(
|
||||
manifest,
|
||||
release,
|
||||
repository,
|
||||
version,
|
||||
);
|
||||
await writeFile(manifestPath, `${JSON.stringify(normalized, null, 2)}\n`);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ parking_lot = "0.12"
|
||||
pem = "3"
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["brotli", "deflate", "gzip", "json", "rustls-tls", "socks", "stream", "system-proxy", "zstd"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "brotli", "deflate", "gzip", "json", "native-tls", "socks", "stream", "system-proxy", "zstd"] }
|
||||
regex = "1"
|
||||
rcgen = { version = "0.14", features = ["aws_lc_rs", "pem", "x509-parser"] }
|
||||
scraper = "0.24"
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::model::{CanonicalMessage, RuntimeEvent, ToolResult};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Debug)]
|
||||
pub struct MessageInsertion {
|
||||
pub messages: Vec<CanonicalMessage>,
|
||||
pub delivered: oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientCommand {
|
||||
ToolResult(ToolResult),
|
||||
RuntimeMessage(CanonicalMessage),
|
||||
RuntimeEvent(RuntimeEvent),
|
||||
InsertMessages(MessageInsertion),
|
||||
ClientClosed { error: String },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
@@ -130,8 +130,8 @@ pub fn api_router(service: ControlService) -> Router {
|
||||
put(models::update).delete(models::remove),
|
||||
)
|
||||
.route(
|
||||
"/__byok-api__/api/models/{model_hash}/test",
|
||||
post(models::test),
|
||||
"/__byok-api__/api/models/{model_hash}/test/{test_id}",
|
||||
post(models::test).delete(models::cancel),
|
||||
)
|
||||
.route("/__byok-api__/api/llm-calls", get(calls::list))
|
||||
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
|
||||
|
||||
@@ -64,9 +64,17 @@ pub async fn update(
|
||||
|
||||
pub async fn test(
|
||||
State(service): State<ControlService>,
|
||||
Path(model_hash): Path<String>,
|
||||
Path((model_hash, test_id)): Path<(String, String)>,
|
||||
) -> Result<Json<ModelConnectivityResult>> {
|
||||
Ok(Json(service.test_model(&model_hash).await?))
|
||||
Ok(Json(service.test_model(&model_hash, &test_id).await?))
|
||||
}
|
||||
|
||||
pub async fn cancel(
|
||||
State(service): State<ControlService>,
|
||||
Path((_model_hash, test_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode> {
|
||||
service.cancel_model_test(&test_id);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn discover(
|
||||
|
||||
+322
-30
@@ -1,4 +1,8 @@
|
||||
use std::{collections::BTreeSet, sync::Arc, time::Instant};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use futures_util::StreamExt;
|
||||
@@ -33,6 +37,7 @@ pub struct ControlService {
|
||||
store: Store,
|
||||
cursor_harness: CursorHarness,
|
||||
provider: Arc<dyn Provider>,
|
||||
model_tests: Arc<Mutex<BTreeMap<String, CancellationToken>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -142,6 +147,7 @@ impl ControlService {
|
||||
cursor_harness: CursorHarness::new(store.clone())?,
|
||||
store,
|
||||
provider,
|
||||
model_tests: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,7 +249,46 @@ impl ControlService {
|
||||
self.store.update_model(model_hash, input).await
|
||||
}
|
||||
|
||||
pub async fn test_model(&self, model_hash: &str) -> Result<ModelConnectivityResult> {
|
||||
pub async fn test_model(
|
||||
&self,
|
||||
model_hash: &str,
|
||||
test_id: &str,
|
||||
) -> Result<ModelConnectivityResult> {
|
||||
let cancellation = CancellationToken::new();
|
||||
let cancellation = {
|
||||
let mut tests = self
|
||||
.model_tests
|
||||
.lock()
|
||||
.expect("model test registry mutex poisoned");
|
||||
tests
|
||||
.entry(test_id.to_owned())
|
||||
.or_insert_with(|| cancellation.clone())
|
||||
.clone()
|
||||
};
|
||||
let result = self.run_model_test(model_hash, cancellation).await;
|
||||
self.model_tests
|
||||
.lock()
|
||||
.expect("model test registry mutex poisoned")
|
||||
.remove(test_id);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn cancel_model_test(&self, test_id: &str) {
|
||||
let cancellation = {
|
||||
let mut tests = self
|
||||
.model_tests
|
||||
.lock()
|
||||
.expect("model test registry mutex poisoned");
|
||||
tests.entry(test_id.to_owned()).or_default().clone()
|
||||
};
|
||||
cancellation.cancel();
|
||||
}
|
||||
|
||||
async fn run_model_test(
|
||||
&self,
|
||||
model_hash: &str,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<ModelConnectivityResult> {
|
||||
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.";
|
||||
|
||||
@@ -255,12 +300,11 @@ impl ControlService {
|
||||
let mut model = ModelSpec::new(model_hash);
|
||||
configured.configure(&mut model);
|
||||
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
|
||||
let test_id = format!("model-test-{}", uuid::Uuid::new_v4());
|
||||
let call_id = test_id.clone();
|
||||
let call_id = format!("model-test-{}", uuid::Uuid::new_v4());
|
||||
let invocation = ModelInvocation {
|
||||
call_id: test_id.clone(),
|
||||
run_id: test_id.clone(),
|
||||
conversation_id: test_id,
|
||||
call_id: call_id.clone(),
|
||||
run_id: call_id.clone(),
|
||||
conversation_id: call_id.clone(),
|
||||
provider_call_index: 0,
|
||||
request: ModelRequest {
|
||||
prompt: PromptSpec {
|
||||
@@ -277,7 +321,6 @@ impl ControlService {
|
||||
}],
|
||||
},
|
||||
};
|
||||
let cancellation = CancellationToken::new();
|
||||
let started = Instant::now();
|
||||
let mut first_text_at = None;
|
||||
let mut output_tokens = None;
|
||||
@@ -305,6 +348,9 @@ impl ControlService {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
if !finished {
|
||||
return Err(Error::Protocol(
|
||||
"provider stream ended without Done during connectivity test".into(),
|
||||
@@ -659,19 +705,95 @@ fn model_discovery_url(base_url: &str) -> Result<Url> {
|
||||
"model request URL must contain a host".into(),
|
||||
));
|
||||
}
|
||||
url.set_path("/v1/models");
|
||||
// 在现有路径上追加,而不是整段替换:多数编程套餐的 API 挂在子路径下
|
||||
// (/api/anthropic、/coding、/api/paas/v4 等),直接 set_path("/v1/models")
|
||||
// 会把这些前缀吃掉,发现请求必然 404
|
||||
let path = url.path().trim_end_matches('/');
|
||||
let last = path.rsplit('/').next().unwrap_or("");
|
||||
let versioned = last.len() > 1
|
||||
&& last.starts_with('v')
|
||||
&& last[1..].bytes().all(|byte| byte.is_ascii_digit());
|
||||
let new_path = if let Some(parent) = path.strip_suffix("/chat/completions") {
|
||||
// 完整请求 URL:剥掉端点段(chat/completions 是两段),换成 models
|
||||
format!("{parent}/models")
|
||||
} else if let Some(parent) = path
|
||||
.strip_suffix("/responses")
|
||||
.or_else(|| path.strip_suffix("/messages"))
|
||||
.or_else(|| path.strip_suffix("/completions"))
|
||||
{
|
||||
format!("{parent}/models")
|
||||
} else if path.is_empty() {
|
||||
"/v1/models".to_string()
|
||||
} else if versioned {
|
||||
// 已带版本段(/v1、/api/v3、/api/paas/v4):只补 models
|
||||
format!("{path}/models")
|
||||
} else {
|
||||
format!("{path}/v1/models")
|
||||
};
|
||||
url.set_path(&new_path);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn model_discovery_urls(base_url: &str) -> Result<Vec<Url>> {
|
||||
let mut configured = Url::parse(base_url)
|
||||
.map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
|
||||
let path = configured.path().trim_end_matches('/');
|
||||
let tail = path.rsplit('/').next().unwrap_or_default();
|
||||
if matches!(tail.to_ascii_lowercase().as_str(), "model" | "models") {
|
||||
configured.set_query(None);
|
||||
configured.set_fragment(None);
|
||||
return Ok(vec![configured]);
|
||||
}
|
||||
|
||||
let primary = model_discovery_url(base_url)?;
|
||||
let versioned = tail.len() > 1
|
||||
&& tail.starts_with('v')
|
||||
&& tail[1..].bytes().all(|byte| byte.is_ascii_digit());
|
||||
let complete_request_url = [
|
||||
"/chat/completions",
|
||||
"/responses",
|
||||
"/messages",
|
||||
"/completions",
|
||||
]
|
||||
.iter()
|
||||
.any(|suffix| path.to_ascii_lowercase().ends_with(suffix));
|
||||
if versioned || complete_request_url {
|
||||
return Ok(vec![primary]);
|
||||
}
|
||||
|
||||
let Some(prefix) = primary.path().strip_suffix("/v1/models") else {
|
||||
return Ok(vec![primary]);
|
||||
};
|
||||
let mut fallback = primary.clone();
|
||||
fallback.set_path(&format!("{prefix}/models"));
|
||||
Ok(vec![primary, fallback])
|
||||
}
|
||||
|
||||
async fn openai_models(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
custom_headers: &serde_json::Value,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut request = client.get(model_discovery_url(base_url)?);
|
||||
let mut last_error = None;
|
||||
for url in model_discovery_urls(base_url)? {
|
||||
match openai_models_at(client, url, api_key, custom_headers).await {
|
||||
Ok(models) => return Ok(models),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| Error::Provider("no model discovery URL available".into())))
|
||||
}
|
||||
|
||||
async fn openai_models_at(
|
||||
client: &reqwest::Client,
|
||||
url: Url,
|
||||
api_key: &str,
|
||||
custom_headers: &serde_json::Value,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut request = client.get(url);
|
||||
if !api_key.is_empty() {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
@@ -693,12 +815,28 @@ async fn anthropic_models(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
custom_headers: &serde_json::Value,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut last_error = None;
|
||||
for url in model_discovery_urls(base_url)? {
|
||||
match anthropic_models_at(client, url, api_key, custom_headers).await {
|
||||
Ok(models) => return Ok(models),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| Error::Provider("no model discovery URL available".into())))
|
||||
}
|
||||
|
||||
async fn anthropic_models_at(
|
||||
client: &reqwest::Client,
|
||||
url: Url,
|
||||
api_key: &str,
|
||||
custom_headers: &serde_json::Value,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut after_id = None::<String>;
|
||||
let mut found = BTreeSet::new();
|
||||
loop {
|
||||
let mut request = client
|
||||
.get(model_discovery_url(base_url)?)
|
||||
.get(url.clone())
|
||||
.query(&[("limit", "100")])
|
||||
.header("anthropic-version", "2023-06-01");
|
||||
if !api_key.is_empty() {
|
||||
@@ -792,17 +930,112 @@ mod tests {
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
model::{ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
|
||||
model::{ModelConfig, ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
|
||||
provider::{FinishReason, ModelEvent, Provider, ProviderStream},
|
||||
store::Store,
|
||||
};
|
||||
|
||||
use super::ControlService;
|
||||
use super::{model_discovery_url, model_discovery_urls, ControlService};
|
||||
|
||||
#[test]
|
||||
fn model_discovery_url_appends_to_path() {
|
||||
let cases = [
|
||||
(
|
||||
"https://api.deepseek.com",
|
||||
"https://api.deepseek.com/v1/models",
|
||||
),
|
||||
(
|
||||
"https://open.bigmodel.cn/api/anthropic",
|
||||
"https://open.bigmodel.cn/api/anthropic/v1/models",
|
||||
),
|
||||
(
|
||||
"https://api.kimi.com/coding",
|
||||
"https://api.kimi.com/coding/v1/models",
|
||||
),
|
||||
(
|
||||
"https://api.moonshot.cn/v1",
|
||||
"https://api.moonshot.cn/v1/models",
|
||||
),
|
||||
(
|
||||
"https://ark.cn-beijing.volces.com/api/v3",
|
||||
"https://ark.cn-beijing.volces.com/api/v3/models",
|
||||
),
|
||||
(
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/models",
|
||||
),
|
||||
];
|
||||
for (base, expected) in cases {
|
||||
assert_eq!(
|
||||
model_discovery_url(base).unwrap().as_str(),
|
||||
expected,
|
||||
"base: {base}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_discovery_urls_fall_back_without_a_version() {
|
||||
let cases = [
|
||||
(
|
||||
"https://opencode.ai/zen/go/v1",
|
||||
vec!["https://opencode.ai/zen/go/v1/models"],
|
||||
),
|
||||
(
|
||||
"https://opencode.ai/zen/go",
|
||||
vec![
|
||||
"https://opencode.ai/zen/go/v1/models",
|
||||
"https://opencode.ai/zen/go/models",
|
||||
],
|
||||
),
|
||||
(
|
||||
"https://api.example.com/openai/v1/models",
|
||||
vec!["https://api.example.com/openai/v1/models"],
|
||||
),
|
||||
];
|
||||
for (base, expected) in cases {
|
||||
let actual = model_discovery_urls(base)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(actual, expected, "base: {base}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_model_discovery_uses_the_unversioned_fallback() {
|
||||
let app = axum::Router::new().route(
|
||||
"/proxy/models",
|
||||
axum::routing::get(|| async {
|
||||
axum::Json(serde_json::json!({ "data": [{ "id": "model-a" }] }))
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
|
||||
let models = super::openai_models(
|
||||
&reqwest::Client::new(),
|
||||
&format!("http://{address}/proxy"),
|
||||
"secret",
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(models, vec!["model-a"]);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
struct TestProvider {
|
||||
invocation: Arc<Mutex<Option<ModelInvocation>>>,
|
||||
}
|
||||
|
||||
struct CancellationProvider {
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl Provider for TestProvider {
|
||||
fn stream(
|
||||
&self,
|
||||
@@ -826,17 +1059,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connectivity_test_uses_the_configured_llm_provider() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("control.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let invocation = Arc::new(Mutex::new(None));
|
||||
let model = store
|
||||
impl Provider for CancellationProvider {
|
||||
fn stream(
|
||||
&self,
|
||||
_invocation: ModelInvocation,
|
||||
cancellation: CancellationToken,
|
||||
) -> ProviderStream {
|
||||
let started = self.started.clone();
|
||||
Box::pin(async_stream::try_stream! {
|
||||
started.notify_one();
|
||||
cancellation.cancelled().await;
|
||||
if false { yield ModelEvent::TextStart; }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_test_model(store: &Store) -> ModelConfig {
|
||||
store
|
||||
.create_model(&ModelConfigInput {
|
||||
model_id: "reasoning-model".into(),
|
||||
display_name: "Reasoning Model".into(),
|
||||
@@ -861,7 +1100,20 @@ mod tests {
|
||||
thinking_budget_tokens: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connectivity_test_uses_the_configured_llm_provider() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("control.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let invocation = Arc::new(Mutex::new(None));
|
||||
let model = create_test_model(&store).await;
|
||||
let service = ControlService::new(
|
||||
store,
|
||||
Arc::new(TestProvider {
|
||||
@@ -870,7 +1122,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = service.test_model(&model.model_hash).await.unwrap();
|
||||
let result = service
|
||||
.test_model(&model.model_hash, "test-id")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.output, "OK");
|
||||
assert_eq!(result.output_tokens, 2);
|
||||
@@ -892,6 +1147,42 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connectivity_test_can_be_cancelled() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("cancel.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let model = create_test_model(&store).await;
|
||||
let started = Arc::new(tokio::sync::Notify::new());
|
||||
let service = ControlService::new(
|
||||
store,
|
||||
Arc::new(CancellationProvider {
|
||||
started: started.clone(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let running_service = service.clone();
|
||||
let model_hash = model.model_hash.clone();
|
||||
let task =
|
||||
tokio::spawn(
|
||||
async move { running_service.test_model(&model_hash, "cancel-test").await },
|
||||
);
|
||||
|
||||
started.notified().await;
|
||||
service.cancel_model_test("cancel-test");
|
||||
|
||||
assert!(matches!(task.await.unwrap(), Err(crate::Error::Cancelled)));
|
||||
assert!(!service
|
||||
.model_tests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains_key("cancel-test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connectivity_output_token_estimate_handles_words_and_empty_text() {
|
||||
assert_eq!(super::estimate_output_tokens("1 2 3"), 3);
|
||||
@@ -899,12 +1190,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_discovery_url_uses_only_the_provider_origin() {
|
||||
fn model_discovery_url_keeps_provider_path_prefix() {
|
||||
assert_eq!(
|
||||
super::model_discovery_url("https://example.com:8443/arbitrary/v1/chat/completions")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://example.com:8443/v1/models"
|
||||
"https://example.com:8443/arbitrary/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -933,7 +1224,7 @@ mod tests {
|
||||
|
||||
let (sender, mut requests) = tokio::sync::mpsc::unbounded_channel();
|
||||
let app = axum::Router::new()
|
||||
.route("/v1/models", axum::routing::get(models))
|
||||
.route("/custom/models", axum::routing::get(models))
|
||||
.with_state(sender);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
@@ -970,7 +1261,8 @@ mod tests {
|
||||
assert_eq!(result.models, vec!["model-a"]);
|
||||
let (method, uri, headers, body) = requests.recv().await.unwrap();
|
||||
assert_eq!(method, axum::http::Method::GET);
|
||||
assert_eq!(uri.path(), "/v1/models");
|
||||
// /custom/responses 剥掉端点段后是 /custom,发现地址为 /custom/models
|
||||
assert_eq!(uri.path(), "/custom/models");
|
||||
assert!(body.is_empty());
|
||||
assert!(headers.get(axum::http::header::USER_AGENT).is_none());
|
||||
assert_eq!(headers.get("x-tenant").unwrap(), "tenant-a");
|
||||
|
||||
+55
-15
@@ -48,7 +48,11 @@ impl CursorActor {
|
||||
let tool_runtime = CursorToolRuntime::default();
|
||||
let context_sync =
|
||||
RequestContextSynchronizer::new(handle.clone(), dependencies.store.clone());
|
||||
let tools = ToolDispatcher::with_results(tool_runtime.clone(), results_tx.clone());
|
||||
let tools = ToolDispatcher::with_results(
|
||||
tool_runtime.clone(),
|
||||
results_tx.clone(),
|
||||
dependencies.store.clone(),
|
||||
);
|
||||
let mut run_resources = Some((results_rx, runtime_actions_rx, dependencies));
|
||||
loop {
|
||||
let command = match receiver.recv().await {
|
||||
@@ -151,15 +155,39 @@ impl CursorActor {
|
||||
context.dynamic_tools.keys().cloned().collect(),
|
||||
context.turn_user.clone(),
|
||||
);
|
||||
if context.background_completion
|
||||
&& dependencies
|
||||
.run_registry
|
||||
.insert_messages(
|
||||
&prepared.conversation_id,
|
||||
prepared.initial_messages.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::cursor::lifecycle::finish_success(
|
||||
&handle,
|
||||
);
|
||||
let _ = handle
|
||||
.command(CursorCommand::Finished)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let cancellation = handle.cancellation();
|
||||
let (port, core) = crate::client::session(256);
|
||||
let core_commands = core.commands.clone();
|
||||
let actor = RunActor::new(
|
||||
dependencies.store.clone(),
|
||||
dependencies.provider,
|
||||
dependencies.run_registry,
|
||||
);
|
||||
let core_run =
|
||||
actor.spawn(prepared, port, cancellation).await;
|
||||
let core_run = actor
|
||||
.spawn(
|
||||
prepared,
|
||||
port,
|
||||
core_commands,
|
||||
cancellation,
|
||||
)
|
||||
.await;
|
||||
let session = CursorSession::new(
|
||||
handle.clone(),
|
||||
dependencies.store,
|
||||
@@ -181,7 +209,6 @@ impl CursorActor {
|
||||
%error,
|
||||
"Cursor session failed"
|
||||
);
|
||||
handle.cancel();
|
||||
let _ = crate::cursor::lifecycle::fail(
|
||||
&handle, &error,
|
||||
);
|
||||
@@ -235,12 +262,14 @@ impl CursorActor {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if tool_runtime.take_exec(close.id).await.is_some()
|
||||
match codec::stream_closed(close.id, &tool_runtime)
|
||||
.await
|
||||
{
|
||||
results_tx.send_error(crate::Error::Protocol(format!(
|
||||
"Exec stream closed before result for id: {}",
|
||||
close.id
|
||||
)));
|
||||
Ok(Some(completion)) => {
|
||||
results_tx.send(completion)
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => results_tx.send_error(error),
|
||||
}
|
||||
}
|
||||
Some(Message::Throw(throw)) => {
|
||||
@@ -311,13 +340,12 @@ impl CursorActor {
|
||||
//
|
||||
// The remaining unimplemented Action variants are
|
||||
// ShellCommandAction, StartPlanAction,
|
||||
// AsyncAskQuestionCompletionAction, CancelSubagentAction,
|
||||
// BackgroundShellAction, BackgroundSubagentAction,
|
||||
// AsyncAskQuestionCompletionAction, BackgroundShellAction,
|
||||
// BackgroundSubagentAction,
|
||||
// SubscriptionNotificationAction and GoalContinuationAction.
|
||||
// CancelSubagentAction must not start an LLM; variants whose wire
|
||||
// behavior is not captured yet need evidence before assigning
|
||||
// semantics. Every unsupported runtime Action must return an explicit
|
||||
// Protocol Error rather than falling through silently.
|
||||
// Variants whose wire behavior is not captured yet need evidence
|
||||
// before assigning semantics. Every unsupported runtime Action must
|
||||
// return an explicit Protocol Error rather than falling through silently.
|
||||
Some(
|
||||
pb::agent_client_message::Message::ConversationAction(
|
||||
action,
|
||||
@@ -341,6 +369,18 @@ impl CursorActor {
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(
|
||||
pb::conversation_action::Action::CancelSubagentAction(
|
||||
action,
|
||||
),
|
||||
) => {
|
||||
if let Some(id) = tool_runtime
|
||||
.running_task_exec_id(&action.subagent_id)
|
||||
.await
|
||||
{
|
||||
let _ = handle.emit(&codec::abort(id));
|
||||
}
|
||||
}
|
||||
Some(action) => {
|
||||
results_tx.send_error(crate::Error::Protocol(format!(
|
||||
"unsupported runtime ConversationAction: {}",
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
use crate::{store::BlobId, store::Store};
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::store::{BlobId, BufferedCursorTraceChunk, Store};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CursorTraceRecorder {
|
||||
store: Store,
|
||||
request_id: String,
|
||||
chunks: Arc<Mutex<TraceChunkBuffer>>,
|
||||
finished: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TraceChunkBuffer {
|
||||
chunks: Vec<BufferedCursorTraceChunk>,
|
||||
bytes: usize,
|
||||
first_chunk_at: Option<Instant>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
const MAX_BUFFERED_CHUNKS: usize = 32;
|
||||
const MAX_BUFFERED_BYTES: usize = 256 * 1024;
|
||||
const MAX_BUFFER_AGE: Duration = Duration::from_millis(50);
|
||||
|
||||
impl CursorTraceRecorder {
|
||||
pub async fn begin(
|
||||
store: Store,
|
||||
@@ -21,6 +45,8 @@ impl CursorTraceRecorder {
|
||||
Ok(true) => Some(Self {
|
||||
store,
|
||||
request_id: request_id.into(),
|
||||
chunks: Arc::new(Mutex::new(TraceChunkBuffer::default())),
|
||||
finished: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
Ok(false) => None,
|
||||
Err(error) => {
|
||||
@@ -35,6 +61,8 @@ impl CursorTraceRecorder {
|
||||
Ok(true) => Some(Self {
|
||||
store,
|
||||
request_id: request_id.into(),
|
||||
chunks: Arc::new(Mutex::new(TraceChunkBuffer::default())),
|
||||
finished: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
Ok(false) => None,
|
||||
Err(error) => {
|
||||
@@ -115,16 +143,56 @@ impl CursorTraceRecorder {
|
||||
}
|
||||
|
||||
pub async fn response_chunk(&self, source: &str, data: &[u8]) {
|
||||
if let Err(error) = self
|
||||
.store
|
||||
.add_cursor_trace_response_chunk(&self.request_id, source, data)
|
||||
.await
|
||||
let mut buffer = self.chunks.lock().await;
|
||||
if self.finished.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let schedule_flush = if buffer.chunks.is_empty() {
|
||||
buffer.generation = buffer.generation.wrapping_add(1);
|
||||
buffer.first_chunk_at = Some(Instant::now());
|
||||
Some(buffer.generation)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
buffer.bytes += data.len();
|
||||
buffer
|
||||
.chunks
|
||||
.push(BufferedCursorTraceChunk::new(source, data));
|
||||
let expired = buffer
|
||||
.first_chunk_at
|
||||
.is_some_and(|started| started.elapsed() >= MAX_BUFFER_AGE);
|
||||
if buffer.chunks.len() >= MAX_BUFFERED_CHUNKS
|
||||
|| buffer.bytes >= MAX_BUFFERED_BYTES
|
||||
|| expired
|
||||
{
|
||||
tracing::warn!(request_id = self.request_id, %error, "failed to record Cursor response chunk");
|
||||
if let Err(error) = self.flush_locked(&mut buffer).await {
|
||||
tracing::warn!(request_id = self.request_id, %error, "failed to record Cursor response chunk");
|
||||
}
|
||||
}
|
||||
drop(buffer);
|
||||
if let Some(generation) = schedule_flush {
|
||||
let recorder = self.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(MAX_BUFFER_AGE).await;
|
||||
let mut buffer = recorder.chunks.lock().await;
|
||||
if buffer.generation == generation {
|
||||
if let Err(error) = recorder.flush_locked(&mut buffer).await {
|
||||
tracing::warn!(request_id = recorder.request_id, %error, "failed to flush Cursor response chunks");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn finish(&self, error: Option<&str>) {
|
||||
if self.finished.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let mut buffer = self.chunks.lock().await;
|
||||
if let Err(store_error) = self.flush_locked(&mut buffer).await {
|
||||
tracing::warn!(request_id = self.request_id, %store_error, "failed to flush Cursor response chunks");
|
||||
}
|
||||
drop(buffer);
|
||||
if let Err(store_error) = self
|
||||
.store
|
||||
.finish_cursor_trace(&self.request_id, error)
|
||||
@@ -133,4 +201,24 @@ impl CursorTraceRecorder {
|
||||
tracing::warn!(request_id = self.request_id, %store_error, "failed to finish Cursor trace");
|
||||
}
|
||||
}
|
||||
|
||||
async fn flush_locked(&self, buffer: &mut TraceChunkBuffer) -> crate::Result<()> {
|
||||
if buffer.chunks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let chunks = std::mem::take(&mut buffer.chunks);
|
||||
buffer.bytes = 0;
|
||||
buffer.first_chunk_at = None;
|
||||
if let Err(error) = self
|
||||
.store
|
||||
.add_cursor_trace_response_chunks(&self.request_id, &chunks)
|
||||
.await
|
||||
{
|
||||
buffer.bytes = chunks.iter().map(|chunk| chunk.data.len()).sum();
|
||||
buffer.first_chunk_at = Some(Instant::now());
|
||||
buffer.chunks = chunks;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{cursor::proto::agent::v1 as pb, Error, Result};
|
||||
|
||||
@@ -35,8 +35,7 @@ pub(super) fn project(
|
||||
));
|
||||
}
|
||||
|
||||
let mut identities = BTreeSet::new();
|
||||
let mut contexts = Vec::with_capacity(action.completions.len());
|
||||
let mut completions = BTreeMap::new();
|
||||
let mut has_shell = false;
|
||||
let mut has_subagent = false;
|
||||
for completion in &action.completions {
|
||||
@@ -88,17 +87,30 @@ pub(super) fn project(
|
||||
}
|
||||
pb::BackgroundTaskKind::Unspecified => unreachable!(),
|
||||
};
|
||||
let identity = agent_id.unwrap_or(&completion.task_id);
|
||||
let identity = format!("{}:{identity}", kind.as_str_name());
|
||||
if !identities.insert(identity.clone()) {
|
||||
let tool_call_id = completion
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol("background task completion has no tool_call_id".into())
|
||||
})?;
|
||||
let task_identity = agent_id.unwrap_or(&completion.task_id);
|
||||
let identity = format!("{}:{task_identity}:{tool_call_id}", kind.as_str_name());
|
||||
let context = completion_context(completion, kind, agent_id)?;
|
||||
if completions
|
||||
.insert(identity.clone(), (completion, context))
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::Protocol(format!(
|
||||
"duplicate background task completion: {identity}"
|
||||
)));
|
||||
}
|
||||
contexts.push(completion_context(completion, kind, agent_id)?);
|
||||
}
|
||||
|
||||
let first = &action.completions[0];
|
||||
let (first, _) = completions
|
||||
.values()
|
||||
.next()
|
||||
.expect("background completion action was validated as non-empty");
|
||||
let text = match (has_shell, has_subagent) {
|
||||
(true, false) => SHELL_FOLLOW_UP.into(),
|
||||
(false, true) => FOLLOW_UP.into(),
|
||||
@@ -106,12 +118,16 @@ pub(super) fn project(
|
||||
(false, false) => unreachable!(),
|
||||
};
|
||||
Ok(Projection {
|
||||
context: contexts.join("\n\n"),
|
||||
context: completions
|
||||
.values()
|
||||
.map(|(_, context)| context.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
turn_user: pb::UserMessage {
|
||||
text,
|
||||
message_id: format!(
|
||||
"background-completed:{}",
|
||||
identities.into_iter().collect::<Vec<_>>().join(":")
|
||||
completions.keys().cloned().collect::<Vec<_>>().join(":")
|
||||
),
|
||||
mode,
|
||||
is_simulated_msg: Some(true),
|
||||
@@ -272,6 +288,56 @@ mod tests {
|
||||
assert!(projection.turn_user.text.contains(FOLLOW_UP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_batch_projection_is_independent_of_input_order() {
|
||||
let first = completion();
|
||||
let mut second = completion();
|
||||
second.task_id = "child-id-2".into();
|
||||
second.subagent_id = Some("child-id-2".into());
|
||||
second.tool_call_id = Some("task-call-2".into());
|
||||
let forward = project(
|
||||
&pb::BackgroundTaskCompletionAction {
|
||||
completions: vec![first.clone(), second.clone()],
|
||||
},
|
||||
pb::AgentMode::Multitask as i32,
|
||||
)
|
||||
.unwrap();
|
||||
let reversed = project(
|
||||
&pb::BackgroundTaskCompletionAction {
|
||||
completions: vec![second, first],
|
||||
},
|
||||
pb::AgentMode::Multitask as i32,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(forward.turn_user, reversed.turn_user);
|
||||
assert_eq!(forward.context, reversed.context);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_subagent_completions_use_the_task_call_as_part_of_their_identity() {
|
||||
let first = project(
|
||||
&pb::BackgroundTaskCompletionAction {
|
||||
completions: vec![completion()],
|
||||
},
|
||||
pb::AgentMode::Multitask as i32,
|
||||
)
|
||||
.unwrap();
|
||||
let mut resumed = completion();
|
||||
resumed.tool_call_id = Some("task-call-2".into());
|
||||
let second = project(
|
||||
&pb::BackgroundTaskCompletionAction {
|
||||
completions: vec![resumed],
|
||||
},
|
||||
pb::AgentMode::Multitask as i32,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(first.turn_user.message_id, second.turn_user.message_id);
|
||||
assert!(first.turn_user.message_id.ends_with(":task-call"));
|
||||
assert!(second.turn_user.message_id.ends_with(":task-call-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_requires_the_captured_subagent_identity_and_terminal_reason() {
|
||||
let mut value = completion();
|
||||
|
||||
@@ -478,9 +478,9 @@ pub fn dynamic_mcp(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Value> {
|
||||
fn normalize_mcp_parameters(tool_name: &str, parameters: Value) -> Result<Value> {
|
||||
let schema = parameters
|
||||
.as_object_mut()
|
||||
.as_object()
|
||||
.ok_or_else(|| invalid_mcp_parameters(tool_name))?;
|
||||
match schema.get("type") {
|
||||
Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters),
|
||||
@@ -505,7 +505,6 @@ fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Va
|
||||
if !object_only_union {
|
||||
return Err(invalid_mcp_parameters(tool_name));
|
||||
}
|
||||
schema.insert("type".into(), Value::String("object".into()));
|
||||
Ok(parameters)
|
||||
}
|
||||
|
||||
@@ -614,7 +613,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_normalizes_cursor_object_union_without_mutating_wire_schema() {
|
||||
fn dynamic_mcp_preserves_cursor_object_union_schema() {
|
||||
let original_schema = serde_json::json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"anyOf": [
|
||||
@@ -655,8 +654,7 @@ mod tests {
|
||||
.get("cursor-app-control-move_agent_to_cloned_root")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(definition.parameters["type"], "object");
|
||||
assert_eq!(definition.parameters["anyOf"], original_schema["anyOf"]);
|
||||
assert_eq!(definition.parameters, original_schema);
|
||||
assert_eq!(
|
||||
wire.input_schema_json.as_deref(),
|
||||
Some(original_json.as_str())
|
||||
|
||||
@@ -41,6 +41,7 @@ pub struct CursorRunContext {
|
||||
pub dynamic_tools: BTreeMap<String, pb::McpToolDefinition>,
|
||||
pub checkpoint_prompt: PromptSpec,
|
||||
pub compacting: bool,
|
||||
pub background_completion: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct PrepareDependencies<'a> {
|
||||
@@ -123,7 +124,7 @@ pub(crate) async fn prepare(
|
||||
mode: mode_number,
|
||||
mut turn_user,
|
||||
action_context,
|
||||
event_id,
|
||||
mut event_id,
|
||||
input_id,
|
||||
starts_turn,
|
||||
compacting,
|
||||
@@ -172,16 +173,45 @@ pub(crate) async fn prepare(
|
||||
}
|
||||
Some(_) | None => store.ensure_conversation(&conversation_id).await?,
|
||||
};
|
||||
let base_revision_id = match input_id {
|
||||
let base_revision_id = match input_id.as_deref() {
|
||||
Some(input_id) => {
|
||||
store
|
||||
.anchor_input(&conversation_id, &input_id, proposed_base_revision_id)
|
||||
.anchor_input(&conversation_id, input_id, proposed_base_revision_id)
|
||||
.await?
|
||||
}
|
||||
None => proposed_base_revision_id,
|
||||
};
|
||||
let mut projected_user_context = if input_id.is_some() && !compacting && !background_completion
|
||||
{
|
||||
runtime::compile_request_context(
|
||||
"identity",
|
||||
&request_context,
|
||||
base_messages.as_deref().unwrap_or_default(),
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if event_id.is_none() {
|
||||
if let (Some(input_id), Some(user)) = (input_id.as_deref(), turn_user.as_ref()) {
|
||||
event_id = Some(
|
||||
runtime::user_event_id(
|
||||
input_id,
|
||||
checkpoint_mode,
|
||||
user,
|
||||
&request_context,
|
||||
&action_context,
|
||||
projected_user_context
|
||||
.as_ref()
|
||||
.map(|message| &message.content),
|
||||
compiler,
|
||||
blob_sync,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
}
|
||||
let existing_runtime = match event_id.as_deref() {
|
||||
Some(event_id) if !background_completion => {
|
||||
Some(event_id) => {
|
||||
store
|
||||
.message(&conversation_id, &format!("runtime:{event_id}"))
|
||||
.await?
|
||||
@@ -193,6 +223,10 @@ pub(crate) async fn prepare(
|
||||
let message_id = format!("request-context:{event_id}");
|
||||
match store.message(&conversation_id, &message_id).await? {
|
||||
Some(message) => Some(message),
|
||||
None if input_id.is_some() => projected_user_context.take().map(|mut message| {
|
||||
message.message_id = message_id;
|
||||
message
|
||||
}),
|
||||
None => runtime::compile_request_context(
|
||||
event_id,
|
||||
&request_context,
|
||||
@@ -202,19 +236,27 @@ pub(crate) async fn prepare(
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let initial_messages = if compacting {
|
||||
let mut initial_messages = if compacting {
|
||||
Vec::new()
|
||||
} else {
|
||||
match (turn_user.clone(), event_id) {
|
||||
(Some(mut user), Some(event_id)) if background_completion => {
|
||||
let (message, text) = runtime::compile_background(
|
||||
event_id,
|
||||
&user,
|
||||
&request_context,
|
||||
&action_context,
|
||||
blob_sync,
|
||||
)
|
||||
.await?;
|
||||
let (message, text) = match existing_runtime {
|
||||
Some(message) => {
|
||||
let text = runtime_message_text(&message)?;
|
||||
(message, text)
|
||||
}
|
||||
None => {
|
||||
runtime::compile_background(
|
||||
event_id,
|
||||
&user,
|
||||
&request_context,
|
||||
&action_context,
|
||||
blob_sync,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
user.text = text;
|
||||
turn_user = Some(user);
|
||||
vec![message]
|
||||
@@ -248,6 +290,10 @@ pub(crate) async fn prepare(
|
||||
}
|
||||
}
|
||||
};
|
||||
let (base_revision_id, reused) = store
|
||||
.match_revision_prefix(&conversation_id, base_revision_id, &initial_messages)
|
||||
.await?;
|
||||
initial_messages.drain(..reused);
|
||||
let action = if compacting {
|
||||
RunAction::Compact
|
||||
} else if starts_turn {
|
||||
@@ -302,10 +348,25 @@ pub(crate) async fn prepare(
|
||||
.collect(),
|
||||
checkpoint_prompt,
|
||||
compacting,
|
||||
background_completion,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_message_text(message: &CanonicalMessage) -> Result<String> {
|
||||
let MessageContent::Parts { parts } = &message.content else {
|
||||
return Err(Error::Protocol(
|
||||
"stored runtime message does not contain parts".into(),
|
||||
));
|
||||
};
|
||||
let Some(ContentPart::Text { text }) = parts.first() else {
|
||||
return Err(Error::Protocol(
|
||||
"stored runtime message does not start with text".into(),
|
||||
));
|
||||
};
|
||||
Ok(text.clone())
|
||||
}
|
||||
|
||||
fn run_kind(subagent_type_name: Option<&str>, parent: Option<(RunId, String)>) -> Result<RunKind> {
|
||||
match (subagent_type_name, parent) {
|
||||
(None | Some("side-chat"), _) => Ok(RunKind::Root),
|
||||
@@ -412,13 +473,13 @@ fn action(request: &pb::AgentRunRequest) -> Result<ActionProjection> {
|
||||
.filter(|text| !text.is_empty())
|
||||
.cloned(),
|
||||
);
|
||||
let event_id = format!("cursor:user:{}", user.message_id);
|
||||
let input_id = format!("cursor:user:{}", user.message_id);
|
||||
Ok(ActionProjection {
|
||||
mode,
|
||||
turn_user: Some(user.clone()),
|
||||
action_context: context.join("\n\n"),
|
||||
event_id: Some(event_id.clone()),
|
||||
input_id: Some(event_id),
|
||||
event_id: None,
|
||||
input_id: Some(input_id),
|
||||
starts_turn: true,
|
||||
compacting: false,
|
||||
background_completion: false,
|
||||
@@ -681,7 +742,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_messages_reusing_a_request_id_keep_distinct_runtime_identities() {
|
||||
fn queued_messages_keep_distinct_input_anchors_until_runtime_identity_is_compiled() {
|
||||
let request = |message_id: &str| pb::AgentRunRequest {
|
||||
action: Some(pb::ConversationAction {
|
||||
action: Some(pb::conversation_action::Action::UserMessageAction(
|
||||
@@ -703,9 +764,11 @@ mod tests {
|
||||
let first = action(&request("message-one")).unwrap();
|
||||
let second = action(&request("message-two")).unwrap();
|
||||
|
||||
assert_eq!(first.event_id.as_deref(), Some("cursor:user:message-one"));
|
||||
assert_eq!(second.event_id.as_deref(), Some("cursor:user:message-two"));
|
||||
assert_ne!(first.event_id, second.event_id);
|
||||
assert_eq!(first.event_id, None);
|
||||
assert_eq!(second.event_id, None);
|
||||
assert_eq!(first.input_id.as_deref(), Some("cursor:user:message-one"));
|
||||
assert_eq!(second.input_id.as_deref(), Some("cursor:user:message-two"));
|
||||
assert_ne!(first.input_id, second.input_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
proto::agent::v1 as pb,
|
||||
},
|
||||
model::{CanonicalMessage, MessageContent, Origin, Role},
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
@@ -80,12 +81,66 @@ pub async fn compile(
|
||||
compiler: &PromptCompiler,
|
||||
blobs: &BlobSynchronizer,
|
||||
) -> Result<CanonicalMessage> {
|
||||
let time = Time::now(
|
||||
let timestamp = Time::now(
|
||||
request_context
|
||||
.env
|
||||
.as_ref()
|
||||
.map(|env| env.time_zone.as_str()),
|
||||
)?;
|
||||
)?
|
||||
.timestamp;
|
||||
compile_with_timestamp(
|
||||
event_id,
|
||||
mode,
|
||||
user,
|
||||
request_context,
|
||||
action_context,
|
||||
timestamp,
|
||||
compiler,
|
||||
blobs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn user_event_id(
|
||||
input_id: &str,
|
||||
mode: Mode,
|
||||
user: &pb::UserMessage,
|
||||
request_context: &pb::RequestContext,
|
||||
action_context: &str,
|
||||
projected_request_context: Option<&MessageContent>,
|
||||
compiler: &PromptCompiler,
|
||||
blobs: &BlobSynchronizer,
|
||||
) -> Result<String> {
|
||||
let runtime = compile_with_timestamp(
|
||||
"identity".into(),
|
||||
mode,
|
||||
user,
|
||||
request_context,
|
||||
action_context,
|
||||
String::new(),
|
||||
compiler,
|
||||
blobs,
|
||||
)
|
||||
.await?;
|
||||
let semantic = serde_json::to_vec(&(projected_request_context, runtime.content))?;
|
||||
Ok(format!(
|
||||
"{input_id}:{}",
|
||||
BlobId::digest(&semantic).to_base64()
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn compile_with_timestamp(
|
||||
event_id: String,
|
||||
mode: Mode,
|
||||
user: &pb::UserMessage,
|
||||
request_context: &pb::RequestContext,
|
||||
action_context: &str,
|
||||
timestamp: String,
|
||||
compiler: &PromptCompiler,
|
||||
blobs: &BlobSynchronizer,
|
||||
) -> Result<CanonicalMessage> {
|
||||
let mut values = BTreeMap::from([
|
||||
("OPEN_FILES", section(open_files(user))),
|
||||
(
|
||||
@@ -98,7 +153,7 @@ pub async fn compile(
|
||||
),
|
||||
),
|
||||
("ACTION_CONTEXT", section(action_context.to_string())),
|
||||
("TIMESTAMP", time.timestamp),
|
||||
("TIMESTAMP", timestamp),
|
||||
("USER_QUERY", user.text.clone()),
|
||||
("DEBUG_SERVER_ENDPOINT", String::new()),
|
||||
("DEBUG_LOG_PATH", String::new()),
|
||||
|
||||
@@ -9,7 +9,11 @@ use tokio_stream::StreamExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
cursor::{connect::END_STREAM_FLAG, observability::CursorTraceRecorder, CursorSessionRegistry},
|
||||
cursor::{
|
||||
connect::{self, END_STREAM_FLAG},
|
||||
observability::CursorTraceRecorder,
|
||||
CursorSessionRegistry,
|
||||
},
|
||||
Result,
|
||||
};
|
||||
|
||||
@@ -49,7 +53,7 @@ fn local_body_stream(
|
||||
trace.chunk(&chunk);
|
||||
if terminal {
|
||||
guard.complete();
|
||||
trace.finish(None);
|
||||
trace.finish(end_stream_error(&chunk));
|
||||
}
|
||||
yield Ok::<Bytes, Infallible>(chunk);
|
||||
if terminal {
|
||||
@@ -67,6 +71,30 @@ fn is_end_stream_frame(frame: &Bytes) -> bool {
|
||||
.is_some_and(|flags| flags & END_STREAM_FLAG != 0)
|
||||
}
|
||||
|
||||
fn end_stream_error(frame: &Bytes) -> Option<String> {
|
||||
connect::decode_frames(frame)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find_map(|(flags, payload)| {
|
||||
if flags & END_STREAM_FLAG == 0 {
|
||||
return None;
|
||||
}
|
||||
let value = serde_json::from_slice::<serde_json::Value>(&payload).ok()?;
|
||||
let error = value.get("error")?;
|
||||
let code = error.get("code").and_then(serde_json::Value::as_str);
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|message| !message.is_empty());
|
||||
Some(match (code, message) {
|
||||
(Some(code), Some(message)) => format!("{code}: {message}"),
|
||||
(Some(code), None) => code.to_string(),
|
||||
(None, Some(message)) => message.to_string(),
|
||||
(None, None) => error.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
struct LocalRunGuard {
|
||||
cancellation: CancellationToken,
|
||||
completed: bool,
|
||||
@@ -233,4 +261,67 @@ mod tests {
|
||||
drop(stream);
|
||||
assert!(!cancellation.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_error_end_stream_exposes_the_trace_error() {
|
||||
let frame = connect::encode_error_end_stream(&connect::ConnectStreamError {
|
||||
code: connect::ConnectCode::InvalidArgument,
|
||||
message: "unsupported runtime action".into(),
|
||||
details: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
end_stream_error(&frame).as_deref(),
|
||||
Some("invalid_argument: unsupported runtime action")
|
||||
);
|
||||
assert_eq!(end_stream_error(&connect::encode_end_stream()), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_error_end_stream_marks_the_local_trace_as_error() {
|
||||
let store = crate::store::Store::connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
store.set_detailed_logging(true).await.unwrap();
|
||||
let trace = CursorTraceRecorder::begin(
|
||||
store.clone(),
|
||||
"error-trace",
|
||||
Some("conversation"),
|
||||
"local_byok",
|
||||
Some("model"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
let cancellation = CancellationToken::new();
|
||||
sender
|
||||
.send(
|
||||
connect::encode_error_end_stream(&connect::ConnectStreamError {
|
||||
code: connect::ConnectCode::InvalidArgument,
|
||||
message: "unsupported runtime action".into(),
|
||||
details: Vec::new(),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut stream = Box::pin(local_body_stream(receiver, cancellation, Some(trace)));
|
||||
|
||||
stream.next().await.unwrap().unwrap();
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
let trace = loop {
|
||||
let trace = store.cursor_trace("error-trace").await.unwrap().unwrap();
|
||||
if trace.status != "running" {
|
||||
break trace;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
};
|
||||
assert_eq!(trace.status, "error");
|
||||
assert_eq!(
|
||||
trace.error_message.as_deref(),
|
||||
Some("invalid_argument: unsupported runtime action")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,23 @@ impl CursorSession {
|
||||
}
|
||||
|
||||
pub async fn run(mut self) -> Result<()> {
|
||||
let result = self.run_inner().await;
|
||||
if let Err(error) = &result {
|
||||
self.abort_execs().await;
|
||||
let error = match error {
|
||||
Error::Protocol(message) => message.clone(),
|
||||
error => error.to_string(),
|
||||
};
|
||||
let _ = self
|
||||
.core
|
||||
.commands
|
||||
.send(ClientCommand::ClientClosed { error })
|
||||
.await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_inner(&mut self) -> Result<()> {
|
||||
if self.context.compacting {
|
||||
self.handle.emit(&interaction::summary_started())?;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@ pub use request::{abort, mcp_request, mcp_state_request, request};
|
||||
pub(crate) use request::{
|
||||
await_read_request, edit_read_request, json_object_to_prost, mcp_meta_request,
|
||||
};
|
||||
pub use response::{client_event, ClientExecEvent};
|
||||
pub use response::{client_event, stream_closed, ClientExecEvent};
|
||||
|
||||
@@ -130,6 +130,53 @@ pub async fn client_event(
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result<Option<ToolCompletion>> {
|
||||
let Some(entry) = pending.take_exec(id).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let error = "Cursor Exec stream closed before returning a terminal result";
|
||||
if entry.call.name.eq_ignore_ascii_case("Shell") {
|
||||
let command = entry
|
||||
.call
|
||||
.arguments
|
||||
.get("command")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let working_directory = entry
|
||||
.call
|
||||
.arguments
|
||||
.get("working_directory")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
return Ok(Some(result::from_exec(
|
||||
entry,
|
||||
&pb::exec_client_message::Message::ShellResult(pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::SpawnError(pb::ShellSpawnError {
|
||||
command,
|
||||
working_directory,
|
||||
error: error.into(),
|
||||
})),
|
||||
..Default::default()
|
||||
}),
|
||||
)?));
|
||||
}
|
||||
let rendered = match &entry.stage {
|
||||
ExecStage::DynamicMcp(definition) => {
|
||||
interaction::render_dynamic_mcp(&entry.call, definition, false)
|
||||
}
|
||||
_ => interaction::render_tool_call(&entry.call, false)?,
|
||||
};
|
||||
Ok(Some(ToolCompletion::from_rendered(
|
||||
&entry.call,
|
||||
entry.started_at_ms,
|
||||
error.into(),
|
||||
true,
|
||||
rendered,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn advance_await(
|
||||
entry: PendingExec,
|
||||
result: &pb::exec_client_message::Message,
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::collections::BTreeMap;
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
model::ToolCall,
|
||||
store::Store,
|
||||
web::{WebFetch, WebSearch},
|
||||
Error, Result,
|
||||
};
|
||||
@@ -36,6 +37,7 @@ pub(super) async fn start(
|
||||
message_index: usize,
|
||||
dynamic_mcp: &BTreeMap<String, pb::McpToolDefinition>,
|
||||
context: &ExecContext,
|
||||
store: Option<&Store>,
|
||||
) -> Result<ToolStart> {
|
||||
if let Some(definition) = dynamic_mcp.get(&call.name) {
|
||||
return exec::start_dynamic(runtime, call, definition, context).await;
|
||||
@@ -57,7 +59,7 @@ pub(super) async fn start(
|
||||
| "generateimage" => interaction::start(runtime, call).await,
|
||||
"todowrite" | "updatecurrentstep" => local::start(call, message_index),
|
||||
"awaitshell" => await_shell::start(runtime, results, call, context).await,
|
||||
"semblesearch" | "semblefindrelated" => semble::start(results, call),
|
||||
"semblesearch" | "semblefindrelated" => semble::start(results, call, store.cloned()),
|
||||
_ => Err(Error::Protocol(format!("unsupported tool: {}", call.name))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::{model::ToolCall, Error, Result};
|
||||
use crate::{model::ToolCall, store::Store, Error, Result};
|
||||
|
||||
use super::ToolStart;
|
||||
use crate::cursor::tools::{
|
||||
@@ -52,7 +52,11 @@ struct FindRelatedArguments {
|
||||
content: ContentSelection,
|
||||
}
|
||||
|
||||
pub(super) fn start(results: &ToolResultSender, call: &ToolCall) -> Result<ToolStart> {
|
||||
pub(super) fn start(
|
||||
results: &ToolResultSender,
|
||||
call: &ToolCall,
|
||||
store: Option<Store>,
|
||||
) -> Result<ToolStart> {
|
||||
let operation = match super::normalized(&call.name).as_str() {
|
||||
"semblesearch" => Operation::Search(serde_json::from_value(call.arguments.clone())?),
|
||||
"semblefindrelated" => {
|
||||
@@ -69,7 +73,7 @@ pub(super) fn start(results: &ToolResultSender, call: &ToolCall) -> Result<ToolS
|
||||
let results = results.clone();
|
||||
let started_at_ms = now_ms();
|
||||
tokio::spawn(async move {
|
||||
let output = execute(operation).await;
|
||||
let output = execute(operation, store).await;
|
||||
match result::semble(&call, started_at_ms, output) {
|
||||
Ok(completion) => results.send(completion),
|
||||
Err(error) => results.send_error(error),
|
||||
@@ -86,8 +90,8 @@ enum Operation {
|
||||
FindRelated(FindRelatedArguments),
|
||||
}
|
||||
|
||||
async fn execute(operation: Operation) -> std::result::Result<Value, String> {
|
||||
let engine = engine().await.map_err(|error| error.to_string())?;
|
||||
async fn execute(operation: Operation, store: Option<Store>) -> std::result::Result<Value, String> {
|
||||
let engine = engine(store).await.map_err(|error| error.to_string())?;
|
||||
tokio::task::spawn_blocking(move || match operation {
|
||||
Operation::Search(arguments) => engine
|
||||
.search(SearchRequest {
|
||||
@@ -114,14 +118,21 @@ async fn execute(operation: Operation) -> std::result::Result<Value, String> {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn engine() -> Result<Arc<SearchEngine>> {
|
||||
async fn engine(store: Option<Store>) -> Result<Arc<SearchEngine>> {
|
||||
ENGINE
|
||||
.get_or_try_init(|| async {
|
||||
tokio::task::spawn_blocking(|| SearchEngine::load_default(SembleConfig::default()))
|
||||
.await
|
||||
.map_err(|error| Error::Config(format!("load Semble search engine: {error}")))?
|
||||
.map(Arc::new)
|
||||
.map_err(|error| Error::Config(format!("load Semble search engine: {error}")))
|
||||
.get_or_try_init(|| async move {
|
||||
let builder = match store {
|
||||
Some(store) => crate::network::blocking_client_builder(&store).await?,
|
||||
None => reqwest::blocking::Client::builder().use_native_tls(),
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let client = builder.build()?;
|
||||
SearchEngine::load_default_with_client(SembleConfig::default(), &client)
|
||||
.map(Arc::new)
|
||||
.map_err(|error| Error::Config(format!("load Semble search engine: {error}")))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| Error::Config(format!("load Semble search engine: {error}")))?
|
||||
})
|
||||
.await
|
||||
.cloned()
|
||||
|
||||
@@ -17,6 +17,7 @@ mod tests;
|
||||
|
||||
use crate::{
|
||||
model::{CanonicalMessage, MessageContent, Role, ToolCall},
|
||||
store::Store,
|
||||
web::{WebFetch, WebSearch},
|
||||
Error, Result,
|
||||
};
|
||||
@@ -32,6 +33,7 @@ pub struct ToolDispatcher {
|
||||
results: ToolResultSender,
|
||||
search: WebSearch,
|
||||
fetch: WebFetch,
|
||||
store: Option<Store>,
|
||||
edit_schedule: Arc<Mutex<EditSchedule>>,
|
||||
}
|
||||
|
||||
@@ -55,15 +57,27 @@ pub enum ClientToolEvent {
|
||||
impl ToolDispatcher {
|
||||
pub fn new(runtime: CursorToolRuntime) -> Self {
|
||||
let (results, _) = result::tool_result_channel();
|
||||
Self::with_results(runtime, results)
|
||||
}
|
||||
|
||||
pub fn with_results(runtime: CursorToolRuntime, results: ToolResultSender) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
results,
|
||||
search: WebSearch::built_in(),
|
||||
fetch: WebFetch::built_in(),
|
||||
store: None,
|
||||
edit_schedule: Arc::new(Mutex::new(EditSchedule::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_results(
|
||||
runtime: CursorToolRuntime,
|
||||
results: ToolResultSender,
|
||||
store: Store,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
results,
|
||||
search: WebSearch::managed(store.clone()),
|
||||
fetch: WebFetch::managed(store.clone()),
|
||||
store: Some(store),
|
||||
edit_schedule: Arc::new(Mutex::new(EditSchedule::default())),
|
||||
}
|
||||
}
|
||||
@@ -165,6 +179,7 @@ impl ToolDispatcher {
|
||||
message_index,
|
||||
dynamic_mcp,
|
||||
context,
|
||||
self.store.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
messages.extend(started.messages);
|
||||
|
||||
@@ -338,6 +338,18 @@ impl CursorToolRuntime {
|
||||
ids
|
||||
}
|
||||
|
||||
pub async fn running_task_exec_id(&self, call_id: &str) -> Option<u32> {
|
||||
self.execs
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter_map(|(id, entry)| {
|
||||
(entry.call.call_id == call_id && entry.call.name.eq_ignore_ascii_case("Task"))
|
||||
.then_some(*id)
|
||||
})
|
||||
.min()
|
||||
}
|
||||
|
||||
fn next_id(&self) -> Result<u32> {
|
||||
self.next_id
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
|
||||
+113
-1
@@ -4,7 +4,9 @@ use crate::{store::Store, Result};
|
||||
|
||||
pub async fn client_builder(store: &Store) -> Result<reqwest::ClientBuilder> {
|
||||
let settings = store.proxy_settings_secret().await?;
|
||||
let mut builder = reqwest::Client::builder();
|
||||
// Use the platform TLS stack for compatibility with provider gateways that
|
||||
// only offer legacy TLS 1.2 cipher suites unsupported by rustls.
|
||||
let mut builder = reqwest::Client::builder().use_native_tls();
|
||||
if settings.mode.is_custom() {
|
||||
let mut proxy = reqwest::Proxy::all(&settings.address)?;
|
||||
if settings.auth_enabled {
|
||||
@@ -18,3 +20,113 @@ pub async fn client_builder(store: &Store) -> Result<reqwest::ClientBuilder> {
|
||||
pub async fn client(store: &Store) -> Result<reqwest::Client> {
|
||||
Ok(client_builder(store).await?.build()?)
|
||||
}
|
||||
|
||||
pub async fn blocking_client_builder(store: &Store) -> Result<reqwest::blocking::ClientBuilder> {
|
||||
let settings = store.proxy_settings_secret().await?;
|
||||
let mut builder = reqwest::blocking::Client::builder().use_native_tls();
|
||||
if settings.mode.is_custom() {
|
||||
let mut proxy = reqwest::Proxy::all(&settings.address)?;
|
||||
if settings.auth_enabled {
|
||||
proxy = proxy.basic_auth(&settings.username, &settings.password);
|
||||
}
|
||||
builder = builder.no_proxy().proxy(proxy);
|
||||
}
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::TcpListener,
|
||||
sync::mpsc,
|
||||
thread,
|
||||
};
|
||||
|
||||
use crate::store::{ProxyMode, ProxySettingsInput};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_proxy_applies_to_async_and_blocking_clients() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let database_url = format!("sqlite://{}", directory.path().join("test.db").display());
|
||||
let store = Store::connect(&database_url).await.unwrap();
|
||||
let (proxy_address, requests, proxy) = proxy_server(2);
|
||||
store
|
||||
.set_proxy_settings(ProxySettingsInput {
|
||||
mode: ProxyMode::Custom,
|
||||
address: proxy_address,
|
||||
auth_enabled: true,
|
||||
username: "proxy-user".into(),
|
||||
password: Some("proxy-password".into()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
client(&store)
|
||||
.await
|
||||
.unwrap()
|
||||
.get("http://provider.invalid/async")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
let blocking = blocking_client_builder(&store).await.unwrap();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
blocking
|
||||
.build()
|
||||
.unwrap()
|
||||
.get("http://provider.invalid/blocking")
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requests = [requests.recv().unwrap(), requests.recv().unwrap()];
|
||||
assert!(requests
|
||||
.iter()
|
||||
.any(|request| request.starts_with("GET http://provider.invalid/async ")));
|
||||
assert!(requests
|
||||
.iter()
|
||||
.any(|request| request.starts_with("GET http://provider.invalid/blocking ")));
|
||||
assert!(requests.iter().all(|request| request
|
||||
.to_ascii_lowercase()
|
||||
.contains("\r\nproxy-authorization: basic ")));
|
||||
proxy.join().unwrap();
|
||||
}
|
||||
|
||||
fn proxy_server(
|
||||
expected_requests: usize,
|
||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let server = thread::spawn(move || {
|
||||
for stream in listener.incoming().take(expected_requests) {
|
||||
let mut stream = stream.unwrap();
|
||||
let mut request = String::new();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
request.push_str(&line);
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sender.send(request).unwrap();
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
(address, receiver, server)
|
||||
}
|
||||
}
|
||||
|
||||
+213
-15
@@ -6,9 +6,11 @@ use std::{
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
model::{NewLlmCall, Usage},
|
||||
store::Store,
|
||||
store::{BufferedLlmChunk, Store},
|
||||
Result,
|
||||
};
|
||||
|
||||
@@ -44,9 +46,23 @@ struct Inner {
|
||||
started: Instant,
|
||||
detailed: bool,
|
||||
next_chunk: AtomicI64,
|
||||
chunks: Mutex<ChunkBuffer>,
|
||||
first_text_recorded: AtomicBool,
|
||||
finished: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ChunkBuffer {
|
||||
chunks: Vec<BufferedLlmChunk>,
|
||||
bytes: usize,
|
||||
first_chunk_at: Option<Instant>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
const MAX_BUFFERED_CHUNKS: usize = 32;
|
||||
const MAX_BUFFERED_BYTES: usize = 256 * 1024;
|
||||
const MAX_BUFFER_AGE: std::time::Duration = std::time::Duration::from_millis(50);
|
||||
|
||||
impl CallRecorder {
|
||||
pub async fn start(store: Store, mut call: NewLlmCall) -> Result<Self> {
|
||||
call.detailed = store.detailed_logging().await?;
|
||||
@@ -58,6 +74,8 @@ impl CallRecorder {
|
||||
started: Instant::now(),
|
||||
detailed: call.detailed,
|
||||
next_chunk: AtomicI64::new(0),
|
||||
chunks: Mutex::new(ChunkBuffer::default()),
|
||||
first_text_recorded: AtomicBool::new(false),
|
||||
finished: AtomicBool::new(false),
|
||||
}),
|
||||
})
|
||||
@@ -91,26 +109,67 @@ impl CallRecorder {
|
||||
}
|
||||
|
||||
pub async fn response_chunk(&self, data: &[u8]) -> Result<()> {
|
||||
let mut buffer = self.inner.chunks.lock().await;
|
||||
if self.is_finished() {
|
||||
return Ok(());
|
||||
}
|
||||
let seq = self.inner.next_chunk.fetch_add(1, Ordering::Relaxed);
|
||||
self.inner
|
||||
.store
|
||||
.record_llm_chunk(
|
||||
&self.inner.call_id,
|
||||
seq,
|
||||
self.elapsed_ms(),
|
||||
data,
|
||||
self.inner.detailed,
|
||||
)
|
||||
.await
|
||||
let schedule_flush = if buffer.chunks.is_empty() {
|
||||
buffer.generation = buffer.generation.wrapping_add(1);
|
||||
buffer.first_chunk_at = Some(Instant::now());
|
||||
Some(buffer.generation)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
buffer.bytes += data.len();
|
||||
buffer.chunks.push(if self.inner.detailed {
|
||||
BufferedLlmChunk::new(seq, self.elapsed_ms(), data)
|
||||
} else {
|
||||
BufferedLlmChunk::metrics(seq, self.elapsed_ms(), data.len())
|
||||
});
|
||||
let expired = buffer
|
||||
.first_chunk_at
|
||||
.is_some_and(|started| started.elapsed() >= MAX_BUFFER_AGE);
|
||||
if buffer.chunks.len() >= MAX_BUFFERED_CHUNKS
|
||||
|| buffer.bytes >= MAX_BUFFERED_BYTES
|
||||
|| expired
|
||||
{
|
||||
self.flush_locked(&mut buffer).await?;
|
||||
}
|
||||
drop(buffer);
|
||||
if let Some(generation) = schedule_flush {
|
||||
let recorder = self.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(MAX_BUFFER_AGE).await;
|
||||
if let Err(error) = recorder.flush_generation(generation).await {
|
||||
tracing::warn!(call_id = recorder.inner.call_id, %error, "failed to flush LLM response chunks");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn event(&self, event: &ModelEvent) -> Result<()> {
|
||||
match event {
|
||||
ModelEvent::TextDelta(_) => {
|
||||
self.inner
|
||||
.store
|
||||
.record_llm_first_text(&self.inner.call_id, self.elapsed_ms())
|
||||
.await?;
|
||||
if self
|
||||
.inner
|
||||
.first_text_recorded
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
if let Err(error) = self
|
||||
.inner
|
||||
.store
|
||||
.record_llm_first_text(&self.inner.call_id, self.elapsed_ms())
|
||||
.await
|
||||
{
|
||||
self.inner
|
||||
.first_text_recorded
|
||||
.store(false, Ordering::Release);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
ModelEvent::Usage(usage) => self.usage(*usage).await?,
|
||||
ModelEvent::Done(reason) => self.completed(*reason).await?,
|
||||
@@ -155,6 +214,10 @@ impl CallRecorder {
|
||||
if self.inner.finished.swap(true, Ordering::AcqRel) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(error) = self.flush_chunks().await {
|
||||
self.inner.finished.store(false, Ordering::Release);
|
||||
return Err(error);
|
||||
}
|
||||
self.inner
|
||||
.store
|
||||
.finish_llm_call(
|
||||
@@ -168,6 +231,40 @@ impl CallRecorder {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn flush_chunks(&self) -> Result<()> {
|
||||
let mut buffer = self.inner.chunks.lock().await;
|
||||
self.flush_locked(&mut buffer).await
|
||||
}
|
||||
|
||||
async fn flush_generation(&self, generation: u64) -> Result<()> {
|
||||
let mut buffer = self.inner.chunks.lock().await;
|
||||
if buffer.generation != generation {
|
||||
return Ok(());
|
||||
}
|
||||
self.flush_locked(&mut buffer).await
|
||||
}
|
||||
|
||||
async fn flush_locked(&self, buffer: &mut ChunkBuffer) -> Result<()> {
|
||||
if buffer.chunks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let chunks = std::mem::take(&mut buffer.chunks);
|
||||
buffer.bytes = 0;
|
||||
buffer.first_chunk_at = None;
|
||||
if let Err(error) = self
|
||||
.inner
|
||||
.store
|
||||
.record_llm_chunks(&self.inner.call_id, &chunks, self.inner.detailed)
|
||||
.await
|
||||
{
|
||||
buffer.bytes = chunks.iter().map(|chunk| chunk.byte_count).sum();
|
||||
buffer.first_chunk_at = Some(Instant::now());
|
||||
buffer.chunks = chunks;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn elapsed_ms(&self) -> i64 {
|
||||
self.inner
|
||||
.started
|
||||
@@ -193,3 +290,104 @@ fn error_kind(error: &crate::Error) -> &'static str {
|
||||
_ => "internal",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn test_recorder(store: &Store, call_id: &str, detailed: bool) -> CallRecorder {
|
||||
sqlx::query(
|
||||
"INSERT INTO llm_calls(
|
||||
call_id, run_id, conversation_id, provider_call_index, provider_type,
|
||||
provider_url, request_type, request_url, model_id, display_name, status,
|
||||
created_at_ms, message_count, tool_count, detailed
|
||||
) VALUES (?, 'run', 'conversation', 0, 'openai-chat',
|
||||
'https://example.com', 'openai-chat', 'https://example.com',
|
||||
'model', 'Model', 'running', 1, 0, 0, ?)",
|
||||
)
|
||||
.bind(call_id)
|
||||
.bind(detailed)
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
CallRecorder {
|
||||
inner: Arc::new(Inner {
|
||||
store: store.clone(),
|
||||
call_id: call_id.into(),
|
||||
started: Instant::now(),
|
||||
detailed,
|
||||
next_chunk: AtomicI64::new(0),
|
||||
chunks: Mutex::new(ChunkBuffer::default()),
|
||||
first_text_recorded: AtomicBool::new(false),
|
||||
finished: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_partial_chunk_batch_flushes_after_the_deadline() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
let recorder = test_recorder(&store, "timed-flush-call", true).await;
|
||||
|
||||
recorder.response_chunk(b"chunk").await.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.llm_call("timed-flush-call")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.stream_event_count,
|
||||
0
|
||||
);
|
||||
|
||||
tokio::time::sleep(MAX_BUFFER_AGE + std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let call = store.llm_call("timed-flush-call").await.unwrap().unwrap();
|
||||
assert_eq!(call.response_bytes, 5);
|
||||
assert_eq!(call.stream_event_count, 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.llm_call_chunks("timed-flush-call")
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_text_is_persisted_only_once() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
let recorder = test_recorder(&store, "first-text-call", false).await;
|
||||
sqlx::query("CREATE TABLE first_text_updates(count INTEGER NOT NULL)")
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO first_text_updates(count) VALUES (0)")
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TRIGGER count_first_text_updates
|
||||
AFTER UPDATE OF first_text_at_ms ON llm_calls
|
||||
BEGIN
|
||||
UPDATE first_text_updates SET count = count + 1;
|
||||
END",
|
||||
)
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
for text in ["one", "two", "three"] {
|
||||
recorder
|
||||
.event(&ModelEvent::TextDelta(text.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let count: i64 = sqlx::query_scalar("SELECT count FROM first_text_updates")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ use std::sync::Arc;
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{client::ClientPort, model::PreparedRun, provider::Provider, store::Store};
|
||||
use crate::{
|
||||
client::{ClientCommand, ClientPort},
|
||||
model::PreparedRun,
|
||||
provider::Provider,
|
||||
store::Store,
|
||||
};
|
||||
|
||||
use super::{RunEngine, RunOutcome, RunRegistry};
|
||||
|
||||
@@ -26,6 +31,7 @@ impl RunActor {
|
||||
&self,
|
||||
prepared: PreparedRun,
|
||||
client: ClientPort,
|
||||
commands: tokio::sync::mpsc::Sender<ClientCommand>,
|
||||
cancellation: CancellationToken,
|
||||
) -> tokio::task::JoinHandle<RunOutcome> {
|
||||
let run_id = prepared.run_id.clone();
|
||||
@@ -35,6 +41,7 @@ impl RunActor {
|
||||
conversation_id.clone(),
|
||||
run_id.clone(),
|
||||
cancellation.clone(),
|
||||
commands,
|
||||
)
|
||||
.await;
|
||||
let actor = self.clone();
|
||||
|
||||
+81
-13
@@ -4,7 +4,10 @@ use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
client::{ClientEvent, ClientPort, CommitBarrier, CommitCause, StateCommitted},
|
||||
client::{
|
||||
ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, MessageInsertion,
|
||||
StateCommitted,
|
||||
},
|
||||
model::{
|
||||
CanonicalMessage, MessageContent, Origin, PreparedRun, Role, RunAction, ToolRoundAssistant,
|
||||
ToolRoundId, Usage,
|
||||
@@ -158,6 +161,7 @@ impl RunEngine {
|
||||
calls: round.calls.clone(),
|
||||
recovered_started_at_ms: Some(round.started_at_ms),
|
||||
},
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -242,21 +246,27 @@ impl RunEngine {
|
||||
&cycle_cancellation,
|
||||
);
|
||||
tokio::pin!(cycle);
|
||||
let cycle = tokio::select! {
|
||||
result = &mut cycle => result,
|
||||
let mut pending_insertions = Vec::new();
|
||||
let cycle = loop {
|
||||
tokio::select! {
|
||||
result = &mut cycle => break result,
|
||||
command = client.commands.recv() => {
|
||||
let message = match command {
|
||||
Some(crate::client::ClientCommand::RuntimeMessage(message)) => message,
|
||||
Some(crate::client::ClientCommand::RuntimeEvent(event)) => event.into_message(),
|
||||
Some(crate::client::ClientCommand::Cancel) => {
|
||||
Some(ClientCommand::InsertMessages(insertion)) => {
|
||||
pending_insertions.push(insertion);
|
||||
continue;
|
||||
}
|
||||
Some(ClientCommand::RuntimeMessage(message)) => message,
|
||||
Some(ClientCommand::RuntimeEvent(event)) => event.into_message(),
|
||||
Some(ClientCommand::Cancel) => {
|
||||
cycle_cancellation.cancel();
|
||||
return (RunOutcome::Cancelled, usage);
|
||||
}
|
||||
Some(crate::client::ClientCommand::ClientClosed { error }) => {
|
||||
Some(ClientCommand::ClientClosed { error }) => {
|
||||
cycle_cancellation.cancel();
|
||||
return (RunOutcome::Failed(RunFailure::Client(error)), usage);
|
||||
}
|
||||
Some(crate::client::ClientCommand::ToolResult(_)) => {
|
||||
Some(ClientCommand::ToolResult(_)) => {
|
||||
cycle_cancellation.cancel();
|
||||
return (
|
||||
RunOutcome::Failed(RunFailure::Protocol(
|
||||
@@ -284,6 +294,19 @@ impl RunEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
revision = match append_insertions(
|
||||
&self.store,
|
||||
prepared,
|
||||
client,
|
||||
cancellation,
|
||||
revision,
|
||||
std::mem::take(&mut pending_insertions),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((revision, _)) => revision,
|
||||
Err(outcome) => return (outcome, usage),
|
||||
};
|
||||
revision = match append_runtime_message(
|
||||
&self.store,
|
||||
prepared,
|
||||
@@ -294,11 +317,12 @@ impl RunEngine {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(revision) => revision,
|
||||
Ok((revision, _)) => revision,
|
||||
Err(outcome) => return (outcome, usage),
|
||||
};
|
||||
continue 'model;
|
||||
}
|
||||
}
|
||||
};
|
||||
let cycle = match cycle {
|
||||
Ok(cycle) => cycle,
|
||||
@@ -413,6 +437,27 @@ impl RunEngine {
|
||||
Ok(revision) => revision,
|
||||
Err(error) => return (RunOutcome::Failed(error.into()), usage),
|
||||
};
|
||||
if !pending_insertions.is_empty() {
|
||||
let inserted = match append_insertions(
|
||||
&self.store,
|
||||
prepared,
|
||||
client,
|
||||
cancellation,
|
||||
revision,
|
||||
pending_insertions,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((next, inserted)) => {
|
||||
revision = next;
|
||||
inserted
|
||||
}
|
||||
Err(outcome) => return (outcome, usage),
|
||||
};
|
||||
if inserted {
|
||||
continue 'model;
|
||||
}
|
||||
}
|
||||
let (barrier, ready) = CommitBarrier::before_continue();
|
||||
if emit(
|
||||
client,
|
||||
@@ -453,6 +498,7 @@ impl RunEngine {
|
||||
calls: cycle.calls,
|
||||
recovered_started_at_ms: None,
|
||||
},
|
||||
pending_insertions,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -683,14 +729,36 @@ fn fallback_summary(messages: &[CanonicalMessage]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
async fn append_runtime_message(
|
||||
pub(super) async fn append_insertions(
|
||||
store: &Store,
|
||||
prepared: &PreparedRun,
|
||||
client: &mut ClientPort,
|
||||
cancellation: &CancellationToken,
|
||||
mut revision: crate::model::RevisionId,
|
||||
insertions: Vec<MessageInsertion>,
|
||||
) -> std::result::Result<(crate::model::RevisionId, bool), RunOutcome> {
|
||||
let mut inserted_any = false;
|
||||
for insertion in insertions {
|
||||
for message in insertion.messages {
|
||||
let (next, inserted) =
|
||||
append_runtime_message(store, prepared, client, cancellation, revision, message)
|
||||
.await?;
|
||||
revision = next;
|
||||
inserted_any |= inserted;
|
||||
}
|
||||
let _ = insertion.delivered.send(());
|
||||
}
|
||||
Ok((revision, inserted_any))
|
||||
}
|
||||
|
||||
pub(super) async fn append_runtime_message(
|
||||
store: &Store,
|
||||
prepared: &PreparedRun,
|
||||
client: &mut ClientPort,
|
||||
cancellation: &CancellationToken,
|
||||
revision: crate::model::RevisionId,
|
||||
message: CanonicalMessage,
|
||||
) -> std::result::Result<crate::model::RevisionId, RunOutcome> {
|
||||
) -> std::result::Result<(crate::model::RevisionId, bool), RunOutcome> {
|
||||
let event_id = message.runtime_event_id.clone().ok_or_else(|| {
|
||||
RunOutcome::Failed(RunFailure::Protocol(
|
||||
"runtime message has no event identity".into(),
|
||||
@@ -706,7 +774,7 @@ async fn append_runtime_message(
|
||||
.await
|
||||
.map_err(|error| RunOutcome::Failed(error.into()))?;
|
||||
if !inserted {
|
||||
return Ok(revision);
|
||||
return Ok((revision, false));
|
||||
}
|
||||
let (barrier, ready) = CommitBarrier::before_continue();
|
||||
emit(
|
||||
@@ -721,7 +789,7 @@ async fn append_runtime_message(
|
||||
.await
|
||||
.map_err(|_| client_failure())?;
|
||||
wait_for_state_ready(ready, cancellation).await?;
|
||||
Ok(revision)
|
||||
Ok((revision, true))
|
||||
}
|
||||
|
||||
async fn hydrate_tool_images(
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::model::{ConversationId, RunId};
|
||||
use crate::{
|
||||
client::{ClientCommand, MessageInsertion},
|
||||
model::{CanonicalMessage, ConversationId, RunId},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RunRegistry {
|
||||
@@ -13,6 +16,7 @@ pub struct RunRegistry {
|
||||
struct ActiveRun {
|
||||
run_id: RunId,
|
||||
cancellation: CancellationToken,
|
||||
commands: tokio::sync::mpsc::Sender<ClientCommand>,
|
||||
}
|
||||
|
||||
impl RunRegistry {
|
||||
@@ -21,12 +25,14 @@ impl RunRegistry {
|
||||
conversation_id: ConversationId,
|
||||
run_id: RunId,
|
||||
cancellation: CancellationToken,
|
||||
commands: tokio::sync::mpsc::Sender<ClientCommand>,
|
||||
) {
|
||||
let previous = self.active.lock().await.insert(
|
||||
conversation_id,
|
||||
ActiveRun {
|
||||
run_id: run_id.clone(),
|
||||
cancellation,
|
||||
commands,
|
||||
},
|
||||
);
|
||||
if let Some(previous) = previous.filter(|previous| previous.run_id != run_id) {
|
||||
@@ -34,6 +40,37 @@ impl RunRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert_messages(
|
||||
&self,
|
||||
conversation_id: &ConversationId,
|
||||
messages: Vec<CanonicalMessage>,
|
||||
) -> bool {
|
||||
if messages.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let commands = self
|
||||
.active
|
||||
.lock()
|
||||
.await
|
||||
.get(conversation_id)
|
||||
.map(|run| run.commands.clone());
|
||||
let Some(commands) = commands else {
|
||||
return false;
|
||||
};
|
||||
let (delivered, delivery) = tokio::sync::oneshot::channel();
|
||||
if commands
|
||||
.send(ClientCommand::InsertMessages(MessageInsertion {
|
||||
messages,
|
||||
delivered,
|
||||
}))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
delivery.await.is_ok()
|
||||
}
|
||||
|
||||
pub async fn release(&self, conversation_id: &ConversationId, run_id: &RunId) {
|
||||
let mut active = self.active.lock().await;
|
||||
if active
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
client::{ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, StateCommitted},
|
||||
client::{
|
||||
ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, MessageInsertion,
|
||||
StateCommitted,
|
||||
},
|
||||
model::{PreparedRun, RevisionId, ToolCall, ToolRoundAssistant, ToolRoundId},
|
||||
store::Store,
|
||||
};
|
||||
@@ -22,6 +25,7 @@ pub(super) async fn execute(
|
||||
cancellation: &CancellationToken,
|
||||
mut revision: RevisionId,
|
||||
round: ToolRound,
|
||||
insertions: Vec<MessageInsertion>,
|
||||
) -> std::result::Result<RevisionId, RunOutcome> {
|
||||
let ToolRound {
|
||||
id: round_id,
|
||||
@@ -66,7 +70,10 @@ pub(super) async fn execute(
|
||||
.await?;
|
||||
|
||||
let mut remaining = calls.len();
|
||||
let mut pending_runtime_messages = Vec::new();
|
||||
let mut pending_runtime_messages = insertions
|
||||
.into_iter()
|
||||
.map(PendingRuntimeMessage::Insertion)
|
||||
.collect::<Vec<_>>();
|
||||
while remaining > 0 {
|
||||
let command = tokio::select! {
|
||||
_ = cancellation.cancelled() => return Err(RunOutcome::Cancelled),
|
||||
@@ -116,10 +123,13 @@ pub(super) async fn execute(
|
||||
}
|
||||
}
|
||||
Some(ClientCommand::RuntimeEvent(event)) => {
|
||||
pending_runtime_messages.push(event.into_message());
|
||||
pending_runtime_messages.push(PendingRuntimeMessage::Message(event.into_message()));
|
||||
}
|
||||
Some(ClientCommand::RuntimeMessage(message)) => {
|
||||
pending_runtime_messages.push(message);
|
||||
pending_runtime_messages.push(PendingRuntimeMessage::Message(message));
|
||||
}
|
||||
Some(ClientCommand::InsertMessages(insertion)) => {
|
||||
pending_runtime_messages.push(PendingRuntimeMessage::Insertion(insertion))
|
||||
}
|
||||
Some(ClientCommand::Cancel) => return Err(RunOutcome::Cancelled),
|
||||
Some(ClientCommand::ClientClosed { error }) => {
|
||||
@@ -128,40 +138,42 @@ pub(super) async fn execute(
|
||||
None => return Err(client_failure()),
|
||||
}
|
||||
}
|
||||
for message in pending_runtime_messages {
|
||||
let event_id = message.runtime_event_id.clone().ok_or_else(|| {
|
||||
RunOutcome::Failed(RunFailure::Protocol(
|
||||
"runtime message has no event identity".into(),
|
||||
))
|
||||
})?;
|
||||
let (next, inserted) = store
|
||||
.append_message_once(
|
||||
&prepared.conversation_id,
|
||||
&prepared.run_id,
|
||||
revision,
|
||||
&message,
|
||||
)
|
||||
.await
|
||||
.map_err(failed)?;
|
||||
revision = next;
|
||||
if inserted {
|
||||
let (barrier, ready) = CommitBarrier::before_continue();
|
||||
send(
|
||||
client,
|
||||
ClientEvent::StateCommitted(StateCommitted {
|
||||
revision_id: revision,
|
||||
tool_round_version: 0,
|
||||
cause: CommitCause::RuntimeEvent { event_id },
|
||||
barrier,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
super::engine::wait_for_state_ready(ready, cancellation).await?;
|
||||
for pending in pending_runtime_messages {
|
||||
match pending {
|
||||
PendingRuntimeMessage::Message(message) => {
|
||||
revision = super::engine::append_runtime_message(
|
||||
store,
|
||||
prepared,
|
||||
client,
|
||||
cancellation,
|
||||
revision,
|
||||
message,
|
||||
)
|
||||
.await?
|
||||
.0;
|
||||
}
|
||||
PendingRuntimeMessage::Insertion(insertion) => {
|
||||
revision = super::engine::append_insertions(
|
||||
store,
|
||||
prepared,
|
||||
client,
|
||||
cancellation,
|
||||
revision,
|
||||
vec![insertion],
|
||||
)
|
||||
.await?
|
||||
.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
enum PendingRuntimeMessage {
|
||||
Message(crate::model::CanonicalMessage),
|
||||
Insertion(MessageInsertion),
|
||||
}
|
||||
|
||||
async fn send(client: &ClientPort, event: ClientEvent) -> std::result::Result<(), RunOutcome> {
|
||||
client
|
||||
.events
|
||||
|
||||
+16
-5
@@ -1,6 +1,6 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::Row;
|
||||
use sqlx::{Row, Sqlite, Transaction};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -44,13 +44,25 @@ pub struct BlobEdge {
|
||||
|
||||
impl Store {
|
||||
pub async fn put_blob(&self, data: &[u8], edges: &[BlobEdge]) -> Result<BlobId> {
|
||||
let _write = self.writes.lock().await;
|
||||
let blob_id = BlobId::digest(data);
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
Self::put_blob_tx(&mut tx, &blob_id, data, edges).await?;
|
||||
tx.commit().await?;
|
||||
Ok(blob_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn put_blob_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
blob_id: &BlobId,
|
||||
data: &[u8],
|
||||
edges: &[BlobEdge],
|
||||
) -> Result<()> {
|
||||
sqlx::query("INSERT OR IGNORE INTO blobs(blob_id, data, created_at_ms) VALUES (?, ?, ?)")
|
||||
.bind(blob_id.as_bytes().as_slice())
|
||||
.bind(data)
|
||||
.bind(now_ms())
|
||||
.execute(&mut *tx)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
for edge in edges {
|
||||
sqlx::query(
|
||||
@@ -59,11 +71,10 @@ impl Store {
|
||||
.bind(blob_id.as_bytes().as_slice())
|
||||
.bind(edge.child.as_bytes().as_slice())
|
||||
.bind(&edge.field_name)
|
||||
.execute(&mut *tx)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(blob_id)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_blob(&self, blob_id: &BlobId) -> Result<Option<Vec<u8>>> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sqlx::Row;
|
||||
use sqlx::{Row, Sqlite, Transaction};
|
||||
|
||||
use crate::{
|
||||
model::{CursorRunTraceArtifact, CursorRunTraceSummary},
|
||||
@@ -7,6 +7,21 @@ use crate::{
|
||||
|
||||
use super::{now_ms, BlobId, Store};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BufferedCursorTraceChunk {
|
||||
pub(crate) source: String,
|
||||
pub(crate) data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BufferedCursorTraceChunk {
|
||||
pub(crate) fn new(source: &str, data: &[u8]) -> Self {
|
||||
Self {
|
||||
source: source.into(),
|
||||
data: data.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub async fn start_cursor_trace_if_detailed(
|
||||
&self,
|
||||
@@ -21,6 +36,7 @@ impl Store {
|
||||
if !self.detailed_logging().await? {
|
||||
return Ok(false);
|
||||
}
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO cursor_run_traces(
|
||||
request_id, conversation_id, route, model_id, status, received_at_ms
|
||||
@@ -53,9 +69,22 @@ impl Store {
|
||||
data: &[u8],
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let blob_id = self.put_blob(data, &[]).await?;
|
||||
self.link_cursor_trace_artifact(request_id, artifact_type, source, &blob_id, metadata)
|
||||
.await
|
||||
let metadata_json = serde_json::to_string(metadata)?;
|
||||
let blob_id = BlobId::digest(data);
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
Self::put_blob_tx(&mut tx, &blob_id, data, &[]).await?;
|
||||
Self::link_cursor_trace_artifact_tx(
|
||||
&mut tx,
|
||||
request_id,
|
||||
artifact_type,
|
||||
source,
|
||||
&blob_id,
|
||||
&metadata_json,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn link_cursor_trace_artifact(
|
||||
@@ -66,13 +95,36 @@ impl Store {
|
||||
blob_id: &BlobId,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let metadata_json = serde_json::to_string(metadata)?;
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
Self::link_cursor_trace_artifact_tx(
|
||||
&mut tx,
|
||||
request_id,
|
||||
artifact_type,
|
||||
source,
|
||||
blob_id,
|
||||
&metadata_json,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn link_cursor_trace_artifact_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
request_id: &str,
|
||||
artifact_type: &str,
|
||||
source: &str,
|
||||
blob_id: &BlobId,
|
||||
metadata_json: &str,
|
||||
) -> Result<()> {
|
||||
let next: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(MAX(seq), -1) + 1
|
||||
FROM cursor_run_trace_artifacts WHERE request_id = ?",
|
||||
)
|
||||
.bind(request_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO cursor_run_trace_artifacts(
|
||||
@@ -84,11 +136,10 @@ impl Store {
|
||||
.bind(artifact_type)
|
||||
.bind(source)
|
||||
.bind(blob_id.as_bytes().as_slice())
|
||||
.bind(serde_json::to_string(metadata)?)
|
||||
.bind(metadata_json)
|
||||
.bind(now_ms())
|
||||
.execute(&mut *tx)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -97,6 +148,7 @@ impl Store {
|
||||
request_id: &str,
|
||||
bytes: usize,
|
||||
) -> Result<()> {
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query(
|
||||
"UPDATE cursor_run_traces
|
||||
SET request_bytes = request_bytes + ? WHERE request_id = ?",
|
||||
@@ -110,6 +162,7 @@ impl Store {
|
||||
|
||||
pub async fn start_cursor_trace_response(&self, request_id: &str, status: u16) -> Result<()> {
|
||||
let now = now_ms();
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query(
|
||||
"UPDATE cursor_run_traces
|
||||
SET status = 'running', http_status = ?,
|
||||
@@ -131,30 +184,58 @@ impl Store {
|
||||
source: &str,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
self.append_cursor_trace_artifact(
|
||||
self.add_cursor_trace_response_chunks(
|
||||
request_id,
|
||||
"run_sse_chunk",
|
||||
source,
|
||||
data,
|
||||
&serde_json::json!({"byte_count": data.len()}),
|
||||
&[BufferedCursorTraceChunk::new(source, data)],
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn add_cursor_trace_response_chunks(
|
||||
&self,
|
||||
request_id: &str,
|
||||
chunks: &[BufferedCursorTraceChunk],
|
||||
) -> Result<()> {
|
||||
if chunks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let response_bytes = chunks.iter().map(|chunk| chunk.data.len()).sum::<usize>();
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
for chunk in chunks {
|
||||
let metadata_json =
|
||||
serde_json::to_string(&serde_json::json!({"byte_count": chunk.data.len()}))?;
|
||||
let blob_id = BlobId::digest(&chunk.data);
|
||||
Self::put_blob_tx(&mut tx, &blob_id, &chunk.data, &[]).await?;
|
||||
Self::link_cursor_trace_artifact_tx(
|
||||
&mut tx,
|
||||
request_id,
|
||||
"run_sse_chunk",
|
||||
&chunk.source,
|
||||
&blob_id,
|
||||
&metadata_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE cursor_run_traces
|
||||
SET response_bytes = response_bytes + ?,
|
||||
response_event_count = response_event_count + 1,
|
||||
response_event_count = response_event_count + ?,
|
||||
first_response_at_ms = COALESCE(first_response_at_ms, ?)
|
||||
WHERE request_id = ?",
|
||||
)
|
||||
.bind(as_i64(data.len()))
|
||||
.bind(as_i64(response_bytes))
|
||||
.bind(chunks.len() as i64)
|
||||
.bind(now_ms())
|
||||
.bind(request_id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn finish_cursor_trace(&self, request_id: &str, error: Option<&str>) -> Result<()> {
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query(
|
||||
"UPDATE cursor_run_traces
|
||||
SET status = ?, finished_at_ms = ?, error_message = ?
|
||||
@@ -244,3 +325,61 @@ fn trace_from_row(row: sqlx::sqlite::SqliteRow) -> Result<CursorRunTraceSummary>
|
||||
fn as_i64(value: usize) -> i64 {
|
||||
value.min(i64::MAX as usize) as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_a_batch_of_trace_chunks_with_one_summary_update() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
store.set_detailed_logging(true).await.unwrap();
|
||||
store
|
||||
.start_cursor_trace_if_detailed("trace", None, "cursor_official", None)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE trace_summary_updates(count INTEGER NOT NULL)")
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO trace_summary_updates(count) VALUES (0)")
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TRIGGER count_trace_summary_updates
|
||||
AFTER UPDATE OF response_bytes ON cursor_run_traces
|
||||
BEGIN
|
||||
UPDATE trace_summary_updates SET count = count + 1;
|
||||
END",
|
||||
)
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.add_cursor_trace_response_chunks(
|
||||
"trace",
|
||||
&[
|
||||
BufferedCursorTraceChunk::new("cursor_official", b"one"),
|
||||
BufferedCursorTraceChunk::new("cursor_official", b"two"),
|
||||
BufferedCursorTraceChunk::new("cursor_official", b"three"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let trace = store.cursor_trace("trace").await.unwrap().unwrap();
|
||||
assert_eq!(trace.response_bytes, 11);
|
||||
assert_eq!(trace.response_event_count, 3);
|
||||
assert_eq!(
|
||||
store.cursor_trace_artifacts("trace").await.unwrap().len(),
|
||||
3
|
||||
);
|
||||
let updates: i64 = sqlx::query_scalar("SELECT count FROM trace_summary_updates")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updates, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ impl Store {
|
||||
input_id: &str,
|
||||
base_revision_id: RevisionId,
|
||||
) -> Result<RevisionId> {
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO input_anchors
|
||||
|
||||
+208
-17
@@ -12,6 +12,34 @@ use crate::{
|
||||
|
||||
use super::{now_ms, Store};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BufferedLlmChunk {
|
||||
pub(crate) seq: i64,
|
||||
pub(crate) elapsed_ms: i64,
|
||||
pub(crate) data: Option<Vec<u8>>,
|
||||
pub(crate) byte_count: usize,
|
||||
}
|
||||
|
||||
impl BufferedLlmChunk {
|
||||
pub(crate) fn new(seq: i64, elapsed_ms: i64, data: &[u8]) -> Self {
|
||||
Self {
|
||||
seq,
|
||||
elapsed_ms,
|
||||
data: Some(data.to_vec()),
|
||||
byte_count: data.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn metrics(seq: i64, elapsed_ms: i64, byte_count: usize) -> Self {
|
||||
Self {
|
||||
seq,
|
||||
elapsed_ms,
|
||||
data: None,
|
||||
byte_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub async fn detailed_logging(&self) -> Result<bool> {
|
||||
let value: String = sqlx::query_scalar(
|
||||
@@ -23,6 +51,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn set_detailed_logging(&self, enabled: bool) -> Result<()> {
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES ('llm_detailed_logging', ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
|
||||
)
|
||||
@@ -34,6 +63,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn start_llm_call(&self, call: &NewLlmCall) -> Result<()> {
|
||||
let _write = self.writes.lock().await;
|
||||
let now = now_ms();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO llm_calls(
|
||||
@@ -74,21 +104,27 @@ impl Store {
|
||||
detailed: bool,
|
||||
) -> Result<()> {
|
||||
let body_json = serde_json::to_string(body)?;
|
||||
let headers_json = detailed
|
||||
.then(|| serde_json::to_string(headers))
|
||||
.transpose()?;
|
||||
let _write = self.writes.lock().await;
|
||||
let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
if detailed {
|
||||
sqlx::query("INSERT INTO llm_call_requests(call_id, headers_json, body_json, byte_count) SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM llm_calls WHERE call_id = ?)")
|
||||
.bind(call_id)
|
||||
.bind(serde_json::to_string(headers)?)
|
||||
.bind(headers_json)
|
||||
.bind(&body_json)
|
||||
.bind(body_json.len() as i64)
|
||||
.bind(call_id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query("UPDATE llm_calls SET request_bytes = ? WHERE call_id = ?")
|
||||
.bind(body_json.len() as i64)
|
||||
.bind(call_id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -98,6 +134,7 @@ impl Store {
|
||||
elapsed_ms: i64,
|
||||
http_status: u16,
|
||||
) -> Result<()> {
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query("UPDATE llm_calls SET response_headers_at_ms = ?, ttfb_ms = ?, http_status = ? WHERE call_id = ?")
|
||||
.bind(now_ms())
|
||||
.bind(elapsed_ms)
|
||||
@@ -116,21 +153,50 @@ impl Store {
|
||||
data: &[u8],
|
||||
detailed: bool,
|
||||
) -> Result<()> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
if detailed {
|
||||
sqlx::query("INSERT INTO llm_call_response_chunks(call_id, seq, received_offset_ms, data, byte_count) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM llm_calls WHERE call_id = ?)")
|
||||
.bind(call_id)
|
||||
.bind(seq)
|
||||
.bind(elapsed_ms)
|
||||
.bind(data)
|
||||
.bind(data.len() as i64)
|
||||
.bind(call_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let chunk = if detailed {
|
||||
BufferedLlmChunk::new(seq, elapsed_ms, data)
|
||||
} else {
|
||||
BufferedLlmChunk::metrics(seq, elapsed_ms, data.len())
|
||||
};
|
||||
self.record_llm_chunks(call_id, &[chunk], detailed).await
|
||||
}
|
||||
|
||||
pub(crate) async fn record_llm_chunks(
|
||||
&self,
|
||||
call_id: &str,
|
||||
chunks: &[BufferedLlmChunk],
|
||||
detailed: bool,
|
||||
) -> Result<()> {
|
||||
if chunks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
sqlx::query("UPDATE llm_calls SET first_event_at_ms = COALESCE(first_event_at_ms, ?), response_bytes = response_bytes + ?, stream_event_count = stream_event_count + 1 WHERE call_id = ?")
|
||||
let byte_count = chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.byte_count as i64)
|
||||
.sum::<i64>();
|
||||
let event_count = chunks.len() as i64;
|
||||
let _write = self.writes.lock().await;
|
||||
let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
if detailed {
|
||||
for chunk in chunks {
|
||||
let data = chunk.data.as_deref().ok_or_else(|| {
|
||||
crate::Error::Store("detailed LLM chunk is missing payload data".into())
|
||||
})?;
|
||||
sqlx::query("INSERT INTO llm_call_response_chunks(call_id, seq, received_offset_ms, data, byte_count) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM llm_calls WHERE call_id = ?)")
|
||||
.bind(call_id)
|
||||
.bind(chunk.seq)
|
||||
.bind(chunk.elapsed_ms)
|
||||
.bind(data)
|
||||
.bind(chunk.byte_count as i64)
|
||||
.bind(call_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
sqlx::query("UPDATE llm_calls SET first_event_at_ms = COALESCE(first_event_at_ms, ?), response_bytes = response_bytes + ?, stream_event_count = stream_event_count + ? WHERE call_id = ?")
|
||||
.bind(now_ms())
|
||||
.bind(data.len() as i64)
|
||||
.bind(byte_count)
|
||||
.bind(event_count)
|
||||
.bind(call_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
@@ -139,6 +205,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn record_llm_first_text(&self, call_id: &str, elapsed_ms: i64) -> Result<()> {
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query("UPDATE llm_calls SET first_text_at_ms = COALESCE(first_text_at_ms, ?), ttft_ms = COALESCE(ttft_ms, ?) WHERE call_id = ?")
|
||||
.bind(now_ms())
|
||||
.bind(elapsed_ms)
|
||||
@@ -149,6 +216,8 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn record_llm_usage(&self, call_id: &str, usage: Usage) -> Result<()> {
|
||||
let usage_json = serde_json::to_string(&usage)?;
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query("UPDATE llm_calls SET input_tokens = ?, output_tokens = ?, total_tokens = ?, cache_read_tokens = ?, cache_write_tokens = ?, reasoning_tokens = ?, usage_json = ? WHERE call_id = ?")
|
||||
.bind(as_i64(usage.input_tokens))
|
||||
.bind(as_i64(usage.output_tokens))
|
||||
@@ -156,7 +225,7 @@ impl Store {
|
||||
.bind(as_i64(usage.cache_read_tokens))
|
||||
.bind(as_i64(usage.cache_write_tokens))
|
||||
.bind(as_i64(usage.reasoning_tokens))
|
||||
.bind(serde_json::to_string(&usage)?)
|
||||
.bind(usage_json)
|
||||
.bind(call_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -172,6 +241,7 @@ impl Store {
|
||||
error_kind: Option<&str>,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query("UPDATE llm_calls SET status = ?, finish_reason = ?, finished_at_ms = ?, duration_ms = ?, error_kind = ?, error_message = ? WHERE call_id = ? AND status = 'running'")
|
||||
.bind(status)
|
||||
.bind(finish_reason)
|
||||
@@ -327,8 +397,129 @@ fn summary_from_row(row: sqlx::sqlite::SqliteRow) -> Result<LlmCallSummary> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::model::{ModelConfigInput, ModelType};
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_writes_are_serialized_without_sqlite_busy_retries() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store::connect(&format!(
|
||||
"sqlite://{}",
|
||||
directory.path().join("concurrent-writes.db").display()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO llm_calls(
|
||||
call_id, run_id, conversation_id, provider_call_index, provider_type,
|
||||
provider_url, request_type, request_url, model_id, display_name, status,
|
||||
created_at_ms, message_count, tool_count, detailed
|
||||
) VALUES (
|
||||
'concurrent-call', 'run', 'conversation', 0, 'openai-chat',
|
||||
'https://example.com', 'openai-chat', 'https://example.com',
|
||||
'model', 'Model', 'running', 1, 0, 0, 0
|
||||
)",
|
||||
)
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut connections = Vec::new();
|
||||
for _ in 0..8 {
|
||||
connections.push(store.pool().acquire().await.unwrap());
|
||||
}
|
||||
for connection in &mut connections {
|
||||
sqlx::query("PRAGMA busy_timeout = 0")
|
||||
.execute(&mut **connection)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
drop(connections);
|
||||
|
||||
let writers = 32;
|
||||
let barrier = Arc::new(Barrier::new(writers));
|
||||
let mut tasks = Vec::with_capacity(writers);
|
||||
for seq in 0..writers {
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
store
|
||||
.record_llm_chunk("concurrent-call", seq as i64, 1, b"x", false)
|
||||
.await
|
||||
}));
|
||||
}
|
||||
for task in tasks {
|
||||
task.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
let call = store.llm_call("concurrent-call").await.unwrap().unwrap();
|
||||
assert_eq!(call.response_bytes, writers as i64);
|
||||
assert_eq!(call.stream_event_count, writers as i64);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_a_batch_of_response_chunks_with_one_summary_update() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO llm_calls(
|
||||
call_id, run_id, conversation_id, provider_call_index, provider_type,
|
||||
provider_url, request_type, request_url, model_id, display_name, status,
|
||||
created_at_ms, message_count, tool_count, detailed
|
||||
) VALUES (
|
||||
'batch-call', 'run', 'conversation', 0, 'openai-chat',
|
||||
'https://example.com', 'openai-chat', 'https://example.com',
|
||||
'model', 'Model', 'running', 1, 0, 0, 1
|
||||
)",
|
||||
)
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE llm_call_summary_updates(count INTEGER NOT NULL)")
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO llm_call_summary_updates(count) VALUES (0)")
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TRIGGER count_llm_call_summary_updates
|
||||
AFTER UPDATE OF response_bytes ON llm_calls
|
||||
BEGIN
|
||||
UPDATE llm_call_summary_updates SET count = count + 1;
|
||||
END",
|
||||
)
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.record_llm_chunks(
|
||||
"batch-call",
|
||||
&[
|
||||
BufferedLlmChunk::new(0, 1, b"one"),
|
||||
BufferedLlmChunk::new(1, 2, b"two"),
|
||||
BufferedLlmChunk::new(2, 3, b"three"),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let call = store.llm_call("batch-call").await.unwrap().unwrap();
|
||||
assert_eq!(call.response_bytes, 11);
|
||||
assert_eq!(call.stream_event_count, 3);
|
||||
assert_eq!(store.llm_call_chunks("batch-call").await.unwrap().len(), 3);
|
||||
let updates: i64 = sqlx::query_scalar("SELECT count FROM llm_call_summary_updates")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updates, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_usage_anchor_uses_the_latest_completed_call_for_the_same_conversation_and_model(
|
||||
|
||||
@@ -13,8 +13,11 @@ mod settings;
|
||||
mod sqlite;
|
||||
mod storage;
|
||||
mod tool_rounds;
|
||||
mod writer;
|
||||
|
||||
pub use cas::*;
|
||||
pub(crate) use cursor_traces::BufferedCursorTraceChunk;
|
||||
pub(crate) use llm_calls::BufferedLlmChunk;
|
||||
pub use runs::*;
|
||||
pub use settings::*;
|
||||
pub(crate) use sqlite::now_ms;
|
||||
|
||||
@@ -60,6 +60,7 @@ impl Store {
|
||||
normalized.push((hash, input));
|
||||
}
|
||||
let now = now_ms();
|
||||
let _write = self.writes.lock().await;
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
for (hash, input) in &normalized {
|
||||
insert_model(&mut transaction, hash, input, now).await?;
|
||||
@@ -87,6 +88,7 @@ impl Store {
|
||||
}
|
||||
}
|
||||
let now = now_ms();
|
||||
let _write = self.writes.lock().await;
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let mut inserted = 0;
|
||||
for (hash, input) in &normalized {
|
||||
@@ -110,6 +112,7 @@ impl Store {
|
||||
let input = normalize_model_input(input)?;
|
||||
let next_hash = model_hash(&input)?;
|
||||
let now = now_ms();
|
||||
let _write = self.writes.lock().await;
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
if next_hash != current.model_hash {
|
||||
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
|
||||
@@ -165,6 +168,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn delete_model(&self, hash: &str) -> Result<()> {
|
||||
let _write = self.writes.lock().await;
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
sqlx::query("UPDATE llm_calls SET model_hash = NULL WHERE model_hash = ?")
|
||||
.bind(hash)
|
||||
@@ -201,6 +205,7 @@ impl Store {
|
||||
}
|
||||
|
||||
let now = now_ms();
|
||||
let _write = self.writes.lock().await;
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
for (index, hash) in model_hashes.iter().enumerate() {
|
||||
sqlx::query(
|
||||
|
||||
@@ -13,6 +13,7 @@ impl Store {
|
||||
&self,
|
||||
conversation_id: &ConversationId,
|
||||
) -> Result<RevisionId> {
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
let revision = Self::ensure_conversation_tx(&mut tx, conversation_id).await?;
|
||||
tx.commit().await?;
|
||||
@@ -57,14 +58,47 @@ impl Store {
|
||||
self.load_revision_messages(RevisionId(revision_id)).await
|
||||
}
|
||||
|
||||
pub async fn match_revision_prefix(
|
||||
&self,
|
||||
conversation_id: &ConversationId,
|
||||
base_revision_id: RevisionId,
|
||||
additions: &[CanonicalMessage],
|
||||
) -> Result<(RevisionId, usize)> {
|
||||
let mut revision = base_revision_id;
|
||||
let mut messages = self.load_revision_messages(revision).await?;
|
||||
for (index, addition) in additions.iter().enumerate() {
|
||||
messages.push(addition.clone());
|
||||
let digest = message_digest(&messages)?;
|
||||
let child = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT revision_id FROM conversation_revisions
|
||||
WHERE conversation_id = ? AND parent_revision_id = ? AND state_digest = ?",
|
||||
)
|
||||
.bind(conversation_id.as_str())
|
||||
.bind(revision.0)
|
||||
.bind(digest.as_slice())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.map(RevisionId);
|
||||
let Some(child) = child else {
|
||||
return Ok((revision, index));
|
||||
};
|
||||
if self.load_revision_messages(child).await? != messages {
|
||||
return Ok((revision, index));
|
||||
}
|
||||
revision = child;
|
||||
}
|
||||
Ok((revision, additions.len()))
|
||||
}
|
||||
|
||||
pub async fn import_revision(
|
||||
&self,
|
||||
conversation_id: &ConversationId,
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Result<RevisionId> {
|
||||
let digest = message_digest(messages)?;
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
let current = Self::ensure_conversation_tx(&mut tx, conversation_id).await?;
|
||||
let digest = message_digest(messages)?;
|
||||
if let Some(existing) = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT revision_id FROM conversation_revisions
|
||||
WHERE conversation_id = ? AND state_digest = ?",
|
||||
@@ -107,9 +141,20 @@ impl Store {
|
||||
if additions.is_empty() {
|
||||
return Ok(expected);
|
||||
}
|
||||
let mut full = self.load_revision_messages(expected).await?;
|
||||
full.extend_from_slice(additions);
|
||||
let digest = message_digest(&full)?;
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
let revision =
|
||||
Self::append_revision_tx(&mut tx, conversation_id, run_id, expected, additions).await?;
|
||||
let revision = Self::append_revision_with_digest_tx(
|
||||
&mut tx,
|
||||
conversation_id,
|
||||
run_id,
|
||||
expected,
|
||||
additions,
|
||||
digest,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(revision)
|
||||
}
|
||||
@@ -121,6 +166,8 @@ impl Store {
|
||||
expected: RevisionId,
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Result<RevisionId> {
|
||||
let digest = message_digest(messages)?;
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
Self::require_active_head_tx(&mut tx, conversation_id, run_id, expected).await?;
|
||||
let root: i64 = sqlx::query_scalar(
|
||||
@@ -130,7 +177,6 @@ impl Store {
|
||||
.bind(conversation_id.as_str())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let digest = message_digest(messages)?;
|
||||
let revision =
|
||||
Self::insert_revision_tx(&mut tx, conversation_id, RevisionId(root), messages, digest)
|
||||
.await?;
|
||||
@@ -202,10 +248,29 @@ impl Store {
|
||||
expected: RevisionId,
|
||||
additions: &[CanonicalMessage],
|
||||
) -> Result<RevisionId> {
|
||||
Self::require_active_head_tx(tx, conversation_id, run_id, expected).await?;
|
||||
let mut full = Self::load_revision_messages_tx(tx, expected.0).await?;
|
||||
full.extend_from_slice(additions);
|
||||
let digest = message_digest(&full)?;
|
||||
Self::append_revision_with_digest_tx(
|
||||
tx,
|
||||
conversation_id,
|
||||
run_id,
|
||||
expected,
|
||||
additions,
|
||||
digest,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn append_revision_with_digest_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
conversation_id: &ConversationId,
|
||||
run_id: &RunId,
|
||||
expected: RevisionId,
|
||||
additions: &[CanonicalMessage],
|
||||
digest: [u8; 32],
|
||||
) -> Result<RevisionId> {
|
||||
Self::require_active_head_tx(tx, conversation_id, run_id, expected).await?;
|
||||
if sqlx::query_scalar::<_, i64>(
|
||||
"SELECT revision_id FROM conversation_revisions
|
||||
WHERE conversation_id = ? AND state_digest = ?",
|
||||
|
||||
@@ -36,6 +36,7 @@ pub struct ClaimedRun {
|
||||
|
||||
impl Store {
|
||||
pub async fn claim_run(&self, prepared: &PreparedRun) -> Result<ClaimedRun> {
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
let now = now_ms();
|
||||
Self::ensure_conversation_tx(&mut tx, &prepared.conversation_id).await?;
|
||||
@@ -146,6 +147,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn begin_provider_call(&self, run_id: &RunId) -> Result<u64> {
|
||||
let _write = self.writes.lock().await;
|
||||
let index: Option<i64> = sqlx::query_scalar(
|
||||
"UPDATE runs SET provider_call_index = provider_call_index + 1, updated_at_ms = ?
|
||||
WHERE run_id = ? AND status = 'running'
|
||||
@@ -167,6 +169,8 @@ impl Store {
|
||||
usage: Option<Usage>,
|
||||
failure: Option<(&str, &str)>,
|
||||
) -> Result<bool> {
|
||||
let usage_json = serde_json::to_string(&usage)?;
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
let row = sqlx::query(
|
||||
"SELECT conversation_id, status, failure_category, failure_summary
|
||||
@@ -200,7 +204,7 @@ impl Store {
|
||||
WHERE run_id = ? AND status = 'running'",
|
||||
)
|
||||
.bind(status.as_str())
|
||||
.bind(serde_json::to_string(&usage)?)
|
||||
.bind(usage_json)
|
||||
.bind(category)
|
||||
.bind(summary)
|
||||
.bind(now)
|
||||
|
||||
@@ -47,10 +47,25 @@ pub struct TabSettings {
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
pub struct DesktopSettings {
|
||||
#[serde(default)]
|
||||
pub silent_start: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_dock_icon: bool,
|
||||
}
|
||||
|
||||
impl Default for DesktopSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
silent_start: false,
|
||||
show_dock_icon: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl TabSettings {
|
||||
@@ -93,6 +108,7 @@ pub(crate) struct ProxySettingsSecret {
|
||||
impl Store {
|
||||
pub(crate) async fn installation_id(&self) -> Result<String> {
|
||||
let generated = uuid::Uuid::new_v4().to_string();
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO NOTHING",
|
||||
)
|
||||
@@ -165,9 +181,11 @@ impl Store {
|
||||
username: input.username.trim().to_owned(),
|
||||
password,
|
||||
};
|
||||
let value_json = serde_json::to_string(&settings)?;
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query("INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
|
||||
.bind(PROXY_SETTINGS_KEY)
|
||||
.bind(serde_json::to_string(&settings)?)
|
||||
.bind(value_json)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -206,9 +224,11 @@ impl Store {
|
||||
));
|
||||
}
|
||||
}
|
||||
let value_json = serde_json::to_string(&settings)?;
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query("INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
|
||||
.bind(TAB_SETTINGS_KEY)
|
||||
.bind(serde_json::to_string(&settings)?)
|
||||
.bind(value_json)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -228,11 +248,13 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn set_port_settings(&self, settings: PortSettings) -> Result<()> {
|
||||
let value_json = serde_json::to_string(&settings)?;
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
|
||||
)
|
||||
.bind(PORT_SETTINGS_KEY)
|
||||
.bind(serde_json::to_string(&settings)?)
|
||||
.bind(value_json)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -264,11 +286,13 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn set_desktop_settings(&self, settings: DesktopSettings) -> Result<()> {
|
||||
let value_json = serde_json::to_string(&settings)?;
|
||||
let _write = self.writes.lock().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
|
||||
)
|
||||
.bind(DESKTOP_SETTINGS_KEY)
|
||||
.bind(serde_json::to_string(&settings)?)
|
||||
.bind(value_json)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -315,6 +339,30 @@ mod tests {
|
||||
assert_eq!(store.port_settings().await.unwrap(), settings);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_settings_show_the_dock_icon_by_default_and_round_trip() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.desktop_settings().await.unwrap(),
|
||||
DesktopSettings::default()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DesktopSettings>(r#"{"silent_start":true}"#).unwrap(),
|
||||
DesktopSettings {
|
||||
silent_start: true,
|
||||
show_dock_icon: true,
|
||||
}
|
||||
);
|
||||
let settings = DesktopSettings {
|
||||
silent_start: true,
|
||||
show_dock_icon: false,
|
||||
};
|
||||
store.set_desktop_settings(settings).await.unwrap();
|
||||
|
||||
assert_eq!(store.desktop_settings().await.unwrap(), settings);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_settings_are_write_only_and_preserve_an_unchanged_password() {
|
||||
let store = Store::connect("sqlite::memory:").await.unwrap();
|
||||
|
||||
@@ -7,9 +7,12 @@ use sqlx::{
|
||||
|
||||
use crate::Result;
|
||||
|
||||
use super::writer::WriteCoordinator;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Store {
|
||||
pub(crate) pool: SqlitePool,
|
||||
pub(crate) writes: WriteCoordinator,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
@@ -25,7 +28,10 @@ impl Store {
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
Ok(Self { pool })
|
||||
Ok(Self {
|
||||
pool,
|
||||
writes: WriteCoordinator::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> &SqlitePool {
|
||||
|
||||
@@ -54,6 +54,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn clear_statistics_storage(&self) -> Result<StatisticsStorage> {
|
||||
let _write = self.writes.lock().await;
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM llm_calls")
|
||||
.execute(&mut *transaction)
|
||||
|
||||
@@ -50,6 +50,8 @@ impl Store {
|
||||
if calls.is_empty() {
|
||||
return Err(Error::Store("cannot persist an empty tool round".into()));
|
||||
}
|
||||
let assistant_json = serde_json::to_string(assistant)?;
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
let ownership: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(
|
||||
@@ -82,7 +84,7 @@ impl Store {
|
||||
.bind(round_id.as_str())
|
||||
.bind(run_id.as_str())
|
||||
.bind(base_revision_id.0)
|
||||
.bind(serde_json::to_string(assistant)?)
|
||||
.bind(assistant_json)
|
||||
.bind(created_at_ms)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
@@ -113,6 +115,7 @@ impl Store {
|
||||
round_id: &ToolRoundId,
|
||||
result: &ToolResult,
|
||||
) -> Result<ToolCommit> {
|
||||
let _write = self.writes.lock().await;
|
||||
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
let round = sqlx::query(
|
||||
"SELECT assistant_json, status, version, next_completion_seq
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{Mutex, MutexGuard};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct WriteCoordinator {
|
||||
lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl WriteCoordinator {
|
||||
pub(crate) async fn lock(&self) -> MutexGuard<'_, ()> {
|
||||
self.lock.lock().await
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use std::{cmp::Ordering, collections::HashMap};
|
||||
|
||||
use futures_util::future::join_all;
|
||||
|
||||
use crate::store::Store;
|
||||
|
||||
use super::{catalog, SearchEngine, SearchHit};
|
||||
|
||||
const RRF_K: f64 = 60.0;
|
||||
@@ -9,10 +11,16 @@ const MAX_RESULTS: usize = 10;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebSearch {
|
||||
client: reqwest::Client,
|
||||
client: SearchClient,
|
||||
engines: Vec<SearchEngine>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum SearchClient {
|
||||
Managed(Store),
|
||||
Direct(reqwest::Client),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("web search failed: {0}")]
|
||||
pub struct SearchError(String);
|
||||
@@ -22,13 +30,20 @@ impl WebSearch {
|
||||
Self::with_engines(catalog::engines())
|
||||
}
|
||||
|
||||
pub(crate) fn managed(store: Store) -> Self {
|
||||
Self {
|
||||
client: SearchClient::Managed(store),
|
||||
engines: catalog::engines(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_engines<I, E>(engines: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = E>,
|
||||
E: Into<SearchEngine>,
|
||||
{
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
client: SearchClient::Direct(reqwest::Client::new()),
|
||||
engines: engines.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
@@ -42,10 +57,16 @@ impl WebSearch {
|
||||
if query.is_empty() {
|
||||
return Err(SearchError("query is empty".into()));
|
||||
}
|
||||
let client = match &self.client {
|
||||
SearchClient::Managed(store) => crate::network::client(store)
|
||||
.await
|
||||
.map_err(|error| SearchError(format!("HTTP client failed: {error}")))?,
|
||||
SearchClient::Direct(client) => client.clone(),
|
||||
};
|
||||
let responses = join_all(
|
||||
self.engines
|
||||
.iter()
|
||||
.map(|engine| engine.search(&self.client, query)),
|
||||
.map(|engine| engine.search(&client, query)),
|
||||
)
|
||||
.await;
|
||||
let mut merged = HashMap::<String, SearchHit>::new();
|
||||
|
||||
+25
-1
@@ -14,6 +14,8 @@ use reqwest::{
|
||||
use tokio::{net::lookup_host, time::timeout};
|
||||
use url::{Host, Url};
|
||||
|
||||
use crate::store::Store;
|
||||
|
||||
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -38,12 +40,27 @@ enum NetworkPolicy {
|
||||
#[derive(Clone)]
|
||||
pub struct WebFetch {
|
||||
network: NetworkPolicy,
|
||||
client: FetchClient,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FetchClient {
|
||||
Managed(Store),
|
||||
Direct,
|
||||
}
|
||||
|
||||
impl WebFetch {
|
||||
pub fn built_in() -> Self {
|
||||
Self {
|
||||
network: NetworkPolicy::PublicOnly,
|
||||
client: FetchClient::Direct,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn managed(store: Store) -> Self {
|
||||
Self {
|
||||
network: NetworkPolicy::PublicOnly,
|
||||
client: FetchClient::Managed(store),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +68,7 @@ impl WebFetch {
|
||||
pub(crate) fn for_test() -> Self {
|
||||
Self {
|
||||
network: NetworkPolicy::Any,
|
||||
client: FetchClient::Direct,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +129,13 @@ impl WebFetch {
|
||||
return Err(failure("URL resolves to a non-public address"));
|
||||
}
|
||||
|
||||
let mut builder = reqwest::Client::builder()
|
||||
let builder = match &self.client {
|
||||
FetchClient::Managed(store) => crate::network::client_builder(store)
|
||||
.await
|
||||
.map_err(|error| failure(format!("HTTP client failed: {error}")))?,
|
||||
FetchClient::Direct => reqwest::Client::builder().use_native_tls(),
|
||||
};
|
||||
let mut builder = builder
|
||||
.redirect(Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10));
|
||||
if domain {
|
||||
|
||||
@@ -76,7 +76,7 @@ async fn background_subagent_completion_starts_a_simulated_parent_turn() {
|
||||
.unwrap();
|
||||
assert!(messages.iter().any(|message| {
|
||||
message.runtime_event_id.as_deref()
|
||||
== Some("background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id")
|
||||
== Some("background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id:task-call")
|
||||
&& matches!(&message.content, MessageContent::Parts { parts } if !parts.is_empty())
|
||||
}));
|
||||
|
||||
@@ -131,12 +131,137 @@ async fn background_subagent_completion_starts_a_simulated_parent_turn() {
|
||||
assert_eq!(
|
||||
runtime_ids,
|
||||
[
|
||||
"runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id",
|
||||
"runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id-2"
|
||||
"runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id:task-call",
|
||||
"runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id-2:task-call"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_completion_joins_the_active_run_instead_of_replacing_it() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
let first_ready = provider.push_gated(stop_response("model-call-1", "first response"));
|
||||
provider.push(stop_response("model-call-2", "processed both completions"));
|
||||
let assets = PromptAssets::load(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("prompt/cursor")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = CursorSessionRegistry::new(
|
||||
store.clone(),
|
||||
Arc::new(provider.clone()),
|
||||
PromptCompiler::new(assets),
|
||||
Default::default(),
|
||||
);
|
||||
let first = registry.get_or_create("active-completion-1").await.unwrap();
|
||||
let first_run = tokio::spawn(async move {
|
||||
drive_completion(
|
||||
&first,
|
||||
completion_run(
|
||||
"child-1",
|
||||
"parent-run-1",
|
||||
pb::ConversationStateStructure::default(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
});
|
||||
while provider.requests().is_empty() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
let second = registry.get_or_create("active-completion-2").await.unwrap();
|
||||
let second_run = tokio::spawn(async move {
|
||||
drive_forwarded_completion(
|
||||
&second,
|
||||
completion_run(
|
||||
"child-2",
|
||||
"parent-run-2",
|
||||
pb::ConversationStateStructure::default(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
first_ready.notify_one();
|
||||
|
||||
second_run.await.unwrap();
|
||||
first_run.await.unwrap();
|
||||
let requests = provider.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let history = serde_json::to_string(&requests[1].history).unwrap();
|
||||
assert!(history.contains("child-1"));
|
||||
assert!(history.contains("first response"));
|
||||
assert!(history.contains("child-2"));
|
||||
let statuses: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT status FROM runs WHERE conversation_id = 'parent-conversation' ORDER BY created_at_ms",
|
||||
)
|
||||
.fetch_all(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(statuses, ["completed"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retrying_one_background_completion_reuses_its_runtime_message() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
provider.push(stop_response("model-call", "followed up"));
|
||||
let assets = PromptAssets::load(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("prompt/cursor")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = CursorSessionRegistry::new(
|
||||
store.clone(),
|
||||
Arc::new(provider.clone()),
|
||||
PromptCompiler::new(assets),
|
||||
Default::default(),
|
||||
);
|
||||
let first = registry.get_or_create("completion-retry-1").await.unwrap();
|
||||
let (checkpoint, _) = drive_completion(
|
||||
&first,
|
||||
completion_run(
|
||||
"retry-child",
|
||||
"completion-retry-run-1",
|
||||
pb::ConversationStateStructure {
|
||||
mode: Some(pb::AgentMode::Multitask as i32),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
provider.push(stop_response("model-call-2", "followed up again"));
|
||||
let second = registry.get_or_create("completion-retry-2").await.unwrap();
|
||||
drive_completion(
|
||||
&second,
|
||||
completion_run("retry-child", "completion-retry-run-2", checkpoint),
|
||||
)
|
||||
.await;
|
||||
|
||||
let messages = store
|
||||
.load_current_messages(&cursor_server::model::ConversationId::new(
|
||||
"parent-conversation",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
message.runtime_event_id.as_deref()
|
||||
== Some(
|
||||
"background-completed:BACKGROUND_TASK_KIND_SUBAGENT:retry-child:task-call",
|
||||
)
|
||||
})
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_shell_completion_wakes_the_parent_with_the_captured_notification() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
@@ -335,10 +460,110 @@ async fn drive_completion(
|
||||
)
|
||||
}
|
||||
|
||||
async fn drive_forwarded_completion(handle: &CursorSessionHandle, message: pb::AgentClientMessage) {
|
||||
let mut output = handle.subscribe();
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: 0,
|
||||
message: Box::new(message),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut append_seqno = 1;
|
||||
loop {
|
||||
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||
if flags & connect::END_STREAM_FLAG != 0 {
|
||||
assert_eq!(payload.as_ref(), b"{}");
|
||||
return;
|
||||
}
|
||||
let server = pb::AgentServerMessage::decode(payload).unwrap();
|
||||
match server.message {
|
||||
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => {
|
||||
assert_eq!(exec.id, 0);
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: append_seqno,
|
||||
message: Box::new(pb::AgentClientMessage {
|
||||
message: Some(
|
||||
pb::agent_client_message::Message::ExecClientControlMessage(
|
||||
pb::ExecClientControlMessage {
|
||||
message: Some(
|
||||
pb::exec_client_control_message::Message::StreamClose(
|
||||
pb::ExecClientStreamClose { id: 0 },
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_seqno += 1;
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: append_seqno,
|
||||
message: Box::new(pb::AgentClientMessage {
|
||||
message: Some(pb::agent_client_message::Message::ExecClientMessage(
|
||||
pb::ExecClientMessage {
|
||||
id: 0,
|
||||
message: Some(
|
||||
pb::exec_client_message::Message::RequestContextResult(
|
||||
pb::RequestContextResult {
|
||||
result: Some(
|
||||
pb::request_context_result::Result::Success(
|
||||
pb::RequestContextSuccess {
|
||||
request_context: Some(
|
||||
pb::RequestContext::default(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_seqno += 1;
|
||||
}
|
||||
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: append_seqno,
|
||||
message: Box::new(kv_ack(kv.id)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_seqno += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn completion_run(
|
||||
child_id: &str,
|
||||
run_id: &str,
|
||||
conversation_state: pb::ConversationStateStructure,
|
||||
) -> pb::AgentClientMessage {
|
||||
completion_run_with_detail(child_id, run_id, conversation_state, "child result")
|
||||
}
|
||||
|
||||
fn completion_run_with_detail(
|
||||
child_id: &str,
|
||||
run_id: &str,
|
||||
conversation_state: pb::ConversationStateStructure,
|
||||
detail: &str,
|
||||
) -> pb::AgentClientMessage {
|
||||
pb::AgentClientMessage {
|
||||
message: Some(pb::agent_client_message::Message::RunRequest(
|
||||
@@ -352,7 +577,7 @@ fn completion_run(
|
||||
kind: pb::BackgroundTaskKind::Subagent as i32,
|
||||
status: pb::BackgroundTaskStatus::Success as i32,
|
||||
title: "Inspect protocol".into(),
|
||||
detail: Some("child result".into()),
|
||||
detail: Some(detail.into()),
|
||||
output_path: Some("/tmp/child.jsonl".into()),
|
||||
reason: pb::BackgroundTaskCompletionReason::TaskFinished as i32,
|
||||
subagent_id: Some(child_id.into()),
|
||||
|
||||
@@ -14,6 +14,7 @@ use cursor_server::{
|
||||
provider::{FinishReason, ModelEvent},
|
||||
run::{RunEngine, RunOutcome},
|
||||
};
|
||||
use tokio::{sync::oneshot, time::Duration};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -54,6 +55,86 @@ async fn a_client_without_checkpoint_protocol_runs_the_same_text_loop() {
|
||||
assert_eq!(run.await.unwrap(), RunOutcome::Completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inserted_messages_wait_for_the_next_model_call_without_interrupting_the_active_call() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
let first_ready = provider.push_gated(vec![
|
||||
ModelEvent::Start {
|
||||
model_call_id: "call-1".into(),
|
||||
},
|
||||
ModelEvent::TextStart,
|
||||
ModelEvent::TextDelta("first answer".into()),
|
||||
ModelEvent::TextEnd,
|
||||
ModelEvent::Done(FinishReason::Stop),
|
||||
]);
|
||||
provider.push(vec![
|
||||
ModelEvent::Start {
|
||||
model_call_id: "call-2".into(),
|
||||
},
|
||||
ModelEvent::TextStart,
|
||||
ModelEvent::TextDelta("followed up".into()),
|
||||
ModelEvent::TextEnd,
|
||||
ModelEvent::Done(FinishReason::Stop),
|
||||
]);
|
||||
let prepared = prepared(&store).await;
|
||||
let (port, mut client) = session(32);
|
||||
let commands = client.commands.clone();
|
||||
let engine = RunEngine::new(store, Arc::new(provider.clone()));
|
||||
let run =
|
||||
tokio::spawn(async move { engine.run(prepared, port, CancellationToken::new()).await });
|
||||
|
||||
while provider.requests().is_empty() {
|
||||
if let Ok(Some(ClientEvent::StateCommitted(state))) =
|
||||
tokio::time::timeout(Duration::from_millis(20), client.events.recv()).await
|
||||
{
|
||||
state.barrier.complete(Ok(()));
|
||||
}
|
||||
}
|
||||
let (delivered, mut delivery) = oneshot::channel();
|
||||
commands
|
||||
.send(ClientCommand::InsertMessages(
|
||||
cursor_server::client::MessageInsertion {
|
||||
messages: vec![cursor_server::model::RuntimeEvent {
|
||||
event_id: "background:finished".into(),
|
||||
text: "background work finished".into(),
|
||||
}
|
||||
.into_message()],
|
||||
delivered,
|
||||
},
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), &mut delivery)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
first_ready.notify_one();
|
||||
while let Some(event) = client.events.recv().await {
|
||||
match event {
|
||||
ClientEvent::StateCommitted(state) => state.barrier.complete(Ok(())),
|
||||
ClientEvent::Ended(outcome) => {
|
||||
assert_eq!(outcome, RunOutcome::Completed);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(run.await.unwrap(), RunOutcome::Completed);
|
||||
delivery.await.unwrap();
|
||||
|
||||
let requests = provider.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let history = &requests[1].history;
|
||||
assert!(matches!(
|
||||
history[1].role,
|
||||
cursor_server::model::Role::Assistant
|
||||
));
|
||||
assert_eq!(history[2].message_id, "runtime:background:finished");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_claim_cannot_overwrite_the_existing_run() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
|
||||
@@ -160,7 +160,7 @@ async fn runtime_protocol_failure_returns_connect_error_end_stream_and_closes()
|
||||
)
|
||||
.unwrap();
|
||||
let registry = CursorSessionRegistry::new(
|
||||
store,
|
||||
store.clone(),
|
||||
Arc::new(provider),
|
||||
PromptCompiler::new(assets),
|
||||
Default::default(),
|
||||
@@ -249,6 +249,29 @@ async fn runtime_protocol_failure_returns_connect_error_end_stream_and_closes()
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
let (status, failure_summary) = loop {
|
||||
let row: (String, Option<String>) =
|
||||
sqlx::query_as("SELECT status, failure_summary FROM runs WHERE cursor_request_id = ?")
|
||||
.bind("protocol-failed-request")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
if row.0 != "running" {
|
||||
break row;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"Run remained running after the Cursor session failed"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
};
|
||||
assert_eq!(status, "failed");
|
||||
assert_eq!(
|
||||
failure_summary.as_deref(),
|
||||
Some("unknown ExecClientMessage id: 1001")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn generic_run_registry_cancels_the_previous_client_for_a_conversation() {
|
||||
conversation.clone(),
|
||||
cursor_server::model::RunId::new("first"),
|
||||
first.clone(),
|
||||
cursor_server::client::session(1).1.commands,
|
||||
)
|
||||
.await;
|
||||
registry
|
||||
@@ -35,6 +36,7 @@ async fn generic_run_registry_cancels_the_previous_client_for_a_conversation() {
|
||||
conversation.clone(),
|
||||
cursor_server::model::RunId::new("second"),
|
||||
second.clone(),
|
||||
cursor_server::client::session(1).1.commands,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -426,6 +428,187 @@ async fn injected_user_context_restarts_only_the_active_model_cycle() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_subagent_action_aborts_the_target_task_and_keeps_the_parent_running() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
provider.push(vec![
|
||||
ModelEvent::Start {
|
||||
model_call_id: "task-cycle".into(),
|
||||
},
|
||||
ModelEvent::ToolCallStart {
|
||||
index: 0,
|
||||
call_id: "task-call".into(),
|
||||
name: "Task".into(),
|
||||
},
|
||||
ModelEvent::ToolCallArgumentsDelta {
|
||||
index: 0,
|
||||
delta: serde_json::json!({
|
||||
"description": "Inspect protocol",
|
||||
"prompt": "Inspect the protocol",
|
||||
"subagent_type": "generalPurpose",
|
||||
"run_in_background": false
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
ModelEvent::ToolCallEnd { index: 0 },
|
||||
ModelEvent::Done(FinishReason::ToolUse),
|
||||
]);
|
||||
provider.push(vec![
|
||||
ModelEvent::Start {
|
||||
model_call_id: "continued".into(),
|
||||
},
|
||||
ModelEvent::TextStart,
|
||||
ModelEvent::TextDelta("continued after subagent cancellation".into()),
|
||||
ModelEvent::TextEnd,
|
||||
ModelEvent::Done(FinishReason::Stop),
|
||||
]);
|
||||
let assets = PromptAssets::load(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("prompt/cursor")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = CursorSessionRegistry::new(
|
||||
store,
|
||||
Arc::new(provider.clone()),
|
||||
PromptCompiler::new(assets),
|
||||
Default::default(),
|
||||
);
|
||||
let handle = registry
|
||||
.get_or_create("cancel-subagent-request")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut output = handle.subscribe();
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: 0,
|
||||
message: Box::new(client_run_for(
|
||||
"cancel-subagent-request",
|
||||
"cancel-subagent-conversation",
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut append_seqno = 1;
|
||||
let exec_id = loop {
|
||||
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("RunSSE closed before Task exec");
|
||||
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||
assert_eq!(flags & connect::END_STREAM_FLAG, 0);
|
||||
let server = pb::AgentServerMessage::decode(payload).unwrap();
|
||||
match server.message {
|
||||
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: append_seqno,
|
||||
message: Box::new(kv_ack(kv.id)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_seqno += 1;
|
||||
}
|
||||
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => {
|
||||
let Some(pb::exec_server_message::Message::SubagentArgs(args)) = exec.message
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
assert_eq!(args.tool_call_id, "task-call");
|
||||
break exec.id;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: append_seqno,
|
||||
message: Box::new(runtime_cancel_subagent("task-call")),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_seqno += 1;
|
||||
|
||||
loop {
|
||||
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("RunSSE closed before Task abort");
|
||||
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||
assert_eq!(flags & connect::END_STREAM_FLAG, 0);
|
||||
let server = pb::AgentServerMessage::decode(payload).unwrap();
|
||||
match server.message {
|
||||
Some(pb::agent_server_message::Message::ExecServerControlMessage(control)) => {
|
||||
let Some(pb::exec_server_control_message::Message::Abort(abort)) = control.message
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
assert_eq!(abort.id, exec_id);
|
||||
break;
|
||||
}
|
||||
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: append_seqno,
|
||||
message: Box::new(kv_ack(kv.id)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_seqno += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: append_seqno,
|
||||
message: Box::new(subagent_aborted(exec_id)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_seqno += 1;
|
||||
|
||||
let mut saw_continued = false;
|
||||
loop {
|
||||
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("RunSSE closed before successful EndStream");
|
||||
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||
if flags & connect::END_STREAM_FLAG != 0 {
|
||||
assert_eq!(payload.as_ref(), b"{}");
|
||||
break;
|
||||
}
|
||||
let server = pb::AgentServerMessage::decode(payload).unwrap();
|
||||
match server.message {
|
||||
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: append_seqno,
|
||||
message: Box::new(kv_ack(kv.id)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_seqno += 1;
|
||||
}
|
||||
Some(pb::agent_server_message::Message::InteractionUpdate(update)) => {
|
||||
if let Some(pb::interaction_update::Message::TextDelta(delta)) = update.message {
|
||||
saw_continued |= delta.text.contains("continued after subagent cancellation");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_continued);
|
||||
assert!(!handle.cancellation().is_cancelled());
|
||||
assert_eq!(provider.requests().len(), 2);
|
||||
}
|
||||
|
||||
fn client_run() -> pb::AgentClientMessage {
|
||||
client_run_for("cancel-request", "cancel-conversation")
|
||||
}
|
||||
@@ -541,3 +724,37 @@ fn runtime_injection() -> pb::AgentClientMessage {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_cancel_subagent(tool_call_id: &str) -> pb::AgentClientMessage {
|
||||
pb::AgentClientMessage {
|
||||
message: Some(pb::agent_client_message::Message::ConversationAction(
|
||||
pb::ConversationAction {
|
||||
action: Some(pb::conversation_action::Action::CancelSubagentAction(
|
||||
pb::CancelSubagentAction {
|
||||
subagent_id: tool_call_id.into(),
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn subagent_aborted(id: u32) -> pb::AgentClientMessage {
|
||||
pb::AgentClientMessage {
|
||||
message: Some(pb::agent_client_message::Message::ExecClientMessage(
|
||||
pb::ExecClientMessage {
|
||||
id,
|
||||
message: Some(pb::exec_client_message::Message::SubagentResult(
|
||||
pb::SubagentResult {
|
||||
result: Some(pb::subagent_result::Result::Error(pb::SubagentError {
|
||||
agent_id: None,
|
||||
error: "Subagent was aborted by the user".into(),
|
||||
})),
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,48 @@ async fn cursor_trace_links_detailed_artifacts_to_the_logical_run() {
|
||||
assert_eq!(artifacts[1].data, b"response");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cursor_trace_artifact_and_blob_are_written_atomically() {
|
||||
let (_directory, store) = test_store("cursor-trace-atomic.db").await;
|
||||
store.set_detailed_logging(true).await.unwrap();
|
||||
store
|
||||
.start_cursor_trace_if_detailed(
|
||||
"request-atomic",
|
||||
Some("conversation"),
|
||||
"cursor_official",
|
||||
Some("model"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TRIGGER reject_trace_artifact
|
||||
BEFORE INSERT ON cursor_run_trace_artifacts
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'rejected artifact');
|
||||
END",
|
||||
)
|
||||
.execute(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store
|
||||
.append_cursor_trace_artifact(
|
||||
"request-atomic",
|
||||
"run_sse_chunk",
|
||||
"cursor_official",
|
||||
b"must-rollback",
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let blob_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blobs")
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(blob_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_one_summary_and_raw_payloads_for_one_provider_request() {
|
||||
let app = Router::new().route(
|
||||
|
||||
@@ -218,3 +218,95 @@ async fn editing_a_logical_input_discards_its_active_suffix() {
|
||||
vec![original, suffix]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_reuses_only_the_matching_initial_child_chain() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let conversation_id = ConversationId::new("retry-conversation");
|
||||
let root = store.ensure_conversation(&conversation_id).await.unwrap();
|
||||
let first = prepared("first-run", &conversation_id, root);
|
||||
store.claim_run(&first).await.unwrap();
|
||||
|
||||
let context = fixtures::user("request-context:event", "context");
|
||||
let context_revision = store
|
||||
.append_revision(
|
||||
&conversation_id,
|
||||
&first.run_id,
|
||||
root,
|
||||
std::slice::from_ref(&context),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let runtime = cursor_server::model::RuntimeEvent {
|
||||
event_id: "cursor:user:stable-id:version".into(),
|
||||
text: "query".into(),
|
||||
}
|
||||
.into_message();
|
||||
|
||||
let (partial_revision, partial_count) = store
|
||||
.match_revision_prefix(&conversation_id, root, &[context.clone(), runtime.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(partial_revision, context_revision);
|
||||
assert_eq!(partial_count, 1);
|
||||
|
||||
let runtime_revision = store
|
||||
.append_revision(
|
||||
&conversation_id,
|
||||
&first.run_id,
|
||||
context_revision,
|
||||
std::slice::from_ref(&runtime),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let suffix = fixtures::user("old-answer", "old answer");
|
||||
let old_head = store
|
||||
.append_revision(
|
||||
&conversation_id,
|
||||
&first.run_id,
|
||||
runtime_revision,
|
||||
std::slice::from_ref(&suffix),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (retry_base, reused) = store
|
||||
.match_revision_prefix(&conversation_id, root, &[context.clone(), runtime.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retry_base, runtime_revision);
|
||||
assert_eq!(reused, 2);
|
||||
assert_eq!(
|
||||
store.load_revision_messages(retry_base).await.unwrap(),
|
||||
vec![context.clone(), runtime.clone()]
|
||||
);
|
||||
let retry = prepared("retry-run", &conversation_id, retry_base);
|
||||
let claimed = store.claim_run(&retry).await.unwrap();
|
||||
assert_eq!(claimed.replaced_run_id.as_ref(), Some(&first.run_id));
|
||||
let first_status: String = sqlx::query_scalar("SELECT status FROM runs WHERE run_id = ?")
|
||||
.bind(first.run_id.as_str())
|
||||
.fetch_one(store.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first_status, "cancelled");
|
||||
|
||||
let changed = cursor_server::model::RuntimeEvent {
|
||||
event_id: "cursor:user:stable-id:changed-version".into(),
|
||||
text: "edited query".into(),
|
||||
}
|
||||
.into_message();
|
||||
let (changed_base, reused) = store
|
||||
.match_revision_prefix(&conversation_id, root, &[context, changed])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(changed_base, context_revision);
|
||||
assert_eq!(reused, 1);
|
||||
assert_eq!(
|
||||
store.load_revision_messages(old_head).await.unwrap(),
|
||||
vec![
|
||||
fixtures::user("request-context:event", "context"),
|
||||
runtime,
|
||||
suffix
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,150 @@ use cursor_server::{
|
||||
};
|
||||
use prost::Message;
|
||||
|
||||
#[tokio::test]
|
||||
async fn retrying_the_same_edited_input_reuses_its_initial_branch() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
for (call, answer) in [
|
||||
("model", "answer"),
|
||||
("model-retry", "retry answer"),
|
||||
("model-edit", "edited answer"),
|
||||
("model-context-edit", "context edited answer"),
|
||||
] {
|
||||
provider.push(vec![
|
||||
ModelEvent::Start {
|
||||
model_call_id: call.into(),
|
||||
},
|
||||
ModelEvent::TextStart,
|
||||
ModelEvent::TextDelta(answer.into()),
|
||||
ModelEvent::TextEnd,
|
||||
ModelEvent::Done(FinishReason::Stop),
|
||||
]);
|
||||
}
|
||||
let assets = PromptAssets::load(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("prompt/cursor")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = CursorSessionRegistry::new(
|
||||
store.clone(),
|
||||
Arc::new(provider.clone()),
|
||||
PromptCompiler::new(assets),
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
for (request_id, text, visible_file) in [
|
||||
("edited-input", "explain this", "/workspace/src/main.rs"),
|
||||
(
|
||||
"edited-input-retry",
|
||||
"explain this",
|
||||
"/workspace/src/main.rs",
|
||||
),
|
||||
(
|
||||
"edited-input-changed",
|
||||
"explain the edited version",
|
||||
"/workspace/src/main.rs",
|
||||
),
|
||||
(
|
||||
"edited-input-context-changed",
|
||||
"explain this",
|
||||
"/workspace/src/edited.rs",
|
||||
),
|
||||
] {
|
||||
let handle = registry.get_or_create(request_id).await.unwrap();
|
||||
let mut output = handle.subscribe();
|
||||
let mut request = run_request(references(&store).await);
|
||||
let Some(pb::agent_client_message::Message::RunRequest(run)) = request.message.as_mut()
|
||||
else {
|
||||
unreachable!("run_request always returns a RunRequest")
|
||||
};
|
||||
let Some(pb::conversation_action::Action::UserMessageAction(action)) = run
|
||||
.action
|
||||
.as_mut()
|
||||
.and_then(|action| action.action.as_mut())
|
||||
else {
|
||||
unreachable!("run_request always contains a UserMessageAction")
|
||||
};
|
||||
action
|
||||
.user_message
|
||||
.as_mut()
|
||||
.expect("run_request always contains a UserMessage")
|
||||
.text = text.into();
|
||||
let user = action
|
||||
.user_message
|
||||
.as_mut()
|
||||
.expect("run_request always contains a UserMessage");
|
||||
let Some(pb::invocation_context::Data::IdeState(ide)) = user
|
||||
.selected_context
|
||||
.as_mut()
|
||||
.and_then(|selected| selected.invocation_context.as_mut())
|
||||
.and_then(|invocation| invocation.data.as_mut())
|
||||
else {
|
||||
unreachable!("run_request always contains IDE state")
|
||||
};
|
||||
ide.visible_files[0].path = visible_file.into();
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: 0,
|
||||
message: Box::new(request),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut seqno = 1;
|
||||
loop {
|
||||
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("retry must finish without closing the stream early");
|
||||
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
|
||||
if flags & connect::END_STREAM_FLAG != 0 {
|
||||
let end = serde_json::from_slice::<serde_json::Value>(&payload).unwrap();
|
||||
assert_eq!(end, serde_json::json!({}));
|
||||
break;
|
||||
}
|
||||
let message = pb::AgentServerMessage::decode(payload).unwrap();
|
||||
if let Some(pb::agent_server_message::Message::KvServerMessage(kv)) = message.message {
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno,
|
||||
message: Box::new(kv_ack(kv.id)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
seqno += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let requests = provider.requests();
|
||||
assert_eq!(requests.len(), 4);
|
||||
assert_eq!(requests[1].history, requests[0].history);
|
||||
assert_eq!(requests[2].history.len(), requests[0].history.len());
|
||||
assert_ne!(
|
||||
requests[2].history.last().unwrap().message_id,
|
||||
requests[0].history.last().unwrap().message_id
|
||||
);
|
||||
let ProjectedContent::Parts(parts) = &requests[2].history.last().unwrap().content else {
|
||||
panic!("edited runtime message must use typed parts")
|
||||
};
|
||||
let [ContentPart::Text { text }] = parts.as_slice() else {
|
||||
panic!("this fixture has no images")
|
||||
};
|
||||
assert!(text.contains("<user_query>\nexplain the edited version\n</user_query>"));
|
||||
assert_ne!(
|
||||
requests[3].history.last().unwrap().message_id,
|
||||
requests[0].history.last().unwrap().message_id
|
||||
);
|
||||
let ProjectedContent::Parts(parts) = &requests[3].history.last().unwrap().content else {
|
||||
panic!("context-edited runtime message must use typed parts")
|
||||
};
|
||||
let [ContentPart::Text { text }] = parts.as_slice() else {
|
||||
panic!("this fixture has no images")
|
||||
};
|
||||
assert!(text.contains("/workspace/src/edited.rs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_request_context_is_not_repeated_and_preserves_the_provider_prefix() {
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
@@ -115,10 +259,9 @@ async fn unchanged_request_context_is_not_repeated_and_preserves_the_provider_pr
|
||||
let [ContentPart::Text { text: context_text }] = context_parts.as_slice() else {
|
||||
panic!("request context message must contain one text part")
|
||||
};
|
||||
assert_eq!(
|
||||
request.history[1].message_id,
|
||||
"runtime:cursor:user:wire-user"
|
||||
);
|
||||
assert!(request.history[1]
|
||||
.message_id
|
||||
.starts_with("runtime:cursor:user:wire-user:"));
|
||||
assert!(!request.prompt.instructions.contains("workspace rule"));
|
||||
assert!(!request.prompt.instructions.contains("<mcp_meta_tools>"));
|
||||
let ProjectedContent::Parts(parts) = &request.history[1].content else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user