Compare commits

...
60 changed files with 55409 additions and 243 deletions
@@ -0,0 +1,82 @@
---
name: cursor-prefix-stability
description: Implement and review Cursor BYOK conversation projection with append-only provider history and prefix-cache stability. Use when changing runtime prompts, request context, canonical messages, checkpoint hydration, compaction, message identity, or provider history serialization under server.
---
# Cursor prefix stability
Preserve the longest valid provider prefix across turns. Treat provider-visible history as an append-only log unless an explicit compaction operation replaces it.
## Architecture
Keep the invariant in the provider-independent conversation layers:
```text
server/
├── prompt/cursor/*/runtime.md Per-turn runtime content
├── src/cursor/request/ Request context and runtime compilation
├── src/cursor/projection/ Canonical ↔ Cursor checkpoint projection
├── src/cursor/checkpoint/ Stable roots, turns, and hydration
├── src/run/ Provider-independent model history
└── src/provider/ Provider-specific serialization only
```
Do not solve prefix instability independently in each provider adapter. Produce one stable canonical history before dispatching to OpenAI Responses, OpenAI Chat, Anthropic, or another provider.
## Required invariants
- When no compaction occurs, the complete provider-visible history from turn N must be an exact structural prefix of turn N+1. Never edit, remove, merge, reorder, renormalize, or regenerate an earlier message.
- Keep `PromptSpec.instructions` and the stable tool prefix byte-stable when their inputs have not changed. Deterministic ordering is required; do not use unordered iteration in provider-visible output.
- Separate conversation/request context from the per-turn runtime message. The runtime message contains current-turn material such as the user query, selected context, open files, action context, mode reminders, and timestamp.
- Project rules, skills, subagents, environment/Git context, and MCP metadata as a stable `request-context:*` message:
- append it on the first applicable turn;
- do not append it again when its compiled content is identical to the latest projected request context;
- when its content changes, append a new request-context message immediately before the current runtime message;
- never represent a context change by rewriting the system prompt, replacing an earlier context message, or mutating a checkpoint root.
- Give every appended context update a unique event identity. Compare the latest context by content, not only by identifier, so `A → B → A` appends the final `A` again while retries of the same event remain idempotent.
- Preserve `request-context:*` wire identity through checkpoint encoding and hydration. Deduplication must still work after process restart or conversation resume.
- Automatic compaction is an explicit prefix reset. Compact obsolete history, retain exactly the latest request-context message, then place the summary and current initial messages in deterministic order. Manual compaction may reproject current context on the next user turn.
- Background completions and injected runtime events must not manufacture duplicate request context unless they actually start a user turn whose context changed.
## Change workflow
Before editing, trace the whole path that applies:
```text
AgentRunRequest
→ request context hydration/compilation
→ CanonicalMessage identity and persistence
→ checkpoint encode/decode
→ projected ModelRequest history
→ provider serialization
```
Determine which data is conversation-level and which is turn-level. If a proposed change moves or rewrites an earlier provider-visible value, redesign it as a new append-only event unless the operation is explicitly compaction.
Use TDD for changes in this path. Start with a failing behavioral test, then implement the smallest provider-independent change.
## Verification
Cover the affected behavior with structural assertions, not token-count estimates alone:
- Two turns with identical request context: the first request history is an exact prefix of the second, the system instructions are identical, and only one `request-context:*` message exists.
- Changed context: one new context message appears at the tail before the new runtime query; all earlier messages remain unchanged.
- Context reversion `A → B → A`: three distinct context events are retained in order.
- Retry of one runtime event: no duplicate or conflicting context message is persisted.
- Checkpoint round-trip: request-context identity and content survive encode/hydrate.
- Automatic compaction: only the latest context is retained outside the summary.
- Runtime templates render without embedding conversation-level rules or MCP metadata in every user query.
Run focused tests first, then the relevant server suites:
```bash
cargo test --lib
cargo test --test runtime_modes
cargo test --test prefix_stability
cargo test --test checkpoint_recovery
cargo test --test compaction
cargo clippy --lib -- -D warnings
cargo fmt --all -- --check
```
Do not repair unrelated dirty-worktree failures while validating. Report them separately.
+77
View File
@@ -0,0 +1,77 @@
---
name: release
description: Prepare, authorize, publish, troubleshoot, and verify Cursor BYOK desktop GitHub Releases. Use for version bumps, release tags, GitHub Actions release runs, updater manifests, signing, or release-readiness checks.
---
# Desktop release
Release through `.github/workflows/release.yml`. Preserve both updater formats: Tauri uses `latest.json`; legacy `v0.0.49` clients use `update.json`.
## Publication authority
- Only the repository author, GitHub user `leookun`, may authorize a live release.
- Before any live mutation, require an explicit release instruction from the author in the current task and verify `gh api user --jq .login` returns `leookun`.
- Treat all of these as publication actions: pushing a `v*` tag, rerunning the release workflow, and publishing or editing a GitHub Release. Pushing a release commit to `main` only prepares the release and must never trigger publication by itself.
- Without that authorization, restrict work to inspection, local edits, validation, and a release-ready commit or branch. Do not infer publication permission from requests such as “prepare”, “check”, or “ready to release”.
- Never print, commit, or upload `.tauri/cursor-byok.key` anywhere except the repository's `TAURI_SIGNING_PRIVATE_KEY` Actions Secret when the author explicitly requests that secret configuration.
- Never delete, replace, or move an existing tag or published Release without separate explicit authorization.
## Version and GitHub Release policy
- Do not use GitHub prereleases. Keep `prerelease: false` for every release and publish the completed release as Latest.
- Use `vMAJOR.MINOR.PATCH` for a stable tag, for example `v0.1.0`.
- Use standard SemVer `vMAJOR.MINOR.PATCH-beta.N` for a test tag, for example `v0.1.0-beta.1`. A beta is still a normal GitHub Release, not a GitHub prerelease. Make its title or body visibly say Beta.
- This normal-Release rule is required because both installed update clients resolve assets through GitHub's `/releases/latest/download/` path, which excludes GitHub prereleases.
- Windows beta builds must use the NSIS bundle. WiX/MSI rejects nonnumeric prerelease identifiers such as `beta.1`; do not weaken the SemVer tag to accommodate MSI.
- Release only from a `v*` tag whose commit is contained in `origin/main`. The tag must equal `v<version>` from the desktop manifests.
- Keep ordinary `main` pushes and manual workflow dispatch disabled as release triggers. The author pushes the matching tag only after the release commit is present on `origin/main`.
- Never republish an already published version. Select a new version instead.
## Release sources
Keep the desktop version identical in the manifests and their locks:
```text
cursor-byok/
├── Cargo.lock
├── apps/desktop/
│ ├── package.json
│ ├── package-lock.json
│ └── src-tauri/
│ ├── Cargo.toml
│ └── tauri.conf.json
├── scripts/cursor-proto/proto/
│ ├── agent_v1.proto
│ └── aiserver_v1.proto
└── .github/workflows/release.yml
```
The two listed Proto files are required build inputs and must be committed. Keep the other locally extracted Proto files ignored unless the build starts depending on them.
## Prepare and validate
1. Inspect `git status`, fetch `origin/main`, and preserve unrelated user changes. Confirm the release commit is based on the current remote head.
2. Choose stable or beta numbering explicitly. Update the desktop version in both manifests and lockfiles; do not change the independent `cursor-server` version merely to release the desktop app.
3. Confirm the updater public key in `tauri.conf.json` matches `.tauri/cursor-byok.key.pub` without exposing the private key.
4. Confirm `TAURI_SIGNING_PRIVATE_KEY` exists in GitHub Actions. `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` must be absent when the local key has no password.
5. Confirm neither the intended tag nor Release already exists.
6. From `apps/desktop`, run:
```bash
npm run check
npm run tauri:build -- --debug --no-bundle
```
7. Validate the workflow YAML and inspect the staged diff. Ensure `.tauri/`, unrelated local files, and unrelated user changes are not staged.
8. Use the `tauri-action@v1` input `uploadUpdaterJson: true`; `includeUpdaterJson` is not a valid v1 input.
## Publish and verify
After the author explicitly authorizes publication:
1. Commit only the reviewed release set and push it to `main`. Confirm the release commit is present in `origin/main`; this push must not start the release workflow.
2. Create the matching tag on that commit, for example `v0.1.0-beta.1`, and push only that tag. This tag push is the publication trigger.
3. Follow the triggered `Release desktop app` run through completion. Report the run URL and stop on failure; diagnose locally before asking the author to authorize another live attempt.
4. Verify `v<version>` exists, is published rather than draft, has `prerelease: false`, and is the repository's Latest release.
5. Verify the Release contains signed Tauri updater artifacts plus `latest.json`, and the legacy platform archives plus `update.json`.
6. For a beta, report clearly that it is a test version even though GitHub represents it as a normal Latest Release.
@@ -0,0 +1,4 @@
interface:
display_name: "Desktop Release"
short_description: "Prepare, authorize, publish, and verify desktop releases"
default_prompt: "Use $release to prepare and verify a Cursor BYOK desktop release."
+31 -8
View File
@@ -2,11 +2,8 @@ name: Release desktop app
on: on:
push: push:
branches:
- main
tags: tags:
- "v*" - "v*"
workflow_dispatch:
permissions: permissions:
contents: write contents: write
@@ -24,6 +21,28 @@ jobs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify release author
env:
REPOSITORY_OWNER: ${{ github.repository_owner }}
shell: bash
run: |
if [[ "${GITHUB_ACTOR}" != "${REPOSITORY_OWNER}" ]]; then
echo "Only ${REPOSITORY_OWNER} may publish a release" >&2
exit 1
fi
- name: Verify updater signing key
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
shell: bash
run: |
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY}" ]]; then
echo "TAURI_SIGNING_PRIVATE_KEY is not configured" >&2
exit 1
fi
- name: Read and verify app version - name: Read and verify app version
id: version id: version
@@ -34,8 +53,12 @@ jobs:
cargo_version=$(sed -n '/^version = / { s/version = "\([^"]*\)"/\1/p; q; }' apps/desktop/src-tauri/Cargo.toml) cargo_version=$(sed -n '/^version = / { s/version = "\([^"]*\)"/\1/p; q; }' apps/desktop/src-tauri/Cargo.toml)
test "${version}" = "${package_version}" test "${version}" = "${package_version}"
test "${version}" = "${cargo_version}" test "${version}" = "${cargo_version}"
if [[ "${GITHUB_REF_TYPE}" = "tag" ]]; then test "${GITHUB_REF_TYPE}" = "tag"
test "${GITHUB_REF_NAME}" = "v${version}" test "${GITHUB_REF_NAME}" = "v${version}"
git fetch origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
echo "Release tag must point to a commit contained in origin/main" >&2
exit 1
fi fi
echo "version=${version}" >> "${GITHUB_OUTPUT}" echo "version=${version}" >> "${GITHUB_OUTPUT}"
@@ -68,7 +91,7 @@ jobs:
target: "" target: ""
- platform: windows-x86_64 - platform: windows-x86_64
os: windows-latest os: windows-latest
args: "" args: "--bundles nsis"
target: "" target: ""
- platform: macos-aarch64 - platform: macos-aarch64
os: macos-15 os: macos-15
@@ -120,10 +143,10 @@ jobs:
projectPath: apps/desktop projectPath: apps/desktop
tagName: v__VERSION__ tagName: v__VERSION__
releaseName: Cursor BYOK v__VERSION__ releaseName: Cursor BYOK v__VERSION__
releaseBody: Download the installer for your platform from the assets below. releaseBody: ${{ contains(needs.prepare.outputs.version, '-') && 'Beta release. Download the installer for your platform from the assets below.' || 'Download the installer for your platform from the assets below.' }}
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
includeUpdaterJson: true uploadUpdaterJson: true
args: ${{ matrix.args }} args: ${{ matrix.args }}
- name: Package legacy Linux updater asset - name: Package legacy Linux updater asset
+3 -1
View File
@@ -30,4 +30,6 @@ server-go/log/
.cursor-app-formatted/ .cursor-app-formatted/
proto/extensions-cursor-app/ proto/extensions-cursor-app/
cursor-tab-server/cursor-tab-server-linux-amd64.tar cursor-tab-server/cursor-tab-server-linux-amd64.tar
/scripts/cursor-proto/proto/ /scripts/cursor-proto/proto/*
!/scripts/cursor-proto/proto/agent_v1.proto
!/scripts/cursor-proto/proto/aiserver_v1.proto
Generated
+1 -1
View File
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]] [[package]]
name = "cursor-byok-desktop" name = "cursor-byok-desktop"
version = "0.1.0" version = "0.1.0-beta.3"
dependencies = [ dependencies = [
"axum", "axum",
"cursor-server", "cursor-server",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.0", "version": "0.1.0-beta.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.0", "version": "0.1.0-beta.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.8.0", "@floating-ui/dom": "^1.8.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.0", "version": "0.1.0-beta.3",
"description": "Cursor BYOK desktop management application", "description": "Cursor BYOK desktop management application",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cursor-byok-desktop" name = "cursor-byok-desktop"
version = "0.1.0" version = "0.1.0-beta.3"
edition = "2021" edition = "2021"
publish = false publish = false
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK", "productName": "Cursor BYOK",
"version": "0.1.0", "version": "0.1.0-beta.3",
"identifier": "dev.cursorbyok.desktop", "identifier": "dev.cursorbyok.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+4 -6
View File
@@ -10,8 +10,8 @@ import { CursorSettingsPage } from "./pages/CursorSettingsPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { ProvidersPage } from "./pages/ProvidersPage"; import { ProvidersPage } from "./pages/ProvidersPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { checkForUpdate, hasNativeAppLifecycle } from "./native/appLifecycle";
import { useAppStore } from "./store/appStore"; import { useAppStore } from "./store/appStore";
import { updateStore } from "./store/updateStore";
export function App() { export function App() {
return ( return (
@@ -47,11 +47,9 @@ function AppMessages() {
}, [error, showMessage]); }, [error, showMessage]);
useEffect(() => { useEffect(() => {
if (!hasNativeAppLifecycle()) return; void updateStore.check().then((version) => {
void checkForUpdate().then(async (update) => { if (!version) return;
if (!update) return; showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
showMessage(t("发现新版本 {version},可在设置中安装", { version: update.version }), { duration: 6_000 });
await update.close();
}).catch(() => { }).catch(() => {
// Startup checks are best-effort; manual checks in Settings report errors. // Startup checks are best-effort; manual checks in Settings report errors.
}); });
+9
View File
@@ -97,6 +97,13 @@ export interface ProxySettingsInput {
password?: string; password?: string;
} }
export type TabMode = "public" | "direct" | "custom";
export interface TabSettings {
mode: TabMode;
address: string;
}
export interface OverviewMetrics { export interface OverviewMetrics {
llm_calls: number; llm_calls: number;
successful_calls: number; successful_calls: number;
@@ -283,4 +290,6 @@ export const api = {
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }), clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
proxySettings: () => request<ProxySettings>("/settings/proxy"), proxySettings: () => request<ProxySettings>("/settings/proxy"),
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }), setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
tabSettings: () => request<TabSettings>("/settings/tab"),
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
}; };
@@ -77,9 +77,10 @@ export function DailyTokenUsageChart({
const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0); const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0);
const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO); const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO);
const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO; const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO;
const averageLevel = data.length === 0 const nonZeroTotals = data.map(totalTokens).filter((total) => total !== 0);
const averageLevel = nonZeroTotals.length === 0
? 0 ? 0
: data.reduce((sum, day) => sum + totalTokens(day), 0) / data.length; : nonZeroTotals.reduce((sum, total) => sum + total, 0) / nonZeroTotals.length;
const option = useMemo<EChartsCoreOption>(() => ({ const option = useMemo<EChartsCoreOption>(() => ({
animationDuration: 450, animationDuration: 450,
@@ -1,5 +1,10 @@
@use "../../styles/typography" as type; @use "../../styles/typography" as type;
.page {
display: grid;
gap: 16px;
}
.gate { .gate {
min-height: 250px; min-height: 250px;
display: flex; display: flex;
@@ -0,0 +1,51 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.row {
min-height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 16px;
& + & {
border-top: 1px solid var(--vscode-sideBar-border);
}
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.selectControl,
.addressInput {
width: min(300px, 44%);
flex: 0 0 auto;
}
@media (max-width: 620px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.selectControl,
.addressInput {
width: 100%;
}
}
@@ -0,0 +1,53 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
settings: TabSettings;
saving: boolean;
onChange: (settings: TabSettings) => void;
onSave: () => void;
}) {
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.selectControl}>
<Select
value={settings.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
/>
</div>
</div>
{settings.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<TextInput
className={styles.addressInput}
value={settings.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onChange({ ...settings, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/>
</div>}
</TitledCard>;
}
@@ -21,3 +21,11 @@
font-size: type.$font-size-xs; font-size: type.$font-size-xs;
} }
} }
.updateDot {
width: 7px;
height: 7px;
flex: 0 0 7px;
background: #ef4444;
border-radius: 50%;
}
@@ -1,13 +1,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import type { Update } from "@tauri-apps/plugin-updater";
import { import {
checkForUpdate,
currentAppVersion, currentAppVersion,
hasNativeAppLifecycle, hasNativeAppLifecycle,
installUpdate,
readAutostart, readAutostart,
writeAutostart, writeAutostart,
} from "../../native/appLifecycle"; } from "../../native/appLifecycle";
import { updateStore, useUpdateStore } from "../../store/updateStore";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { Switch } from "../ui/Switch"; import { Switch } from "../ui/Switch";
import { TitledCard } from "../ui/TitledCard"; import { TitledCard } from "../ui/TitledCard";
@@ -17,13 +15,10 @@ import styles from "./AppLifecycleSettingsCard.module.scss";
export function AppLifecycleSettingsCard() { export function AppLifecycleSettingsCard() {
const message = useMessage(); const message = useMessage();
const native = hasNativeAppLifecycle(); const native = hasNativeAppLifecycle();
const updateRef = useRef<Update | null>(null); const { availableVersion, checking, installing } = useUpdateStore();
const [version, setVersion] = useState("…"); const [version, setVersion] = useState("…");
const [autostart, setAutostart] = useState(false); const [autostart, setAutostart] = useState(false);
const [loadingAutostart, setLoadingAutostart] = useState(native); const [loadingAutostart, setLoadingAutostart] = useState(native);
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
@@ -34,12 +29,7 @@ export function AppLifecycleSettingsCard() {
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause))) .catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
.finally(() => { if (!disposed) setLoadingAutostart(false); }); .finally(() => { if (!disposed) setLoadingAutostart(false); });
} }
return () => { return () => { disposed = true; };
disposed = true;
const update = updateRef.current;
updateRef.current = null;
if (update) void update.close();
};
}, [message, native]); }, [message, native]);
const toggleAutostart = async (enabled: boolean) => { const toggleAutostart = async (enabled: boolean) => {
@@ -57,29 +47,17 @@ export function AppLifecycleSettingsCard() {
const checkUpdate = async () => { const checkUpdate = async () => {
try { try {
setChecking(true); const nextVersion = await updateStore.check();
const previous = updateRef.current; message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
updateRef.current = null;
if (previous) await previous.close();
const update = await checkForUpdate();
updateRef.current = update;
setAvailableVersion(update?.version ?? null);
message(update ? t("发现新版本 {version}", { version: update.version }) : t("当前已是最新版本"));
} catch (cause) { } catch (cause) {
message(cause instanceof Error ? cause.message : String(cause)); message(cause instanceof Error ? cause.message : String(cause));
} finally {
setChecking(false);
} }
}; };
const updateNow = async () => { const updateNow = async () => {
const update = updateRef.current;
if (!update) return;
try { try {
setInstalling(true); await updateStore.install();
await installUpdate(update);
} catch (cause) { } catch (cause) {
setInstalling(false);
message(cause instanceof Error ? cause.message : String(cause)); message(cause instanceof Error ? cause.message : String(cause));
} }
}; };
@@ -107,6 +85,7 @@ export function AppLifecycleSettingsCard() {
{availableVersion {availableVersion
? <Button size="small" variant="primary" disabled={installing} onClick={() => void updateNow()}> ? <Button size="small" variant="primary" disabled={installing} onClick={() => void updateNow()}>
{installing ? t("安装中…") : t("下载并安装")} {installing ? t("安装中…") : t("下载并安装")}
<span className={styles.updateDot} aria-hidden="true" />
</Button> </Button>
: <Button size="small" disabled={!native || checking} onClick={() => void checkUpdate()}> : <Button size="small" disabled={!native || checking} onClick={() => void checkUpdate()}>
{checking ? t("检查中…") : t("检查更新")} {checking ? t("检查中…") : t("检查更新")}
+224 -89
View File
@@ -175,7 +175,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 156, "line": 180,
"column": 89 "column": 89
} }
] ]
@@ -278,12 +278,12 @@
"refs": [ "refs": [
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 108, "line": 109,
"column": 47 "column": 47
}, },
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 172, "line": 173,
"column": 15 "column": 15
} }
] ]
@@ -353,7 +353,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 155, "line": 179,
"column": 36 "column": 36
}, },
{ {
@@ -440,17 +440,17 @@
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 120, "line": 144,
"column": 40 "column": 40
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 120, "line": 144,
"column": 119 "column": 119
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 123, "line": 147,
"column": 63 "column": 63
} }
] ]
@@ -515,7 +515,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 79, "line": 87,
"column": 86 "column": 86
} }
] ]
@@ -527,7 +527,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 109, "line": 87,
"column": 27 "column": 27
} }
] ]
@@ -573,17 +573,17 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 98, "line": 106,
"column": 32 "column": 32
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 98, "line": 106,
"column": 119 "column": 119
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 133, "line": 157,
"column": 44 "column": 44
} }
] ]
@@ -740,8 +740,8 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 67, "line": 51,
"column": 24 "column": 29
} }
] ]
}, },
@@ -754,7 +754,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 105, "line": 83,
"column": 13 "column": 13
} }
] ]
@@ -766,7 +766,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 216, "line": 223,
"column": 14 "column": 14
} }
] ]
@@ -805,12 +805,12 @@
}, },
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 217, "line": 224,
"column": 19 "column": 19
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 133, "line": 157,
"column": 67 "column": 67
}, },
{ {
@@ -877,7 +877,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 141, "line": 165,
"column": 70 "column": 70
}, },
{ {
@@ -887,6 +887,18 @@
} }
] ]
}, },
"2f4a9609285d8f49": {
"source": "TAB 设置已保存",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 128,
"column": 15
}
]
},
"2f5f1d6fbfb061ed": { "2f5f1d6fbfb061ed": {
"source": "未设置", "source": "未设置",
"kind": "text", "kind": "text",
@@ -932,7 +944,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 133, "line": 157,
"column": 89 "column": 89
} }
] ]
@@ -944,7 +956,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 218, "line": 225,
"column": 20 "column": 20
} }
] ]
@@ -1024,12 +1036,12 @@
"refs": [ "refs": [
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 106, "line": 107,
"column": 50 "column": 50
}, },
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 154, "line": 155,
"column": 15 "column": 15
} }
] ]
@@ -1137,6 +1149,18 @@
} }
] ]
}, },
"3f6c25aa329163a4": {
"source": "原接口路径会追加到此服务地址。",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 41,
"column": 17
}
]
},
"3fd47edce45b3603": { "3fd47edce45b3603": {
"source": "关闭", "source": "关闭",
"kind": "text", "kind": "text",
@@ -1144,7 +1168,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 126, "line": 150,
"column": 71 "column": 71
} }
] ]
@@ -1168,12 +1192,12 @@
"refs": [ "refs": [
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 105, "line": 106,
"column": 46 "column": 46
}, },
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 145, "line": 146,
"column": 15 "column": 15
} }
] ]
@@ -1219,18 +1243,6 @@
} }
] ]
}, },
"4510b63fdcf7451f": {
"source": "Cursor 助手 v0.1.0",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppHeader.tsx",
"line": 20,
"column": 18
}
]
},
"470049252e54de6a": { "470049252e54de6a": {
"source": "成功占比:{rate}", "source": "成功占比:{rate}",
"kind": "template", "kind": "template",
@@ -1280,7 +1292,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 104, "line": 82,
"column": 13 "column": 13
} }
] ]
@@ -1304,7 +1316,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 71, "line": 73,
"column": 48 "column": 48
} }
] ]
@@ -1338,8 +1350,8 @@
}, },
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 197, "line": 196,
"column": 84 "column": 67
} }
] ]
}, },
@@ -1348,6 +1360,11 @@
"kind": "text", "kind": "text",
"placeholders": [], "placeholders": [],
"refs": [ "refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 32,
"column": 39
},
{ {
"file": "components/overview/OverviewTimeRangeFilter.tsx", "file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 89, "line": 89,
@@ -1389,6 +1406,18 @@
} }
] ]
}, },
"5401344227e49e2f": {
"source": "TAB 设置",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 17,
"column": 90
}
]
},
"54644705e9c61009": { "54644705e9c61009": {
"source": "端口设置", "source": "端口设置",
"kind": "text", "kind": "text",
@@ -1449,11 +1478,40 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 120, "line": 144,
"column": 52 "column": 52
} }
] ]
}, },
"59346e82b3dd2998": {
"source": "TAB 服务地址",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 40,
"column": 18
},
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 47,
"column": 21
}
]
},
"5a3bd99fa69a40c1": {
"source": "使用公益服务",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 30,
"column": 39
}
]
},
"5ae715656ffbc35d": { "5ae715656ffbc35d": {
"source": "合并到该上游所有模型的请求体。", "source": "合并到该上游所有模型的请求体。",
"kind": "text", "kind": "text",
@@ -1483,6 +1541,20 @@
} }
] ]
}, },
"5d59857bf039cac9": {
"source": "Cursor 助手 v{version}",
"kind": "template",
"placeholders": [
"version"
],
"refs": [
{
"file": "layouts/AppHeader.tsx",
"line": 31,
"column": 18
}
]
},
"5f8d556a9c47da3c": { "5f8d556a9c47da3c": {
"source": "已关闭开机启动", "source": "已关闭开机启动",
"kind": "text", "kind": "text",
@@ -1490,7 +1562,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 50, "line": 40,
"column": 40 "column": 40
} }
] ]
@@ -1502,7 +1574,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 222, "line": 229,
"column": 11 "column": 11
} }
] ]
@@ -1528,12 +1600,12 @@
"refs": [ "refs": [
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 107, "line": 108,
"column": 51 "column": 51
}, },
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 163, "line": 164,
"column": 15 "column": 15
} }
] ]
@@ -1614,7 +1686,7 @@
"refs": [ "refs": [
{ {
"file": "api.ts", "file": "api.ts",
"line": 265, "line": 272,
"column": 43 "column": 43
} }
] ]
@@ -1626,7 +1698,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 102, "line": 80,
"column": 18 "column": 18
} }
] ]
@@ -1705,7 +1777,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 74, "line": 76,
"column": 47 "column": 47
} }
] ]
@@ -1768,8 +1840,8 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 67, "line": 51,
"column": 76 "column": 78
} }
] ]
}, },
@@ -1876,7 +1948,7 @@
"refs": [ "refs": [
{ {
"file": "api.ts", "file": "api.ts",
"line": 260, "line": 267,
"column": 43 "column": 43
} }
] ]
@@ -1914,11 +1986,23 @@
"refs": [ "refs": [
{ {
"file": "App.tsx", "file": "App.tsx",
"line": 53, "line": 52,
"column": 19 "column": 19
} }
] ]
}, },
"7e0891860c9e6374": {
"source": "TAB 服务地址不能为空",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 125,
"column": 83
}
]
},
"7e1845870b528392": { "7e1845870b528392": {
"source": "启用模型", "source": "启用模型",
"kind": "text", "kind": "text",
@@ -1979,7 +2063,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 112, "line": 91,
"column": 37 "column": 37
} }
] ]
@@ -2003,7 +2087,7 @@
"refs": [ "refs": [
{ {
"file": "api.ts", "file": "api.ts",
"line": 214, "line": 221,
"column": 21 "column": 21
} }
] ]
@@ -2032,12 +2116,12 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 90, "line": 68,
"column": 18 "column": 18
}, },
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 96, "line": 74,
"column": 16 "column": 16
} }
] ]
@@ -2049,7 +2133,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 150, "line": 174,
"column": 21 "column": 21
} }
] ]
@@ -2158,11 +2242,23 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 224, "line": 231,
"column": 16 "column": 16
} }
] ]
}, },
"8f9b0d6cc477d334": {
"source": "控制 Cursor TAB 相关接口的连接方式。",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 23,
"column": 17
}
]
},
"90800c48a1dd0655": { "90800c48a1dd0655": {
"source": "{label} 必须是 JSON 对象", "source": "{label} 必须是 JSON 对象",
"kind": "template", "kind": "template",
@@ -2172,7 +2268,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 142, "line": 166,
"column": 87 "column": 87
}, },
{ {
@@ -2201,7 +2297,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 69, "line": 71,
"column": 39 "column": 39
} }
] ]
@@ -2237,7 +2333,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 134, "line": 158,
"column": 11 "column": 11
} }
] ]
@@ -2278,6 +2374,23 @@
} }
] ]
}, },
"966498853d801a52": {
"source": "TAB 选择",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 22,
"column": 18
},
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 28,
"column": 22
}
]
},
"9850ed41a5bfbb0c": { "9850ed41a5bfbb0c": {
"source": "已选 {count} 项", "source": "已选 {count} 项",
"kind": "template", "kind": "template",
@@ -2412,7 +2525,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 87, "line": 65,
"column": 29 "column": 29
} }
] ]
@@ -2434,6 +2547,11 @@
"kind": "text", "kind": "text",
"placeholders": [], "placeholders": [],
"refs": [ "refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 18,
"column": 109
},
{ {
"file": "components/settings/ProxySettingsCard.tsx", "file": "components/settings/ProxySettingsCard.tsx",
"line": 33, "line": 33,
@@ -2482,7 +2600,7 @@
"refs": [ "refs": [
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 186, "line": 187,
"column": 24 "column": 24
} }
] ]
@@ -2618,7 +2736,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 50, "line": 40,
"column": 25 "column": 25
} }
] ]
@@ -2630,7 +2748,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 109, "line": 87,
"column": 39 "column": 39
} }
] ]
@@ -2680,16 +2798,28 @@
}, },
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 237, "line": 244,
"column": 64 "column": 64
}, },
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 237, "line": 244,
"column": 125 "column": 125
} }
] ]
}, },
"b06325c5660f0c29": {
"source": "直连",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 31,
"column": 39
}
]
},
"b4411558b932266f": { "b4411558b932266f": {
"source": "上游类型", "source": "上游类型",
"kind": "text", "kind": "text",
@@ -2788,7 +2918,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 126, "line": 150,
"column": 93 "column": 93
} }
] ]
@@ -2892,7 +3022,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 229, "line": 236,
"column": 24 "column": 24
} }
] ]
@@ -3000,17 +3130,17 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 97, "line": 105,
"column": 32 "column": 32
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 97, "line": 105,
"column": 95 "column": 95
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 123, "line": 147,
"column": 51 "column": 51
} }
] ]
@@ -3198,7 +3328,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 77, "line": 85,
"column": 45 "column": 45
} }
] ]
@@ -3210,7 +3340,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 91, "line": 69,
"column": 17 "column": 17
} }
] ]
@@ -3280,7 +3410,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 180, "line": 182,
"column": 54 "column": 54
} }
] ]
@@ -3292,7 +3422,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 128, "line": 152,
"column": 18 "column": 18
} }
] ]
@@ -3328,7 +3458,7 @@
"refs": [ "refs": [
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 104, "line": 105,
"column": 14 "column": 14
} }
] ]
@@ -3465,7 +3595,7 @@
"refs": [ "refs": [
{ {
"file": "components/charts/DailyTokenUsageChart.tsx", "file": "components/charts/DailyTokenUsageChart.tsx",
"line": 134, "line": 135,
"column": 15 "column": 15
} }
] ]
@@ -3477,7 +3607,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 70, "line": 72,
"column": 44 "column": 44
} }
] ]
@@ -3542,7 +3672,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 78, "line": 86,
"column": 72 "column": 72
} }
] ]
@@ -3619,7 +3749,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 129, "line": 153,
"column": 16 "column": 16
} }
] ]
@@ -3648,7 +3778,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 126, "line": 150,
"column": 45 "column": 45
} }
] ]
@@ -3660,12 +3790,12 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 75, "line": 77,
"column": 53 "column": 53
}, },
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 122, "line": 146,
"column": 25 "column": 25
} }
] ]
@@ -3701,7 +3831,7 @@
"refs": [ "refs": [
{ {
"file": "pages/CursorSettingsPage.tsx", "file": "pages/CursorSettingsPage.tsx",
"line": 54, "line": 62,
"column": 187 "column": 187
}, },
{ {
@@ -3893,7 +4023,7 @@
"refs": [ "refs": [
{ {
"file": "components/settings/AppLifecycleSettingsCard.tsx", "file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 112, "line": 91,
"column": 25 "column": 25
} }
] ]
@@ -3985,7 +4115,7 @@
"refs": [ "refs": [
{ {
"file": "layouts/AppLayout.tsx", "file": "layouts/AppLayout.tsx",
"line": 72, "line": 74,
"column": 48 "column": 48
} }
] ]
@@ -3995,6 +4125,11 @@
"kind": "text", "kind": "text",
"placeholders": [], "placeholders": [],
"refs": [ "refs": [
{
"file": "components/cursor/TabSettingsCard.tsx",
"line": 18,
"column": 97
},
{ {
"file": "components/settings/ProxySettingsCard.tsx", "file": "components/settings/ProxySettingsCard.tsx",
"line": 33, "line": 33,
+10 -1
View File
@@ -56,6 +56,7 @@
"2eb2bf7c6597ab9a": "Detailed records", "2eb2bf7c6597ab9a": "Detailed records",
"2f1b67cdaa23351a": "Custom full request URL", "2f1b67cdaa23351a": "Custom full request URL",
"2f4a361f878176d1": "{label} must be valid JSON", "2f4a361f878176d1": "{label} must be valid JSON",
"2f4a9609285d8f49": "TAB settings saved",
"2f5f1d6fbfb061ed": "Not set", "2f5f1d6fbfb061ed": "Not set",
"2f6416a2c424856b": "Final request URL", "2f6416a2c424856b": "Final request URL",
"2f7dec3be28d7597": "{count} selected", "2f7dec3be28d7597": "{count} selected",
@@ -75,13 +76,13 @@
"3cfae5728b92b334": "Token usage: {tokens}", "3cfae5728b92b334": "Token usage: {tokens}",
"3d13868593ae4eeb": "Display language", "3d13868593ae4eeb": "Display language",
"3da0bf1610ff5db5": "Recommended", "3da0bf1610ff5db5": "Recommended",
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
"3fd47edce45b3603": "Close", "3fd47edce45b3603": "Close",
"4125fc7ba333524c": "Default light", "4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)", "42655ed8e4108ae2": "Input (non-cached)",
"42a1d9e5b037c210": "Bytes", "42a1d9e5b037c210": "Bytes",
"42aa8e01e98c0d8c": "Total duration", "42aa8e01e98c0d8c": "Total duration",
"43cb41d62de2d179": "Proxy requires authentication", "43cb41d62de2d179": "Proxy requires authentication",
"4510b63fdcf7451f": "Cursor Assistant v0.1.0",
"470049252e54de6a": "Success rate: {rate}", "470049252e54de6a": "Success rate: {rate}",
"48b970b568a7f8f9": "Proxy settings", "48b970b568a7f8f9": "Proxy settings",
"48d8db17bae06246": "{count} total", "48d8db17bae06246": "{count} total",
@@ -93,13 +94,17 @@
"4eafa9e925b30bcd": "Custom", "4eafa9e925b30bcd": "Custom",
"51d04bc3d286f018": "Last calendar day", "51d04bc3d286f018": "Last calendar day",
"5228358a6db59fe7": "For example: now, 2026-08-23 18:00", "5228358a6db59fe7": "For example: now, 2026-08-23 18:00",
"5401344227e49e2f": "TAB settings",
"54644705e9c61009": "Port settings", "54644705e9c61009": "Port settings",
"54c53e5fe791d1f3": "Initialize CA", "54c53e5fe791d1f3": "Initialize CA",
"550eddc3c7fefa99": "Sponsored", "550eddc3c7fefa99": "Sponsored",
"555737734a6371e6": "Delete provider", "555737734a6371e6": "Delete provider",
"56432ba297009bdc": "Initialize the CA first", "56432ba297009bdc": "Initialize the CA first",
"59346e82b3dd2998": "TAB service address",
"5a3bd99fa69a40c1": "Use public service",
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.", "5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
"5c55a67935af8f45": "All", "5c55a67935af8f45": "All",
"5d59857bf039cac9": "Cursor Assistant v{version}",
"5f8d556a9c47da3c": "Launch at login disabled", "5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?", "5f9acfb945229062": "Are you sure you no longer want to see this ad?",
"5fd2ec5a6e9b654c": "Total: {cost}", "5fd2ec5a6e9b654c": "Total: {cost}",
@@ -132,6 +137,7 @@
"7a3cec4ca715de80": "Call statistics", "7a3cec4ca715de80": "Call statistics",
"7ba2d6728fe2531b": "Confirm clear", "7ba2d6728fe2531b": "Confirm clear",
"7d9f043f8f7ab45c": "Version {version} is available in Settings", "7d9f043f8f7ab45c": "Version {version} is available in Settings",
"7e0891860c9e6374": "TAB service address is required",
"7e1845870b528392": "Enable model", "7e1845870b528392": "Enable model",
"7e1f06318e80c3af": "Statistics cleared", "7e1f06318e80c3af": "Statistics cleared",
"7e9ab9ada2cbf2cb": "Name and Base URL are required", "7e9ab9ada2cbf2cb": "Name and Base URL are required",
@@ -149,6 +155,7 @@
"8b6ff498515bcc2f": "Time", "8b6ff498515bcc2f": "Time",
"8d0c47eb9eac2d34": "Call type", "8d0c47eb9eac2d34": "Call type",
"8df48894086d6fbd": "Reason (optional)", "8df48894086d6fbd": "Reason (optional)",
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
"90800c48a1dd0655": "{label} must be a JSON object", "90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password", "919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview", "91aaf184cfc17ffd": "Overview",
@@ -158,6 +165,7 @@
"94803f35c825e47a": "Full request URL", "94803f35c825e47a": "Full request URL",
"94df1e7f04815daf": "Used only for display; does not change the model name sent to the provider.", "94df1e7f04815daf": "Used only for display; does not change the model name sent to the provider.",
"95f76d30c25d5eda": "CA installation is not supported on this system", "95f76d30c25d5eda": "CA installation is not supported on this system",
"966498853d801a52": "TAB connection",
"9850ed41a5bfbb0c": "{count} selected", "9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA", "997ec8201c2adeda": "Open terminal to install CA",
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.", "9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.",
@@ -186,6 +194,7 @@
"ad6a60ee93d3ba3e": "Loading call details…", "ad6a60ee93d3ba3e": "Loading call details…",
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}", "ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "Refresh", "aee88743413144a2": "Refresh",
"b06325c5660f0c29": "Direct",
"b4411558b932266f": "Provider type", "b4411558b932266f": "Provider type",
"b502b1d414664337": "Prompt: {tokens}", "b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes", "b5141d3d19e9a048": "Yes",
+10 -1
View File
@@ -56,6 +56,7 @@
"2eb2bf7c6597ab9a": "详细记录", "2eb2bf7c6597ab9a": "详细记录",
"2f1b67cdaa23351a": "自定义请求完整地址", "2f1b67cdaa23351a": "自定义请求完整地址",
"2f4a361f878176d1": "{label} 必须是有效 JSON", "2f4a361f878176d1": "{label} 必须是有效 JSON",
"2f4a9609285d8f49": "TAB 设置已保存",
"2f5f1d6fbfb061ed": "未设置", "2f5f1d6fbfb061ed": "未设置",
"2f6416a2c424856b": "最终请求地址", "2f6416a2c424856b": "最终请求地址",
"2f7dec3be28d7597": "已选择 {count} 个", "2f7dec3be28d7597": "已选择 {count} 个",
@@ -75,13 +76,13 @@
"3cfae5728b92b334": "Token 用量:{tokens}", "3cfae5728b92b334": "Token 用量:{tokens}",
"3d13868593ae4eeb": "界面语言", "3d13868593ae4eeb": "界面语言",
"3da0bf1610ff5db5": "推荐内容", "3da0bf1610ff5db5": "推荐内容",
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd47edce45b3603": "关闭", "3fd47edce45b3603": "关闭",
"4125fc7ba333524c": "默认亮色", "4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)", "42655ed8e4108ae2": "输入(非缓存)",
"42a1d9e5b037c210": "字节数", "42a1d9e5b037c210": "字节数",
"42aa8e01e98c0d8c": "总耗时", "42aa8e01e98c0d8c": "总耗时",
"43cb41d62de2d179": "代理需要认证", "43cb41d62de2d179": "代理需要认证",
"4510b63fdcf7451f": "Cursor 助手 v0.1.0",
"470049252e54de6a": "成功占比:{rate}", "470049252e54de6a": "成功占比:{rate}",
"48b970b568a7f8f9": "代理设置", "48b970b568a7f8f9": "代理设置",
"48d8db17bae06246": "共 {count} 条", "48d8db17bae06246": "共 {count} 条",
@@ -93,13 +94,17 @@
"4eafa9e925b30bcd": "自定义", "4eafa9e925b30bcd": "自定义",
"51d04bc3d286f018": "近1自然日", "51d04bc3d286f018": "近1自然日",
"5228358a6db59fe7": "如:现在、2026-08-23 18:00", "5228358a6db59fe7": "如:现在、2026-08-23 18:00",
"5401344227e49e2f": "TAB 设置",
"54644705e9c61009": "端口设置", "54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA", "54c53e5fe791d1f3": "初始化 CA",
"550eddc3c7fefa99": "推广", "550eddc3c7fefa99": "推广",
"555737734a6371e6": "删除上游", "555737734a6371e6": "删除上游",
"56432ba297009bdc": "请先初始化 CA", "56432ba297009bdc": "请先初始化 CA",
"59346e82b3dd2998": "TAB 服务地址",
"5a3bd99fa69a40c1": "使用公益服务",
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。", "5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
"5c55a67935af8f45": "全部", "5c55a67935af8f45": "全部",
"5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动", "5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?", "5f9acfb945229062": "你确认不想再看到此广告吗?",
"5fd2ec5a6e9b654c": "合计:{cost}", "5fd2ec5a6e9b654c": "合计:{cost}",
@@ -132,6 +137,7 @@
"7a3cec4ca715de80": "调用统计", "7a3cec4ca715de80": "调用统计",
"7ba2d6728fe2531b": "确认清理", "7ba2d6728fe2531b": "确认清理",
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装", "7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
"7e0891860c9e6374": "TAB 服务地址不能为空",
"7e1845870b528392": "启用模型", "7e1845870b528392": "启用模型",
"7e1f06318e80c3af": "统计数据已清理", "7e1f06318e80c3af": "统计数据已清理",
"7e9ab9ada2cbf2cb": "名称和 Base URL 不能为空", "7e9ab9ada2cbf2cb": "名称和 Base URL 不能为空",
@@ -149,6 +155,7 @@
"8b6ff498515bcc2f": "时间", "8b6ff498515bcc2f": "时间",
"8d0c47eb9eac2d34": "调用类型", "8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)", "8df48894086d6fbd": "原因(可选)",
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
"90800c48a1dd0655": "{label} 必须是 JSON 对象", "90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码", "919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览", "91aaf184cfc17ffd": "数据概览",
@@ -158,6 +165,7 @@
"94803f35c825e47a": "请求完整地址", "94803f35c825e47a": "请求完整地址",
"94df1e7f04815daf": "仅用于界面展示,不会改变发送给上游的模型名称。", "94df1e7f04815daf": "仅用于界面展示,不会改变发送给上游的模型名称。",
"95f76d30c25d5eda": "当前系统暂不支持安装 CA", "95f76d30c25d5eda": "当前系统暂不支持安装 CA",
"966498853d801a52": "TAB 选择",
"9850ed41a5bfbb0c": "已选 {count} 项", "9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA", "997ec8201c2adeda": "打开终端安装 CA",
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。", "9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
@@ -186,6 +194,7 @@
"ad6a60ee93d3ba3e": "正在加载调用详情…", "ad6a60ee93d3ba3e": "正在加载调用详情…",
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}", "ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "刷新", "aee88743413144a2": "刷新",
"b06325c5660f0c29": "直连",
"b4411558b932266f": "上游类型", "b4411558b932266f": "上游类型",
"b502b1d414664337": "提示词:{tokens}", "b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是", "b5141d3d19e9a048": "是",
+12 -1
View File
@@ -1,4 +1,6 @@
import { useEffect, useState } from "react";
import appIcon from "../../src-tauri/icons/32x32.png"; import appIcon from "../../src-tauri/icons/32x32.png";
import { currentAppVersion } from "../native/appLifecycle";
import type { DesktopPlatform } from "./AppFrame"; import type { DesktopPlatform } from "./AppFrame";
import { WindowControls } from "./WindowControls"; import { WindowControls } from "./WindowControls";
import styles from "./AppHeader.module.scss"; import styles from "./AppHeader.module.scss";
@@ -10,6 +12,15 @@ type AppHeaderProps = {
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) { export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
const showNativeUi = nativeDesktop && platform !== "macos"; const showNativeUi = nativeDesktop && platform !== "macos";
const [version, setVersion] = useState("…");
useEffect(() => {
let disposed = false;
void currentAppVersion().then((next) => {
if (!disposed) setVersion(next);
});
return () => { disposed = true; };
}, []);
return <header className={styles.root}> return <header className={styles.root}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" /> <div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
@@ -17,7 +28,7 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
{showNativeUi && <> {showNativeUi && <>
<div className={styles.identity} aria-label="Cursor BYOK"> <div className={styles.identity} aria-label="Cursor BYOK">
<img src={appIcon} alt="" /> <img src={appIcon} alt="" />
<span>{t("Cursor 助手 v0.1.0")}</span> <span>{t("Cursor 助手 v{version}", { version })}</span>
</div> </div>
<WindowControls /> <WindowControls />
</>} </>}
@@ -83,7 +83,7 @@
} }
} }
.menuUnreadDot { .menuIndicatorDot {
width: 7px; width: 7px;
height: 7px; height: 7px;
flex: 0 0 7px; flex: 0 0 7px;
+4 -1
View File
@@ -18,6 +18,7 @@ import { useMessage } from "../components/ui/message";
import { VirtualList } from "../components/virtual/VirtualList"; import { VirtualList } from "../components/virtual/VirtualList";
import { useI18n } from "../i18n/store"; import { useI18n } from "../i18n/store";
import { appStore, useAppStore } from "../store/appStore"; import { appStore, useAppStore } from "../store/appStore";
import { useUpdateStore } from "../store/updateStore";
import styles from "./AppLayout.module.scss"; import styles from "./AppLayout.module.scss";
import { PageActionsTarget } from "./PageActions"; import { PageActionsTarget } from "./PageActions";
@@ -43,6 +44,7 @@ function loadStoredAdIds(key: string): Set<string> {
export function AppLayout() { export function AppLayout() {
const { busy } = useAppStore(); const { busy } = useAppStore();
const { availableVersion } = useUpdateStore();
const { locale } = useI18n(); const { locale } = useI18n();
const message = useMessage(); const message = useMessage();
const location = useLocation(); const location = useLocation();
@@ -198,7 +200,7 @@ export function AppLayout() {
? <Icon src={item.icon} size="1.3em" /> ? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />} : <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span> <span>{item.label}</span>
{!tutorialRead && <span className={styles.menuUnreadDot} aria-hidden="true" />} {!tutorialRead && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
</button> </button>
</div> </div>
: <div className={styles.navigationRow} key={item.path}> : <div className={styles.navigationRow} key={item.path}>
@@ -207,6 +209,7 @@ export function AppLayout() {
? <Icon src={item.icon} size="1.3em" /> ? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />} : <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span> <span>{item.label}</span>
{item.path === "/settings" && availableVersion && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
</NavLink> </NavLink>
</div>} </div>}
</VirtualList> </VirtualList>
+27 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api, type Model, type ProviderSelection } from "../api"; import { api, type Model, type ProviderSelection, type TabSettings } from "../api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates"; import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor"; import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
import { TabSettingsCard } from "../components/cursor/TabSettingsCard";
import styles from "../components/cursor/CursorSettings.module.scss"; import styles from "../components/cursor/CursorSettings.module.scss";
import { PageContent } from "../components/layout/PageContent"; import { PageContent } from "../components/layout/PageContent";
import controls from "../components/ui/Controls.module.scss"; import controls from "../components/ui/Controls.module.scss";
@@ -24,12 +25,19 @@ export function CursorSettingsPage() {
const [caCommand, setCaCommand] = useState<string | null>(null); const [caCommand, setCaCommand] = useState<string | null>(null);
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false); const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
const [deleting, setDeleting] = useState<Model | null>(null); const [deleting, setDeleting] = useState<Model | null>(null);
const [tabDraft, setTabDraft] = useState<TabSettings | null>(null);
const [savingTab, setSavingTab] = useState(false);
const grouped = useMemo(() => providers.map((provider) => ({ provider, models: models.filter((model) => model.provider_id === provider.provider_id) })).filter((group) => group.models.length > 0), [providers, models]); const grouped = useMemo(() => providers.map((provider) => ({ provider, models: models.filter((model) => model.provider_id === provider.provider_id) })).filter((group) => group.models.length > 0), [providers, models]);
const caReady = cursorHarness?.ca === "ready"; const caReady = cursorHarness?.ca === "ready";
useEffect(() => { useEffect(() => {
if (!caCommand) return; if (!caCommand) return;
void api.copyCursorText(caCommand); void api.copyCursorText(caCommand);
}, [caCommand]); }, [caCommand]);
useEffect(() => {
void api.tabSettings()
.then(setTabDraft)
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]);
const initializeCa = async () => { const initializeCa = async () => {
const status = await appStore.initializeCursorCa(); const status = await appStore.initializeCursorCa();
if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command); if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command);
@@ -111,15 +119,31 @@ export function CursorSettingsPage() {
setCaCommand(null); setCaCommand(null);
setWaitingForCaRefresh(true); setWaitingForCaRefresh(true);
}; };
const saveTab = async () => {
if (!tabDraft) return;
try {
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
setSavingTab(true);
setTabDraft(await api.setTabSettings(tabDraft));
message(t("TAB 设置已保存"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSavingTab(false);
}
};
const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}> const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}>
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider> <div className={styles.page}>
{tabDraft && <TabSettingsCard settings={tabDraft} saving={savingTab} onChange={setTabDraft} onSave={() => void saveTab()} />}
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider>
</div>
</CursorCaGate></CursorCaProvider>; </CursorCaGate></CursorCaProvider>;
return <> return <>
<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> <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> </PageActions>
<PageContent title={t("Cursor 设置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(280, models.length * 55 + grouped.length * 62), content }]} /> <PageContent title={t("Cursor 设置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(430, models.length * 55 + grouped.length * 62 + 145), content }]} />
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} busy={cursorBusy} onClose={() => setDraft(null)} onSubmit={() => void save()}> <Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} busy={cursorBusy} onClose={() => setDraft(null)} onSubmit={() => void save()}>
{draft && <CursorModelEditor draft={draft} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />} {draft && <CursorModelEditor draft={draft} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />}
</Modal> </Modal>
+74
View File
@@ -0,0 +1,74 @@
import { useSyncExternalStore } from "react";
import type { Update } from "@tauri-apps/plugin-updater";
import {
checkForUpdate,
hasNativeAppLifecycle,
installUpdate,
} from "../native/appLifecycle";
export type UpdateSnapshot = {
availableVersion: string | null;
checking: boolean;
installing: boolean;
};
let snapshot: UpdateSnapshot = {
availableVersion: null,
checking: false,
installing: false,
};
let availableUpdate: Update | null = null;
let pendingCheck: Promise<string | null> | null = null;
const listeners = new Set<() => void>();
function update(patch: Partial<UpdateSnapshot>) {
snapshot = { ...snapshot, ...patch };
listeners.forEach((listener) => listener());
}
async function replaceAvailableUpdate(next: Update | null) {
const previous = availableUpdate;
availableUpdate = next;
update({ availableVersion: next?.version ?? null });
if (previous && previous !== next) await previous.close();
}
export const updateStore = {
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot: () => snapshot,
async check(): Promise<string | null> {
if (!hasNativeAppLifecycle()) return null;
if (pendingCheck) return pendingCheck;
update({ checking: true });
pendingCheck = (async () => {
const next = await checkForUpdate();
await replaceAvailableUpdate(next);
return next?.version ?? null;
})();
try {
return await pendingCheck;
} finally {
pendingCheck = null;
update({ checking: false });
}
},
async install(): Promise<void> {
const current = availableUpdate;
if (!current) return;
update({ installing: true });
try {
await installUpdate(current);
} finally {
update({ installing: false });
}
},
};
export function useUpdateStore(): UpdateSnapshot {
return useSyncExternalStore(updateStore.subscribe, updateStore.getSnapshot);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder> {{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Agent mode. You have EXITED your previous mode. Continue with the task in the new mode. You are now in Agent mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder> </system_reminder>
<system_reminder> <system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder> {{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Ask mode. You have EXITED your previous mode. Continue with the task in the new mode. You are now in Ask mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder> </system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<timestamp>{{TIMESTAMP}}</timestamp> {{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<timestamp>{{TIMESTAMP}}</timestamp>
<user_query> <user_query>
{{USER_QUERY}} {{USER_QUERY}}
</user_query> </user_query>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder> {{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Debug mode. You have EXITED your previous mode. Continue with the task in the new mode. You are now in Debug mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder> </system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder> {{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Multitask mode. You have EXITED your previous mode. Continue with the task in the new mode. You are now in Multitask mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder> </system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder> {{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are now in Plan mode. You have EXITED your previous mode. Continue with the task in the new mode. You are now in Plan mode. You have EXITED your previous mode. Continue with the task in the new mode.
</system_reminder> </system_reminder>
+1 -1
View File
@@ -1,4 +1,4 @@
{{REQUEST_CONTEXT}}{{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder> {{OPEN_FILES}}{{SELECTED_CONTEXT}}{{ACTION_CONTEXT}}<system_reminder>
You are currently working inside a Task subagent. Your parent agent has delegated a clearly bounded assignment to you. Complete that assignment directly with the tools available in this session. The Task tool is unavailable inside subagents, so delegation cannot be nested. You are currently working inside a Task subagent. Your parent agent has delegated a clearly bounded assignment to you. Complete that assignment directly with the tools available in this session. The Task tool is unavailable inside subagents, so delegation cannot be nested.
</system_reminder> </system_reminder>
<timestamp>{{TIMESTAMP}}</timestamp> <timestamp>{{TIMESTAMP}}</timestamp>
+4
View File
@@ -155,6 +155,10 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/settings/proxy", "/__byok-api__/api/settings/proxy",
get(settings::get_proxy).put(settings::update_proxy), get(settings::get_proxy).put(settings::update_proxy),
) )
.route(
"/__byok-api__/api/settings/tab",
get(settings::get_tab).put(settings::update_tab),
)
.route( .route(
"/__byok-api__/api/harness/cursor/status", "/__byok-api__/api/harness/cursor/status",
get(harness::status), get(harness::status),
+11 -1
View File
@@ -17,7 +17,9 @@ use crate::{
LlmCallSummary, Overview, ProviderEndpoint, ProviderEndpointInput, ProviderEndpointSecret, LlmCallSummary, Overview, ProviderEndpoint, ProviderEndpointInput, ProviderEndpointSecret,
ProviderModel, ProviderModelInput, ProviderType, ProviderModel, ProviderModelInput, ProviderType,
}, },
store::{PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store}, store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, TabSettings,
},
Error, Result, Error, Result,
}; };
@@ -372,6 +374,14 @@ impl ControlService {
pub async fn set_proxy_settings(&self, settings: ProxySettingsInput) -> Result<ProxySettings> { pub async fn set_proxy_settings(&self, settings: ProxySettingsInput) -> Result<ProxySettings> {
self.store.set_proxy_settings(settings).await self.store.set_proxy_settings(settings).await
} }
pub async fn tab_settings(&self) -> Result<TabSettings> {
self.store.tab_settings().await
}
pub async fn set_tab_settings(&self, settings: TabSettings) -> Result<TabSettings> {
self.cursor_harness.set_tab_settings(settings).await
}
} }
fn official_call(trace: CursorRunTraceSummary) -> CallSummary { fn official_call(trace: CursorRunTraceSummary) -> CallSummary {
+14 -1
View File
@@ -1,7 +1,9 @@
use crate::Result; use crate::Result;
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use crate::store::{PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage}; use crate::store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, TabSettings,
};
use super::{ControlService, ObservabilitySettings}; use super::{ControlService, ObservabilitySettings};
@@ -47,3 +49,14 @@ pub async fn update_proxy(
) -> Result<Json<ProxySettings>> { ) -> Result<Json<ProxySettings>> {
Ok(Json(service.set_proxy_settings(settings).await?)) Ok(Json(service.set_proxy_settings(settings).await?))
} }
pub async fn get_tab(State(service): State<ControlService>) -> Result<Json<TabSettings>> {
Ok(Json(service.tab_settings().await?))
}
pub async fn update_tab(
State(service): State<ControlService>,
Json(settings): Json<TabSettings>,
) -> Result<Json<TabSettings>> {
Ok(Json(service.set_tab_settings(settings).await?))
}
+4
View File
@@ -8,6 +8,7 @@ use crate::{
blob_sync::BlobSynchronizer, blob_sync::BlobSynchronizer,
checkpoint::CheckpointBuilder, checkpoint::CheckpointBuilder,
context_sync::RequestContextSynchronizer, context_sync::RequestContextSynchronizer,
interaction,
proto::agent::v1 as pb, proto::agent::v1 as pb,
request, request,
session::CursorSession, session::CursorSession,
@@ -272,6 +273,9 @@ impl CursorActor {
)) => { )) => {
let _ = blob_sync.handle_client(message).await; let _ = blob_sync.handle_client(message).await;
} }
Some(pb::agent_client_message::Message::ClientHeartbeat(_)) => {
let _ = handle.emit(&interaction::heartbeat());
}
// TODO: ConversationAction has two different delivery paths that // TODO: ConversationAction has two different delivery paths that
// must not be conflated: // must not be conflated:
// //
+2 -1
View File
@@ -13,7 +13,7 @@ use crate::{
observability::CursorTraceRecorder, observability::CursorTraceRecorder,
proto::{agent::v1 as agent, aiserver::v1 as ai}, proto::{agent::v1 as agent, aiserver::v1 as ai},
proxy::{self, CursorProxy}, proxy::{self, CursorProxy},
run_sse, run_sse, tab,
}, },
cursor::{CursorParent, CursorSessionRegistry}, cursor::{CursorParent, CursorSessionRegistry},
Result, Result,
@@ -70,6 +70,7 @@ fn router_with_proxy(registry: CursorSessionRegistry, proxy: CursorProxy) -> Rou
post(analytics::bootstrap_statsig), post(analytics::bootstrap_statsig),
) )
.route("/auth/full_stripe_profile", get(account::stripe_profile)) .route("/auth/full_stripe_profile", get(account::stripe_profile))
.merge(tab::router())
.route_layer(DefaultBodyLimit::disable()) .route_layer(DefaultBodyLimit::disable())
.route_layer(RequestDecompressionLayer::new()) .route_layer(RequestDecompressionLayer::new())
.fallback(proxy::forward) .fallback(proxy::forward)
+6
View File
@@ -66,6 +66,12 @@ pub fn thinking_completed(elapsed: Duration) -> pb::AgentServerMessage {
)) ))
} }
pub fn heartbeat() -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::Heartbeat(
pb::HeartbeatUpdate {},
))
}
pub fn arguments_delta(call: &ToolCall, delta: &str) -> Result<pb::AgentServerMessage> { pub fn arguments_delta(call: &ToolCall, delta: &str) -> Result<pb::AgentServerMessage> {
Ok(server_interaction( Ok(server_interaction(
pb::interaction_update::Message::PartialToolCall(pb::PartialToolCallUpdate { pb::interaction_update::Message::PartialToolCall(pb::PartialToolCallUpdate {
+1
View File
@@ -22,6 +22,7 @@ pub mod request;
pub mod run_sse; pub mod run_sse;
pub mod session; pub mod session;
pub mod sessions; pub mod sessions;
pub(crate) mod tab;
pub mod tools; pub mod tools;
mod usage; mod usage;
+5 -7
View File
@@ -30,17 +30,15 @@ pub fn decode(data: &[u8], internal_id: String) -> Result<CanonicalMessage> {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
let is_request_context = role == Role::User && wire_id.starts_with("request-context:");
let is_prompt_context =
is_request_context || role == Role::User && wire_id.starts_with("selected-context:");
let origin = match role { let origin = match role {
Role::System => Origin::Prompt, Role::System => Origin::Prompt,
Role::Assistant => Origin::Assistant, Role::Assistant => Origin::Assistant,
Role::Tool => Origin::Tool, Role::Tool => Origin::Tool,
Role::User if wire_id.starts_with("runtime:") => Origin::Runtime, Role::User if wire_id.starts_with("runtime:") => Origin::Runtime,
Role::User Role::User if is_prompt_context => Origin::Prompt,
if wire_id.starts_with("request-context:")
|| wire_id.starts_with("selected-context:") =>
{
Origin::Prompt
}
Role::User => Origin::User, Role::User => Origin::User,
}; };
let runtime_event_id = wire_id.strip_prefix("runtime:").map(str::to_string); let runtime_event_id = wire_id.strip_prefix("runtime:").map(str::to_string);
@@ -49,7 +47,7 @@ pub fn decode(data: &[u8], internal_id: String) -> Result<CanonicalMessage> {
Role::Tool => MessageContent::ToolResult(decode_tool_result(&value)?), Role::Tool => MessageContent::ToolResult(decode_tool_result(&value)?),
_ => decode_text(&value)?, _ => decode_text(&value)?,
}; };
let message_id = if runtime_event_id.is_some() { let message_id = if runtime_event_id.is_some() || is_request_context {
wire_id wire_id
} else { } else {
internal_id internal_id
+17
View File
@@ -131,6 +131,23 @@ fn runtime_wire_identity_survives_checkpoint_hydration() {
); );
} }
#[test]
fn request_context_identity_survives_checkpoint_hydration() {
let wire = json!({
"role": "user",
"id": "request-context:digest",
"content": "<rules>current rules</rules>",
});
let message = decode(
serde_json::to_vec(&wire).unwrap().as_slice(),
"cursor-root:blob-id:20".into(),
)
.unwrap();
assert_eq!(message.message_id, "request-context:digest");
assert_eq!(message.origin, crate::model::Origin::Prompt);
}
#[test] #[test]
fn cursor_user_image_uses_image_field() { fn cursor_user_image_uses_image_field() {
let wire = json!({ let wire = json!({
-1
View File
@@ -134,7 +134,6 @@ impl PromptAssets {
} }
const RUNTIME_VARIABLES: &[&str] = &[ const RUNTIME_VARIABLES: &[&str] = &[
"REQUEST_CONTEXT",
"OPEN_FILES", "OPEN_FILES",
"SELECTED_CONTEXT", "SELECTED_CONTEXT",
"ACTION_CONTEXT", "ACTION_CONTEXT",
+53 -3
View File
@@ -82,14 +82,34 @@ impl CursorProxy {
pub async fn forward( pub async fn forward(
Extension(proxy): Extension<CursorProxy>, Extension(proxy): Extension<CursorProxy>,
request: Request<Body>, request: Request<Body>,
) -> Result<Response<Body>> {
forward_request(&proxy, request, None).await
}
pub(crate) async fn forward_to_service(
proxy: &CursorProxy,
request: Request<Body>,
service_url: &str,
) -> Result<Response<Body>> {
forward_request(proxy, request, Some(service_url)).await
}
async fn forward_request(
proxy: &CursorProxy,
request: Request<Body>,
service_url: Option<&str>,
) -> Result<Response<Body>> { ) -> Result<Response<Body>> {
let started = Instant::now(); let started = Instant::now();
let (parts, body) = request.into_parts(); let (parts, body) = request.into_parts();
let path = parts let path = parts
.uri .uri
.path_and_query() .path_and_query()
.map_or("/", |value| value.as_str()); .map_or("/", |value| value.as_str())
let url = upstream_url(&parts.headers, &proxy.upstream, path)?; .to_owned();
let url = match service_url {
Some(service_url) => format!("{}{}", service_url.trim_end_matches('/'), path),
None => upstream_url(&parts.headers, &proxy.upstream, &path)?,
};
let mut headers = parts.headers; let mut headers = parts.headers;
headers.remove(UPSTREAM_URL_HEADER); headers.remove(UPSTREAM_URL_HEADER);
@@ -239,7 +259,7 @@ mod tests {
}; };
use tower::ServiceExt; use tower::ServiceExt;
use super::{forward, CursorProxy}; use super::{forward, forward_to_service, CursorProxy};
#[tokio::test] #[tokio::test]
async fn preserves_request_and_response() { async fn preserves_request_and_response() {
@@ -289,4 +309,34 @@ mod tests {
); );
server.abort(); server.abort();
} }
#[tokio::test]
async fn tab_service_keeps_its_base_path_and_the_original_query() {
let upstream = Router::new().route(
"/base/aiserver.v1.AiService/StreamCpp",
any(|request: Request<Body>| async move {
request.uri().path_and_query().unwrap().as_str().to_owned()
}),
);
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, upstream).await.unwrap() });
let proxy = CursorProxy::for_upstream("http://unused.invalid").unwrap();
let response = forward_to_service(
&proxy,
Request::post("/aiserver.v1.AiService/StreamCpp?client=cursor")
.body(Body::empty())
.unwrap(),
&format!("http://{address}/base"),
)
.await
.unwrap();
assert_eq!(
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
"/base/aiserver.v1.AiService/StreamCpp?client=cursor"
);
server.abort();
}
} }
+35 -15
View File
@@ -192,6 +192,20 @@ pub(crate) async fn prepare(
} }
_ => None, _ => None,
}; };
let request_context_message = match event_id.as_deref() {
Some(event_id) if !compacting && !background_completion => {
let message_id = format!("request-context:{event_id}");
match store.message(&conversation_id, &message_id).await? {
Some(message) => Some(message),
None => runtime::compile_request_context(
event_id,
&request_context,
base_messages.as_deref().unwrap_or_default(),
)?,
}
}
_ => None,
};
let initial_messages = if compacting { let initial_messages = if compacting {
Vec::new() Vec::new()
} else { } else {
@@ -209,21 +223,27 @@ pub(crate) async fn prepare(
turn_user = Some(user); turn_user = Some(user);
vec![message] vec![message]
} }
(Some(user), Some(event_id)) => match existing_runtime { (Some(user), Some(event_id)) => {
Some(message) => vec![message], let runtime = match existing_runtime {
None => vec![ Some(message) => message,
runtime::compile( None => {
event_id, runtime::compile(
checkpoint_mode, event_id,
&user, checkpoint_mode,
&request_context, &user,
&action_context, &request_context,
compiler, &action_context,
blob_sync, compiler,
) blob_sync,
.await?, )
], .await?
}, }
};
request_context_message
.into_iter()
.chain(std::iter::once(runtime))
.collect()
}
(None, None) => Vec::new(), (None, None) => Vec::new(),
_ => { _ => {
return Err(Error::Protocol( return Err(Error::Protocol(
+87 -4
View File
@@ -87,10 +87,6 @@ pub async fn compile(
.map(|env| env.time_zone.as_str()), .map(|env| env.time_zone.as_str()),
)?; )?;
let mut values = BTreeMap::from([ let mut values = BTreeMap::from([
(
"REQUEST_CONTEXT",
section(context::compile_context(request_context, &time.today)),
),
("OPEN_FILES", section(open_files(user))), ("OPEN_FILES", section(open_files(user))),
( (
"SELECTED_CONTEXT", "SELECTED_CONTEXT",
@@ -122,6 +118,41 @@ pub async fn compile(
.await .await
} }
pub(super) fn compile_request_context(
event_id: &str,
request_context: &pb::RequestContext,
history: &[CanonicalMessage],
) -> Result<Option<CanonicalMessage>> {
let time = Time::now(
request_context
.env
.as_ref()
.map(|env| env.time_zone.as_str()),
)?;
let text = context::compile_context(request_context, &time.today);
if text.is_empty() {
return Ok(None);
}
let message = CanonicalMessage::text(
format!("request-context:{event_id}"),
Role::User,
Origin::Prompt,
text,
);
Ok(should_project_request_context(history, &message).then_some(message))
}
fn should_project_request_context(
history: &[CanonicalMessage],
current: &CanonicalMessage,
) -> bool {
history
.iter()
.rev()
.find(|message| message.message_id.starts_with("request-context:"))
.is_none_or(|previous| previous.content != current.content)
}
pub async fn compile_background( pub async fn compile_background(
event_id: String, event_id: String,
user: &pb::UserMessage, user: &pb::UserMessage,
@@ -252,3 +283,55 @@ impl Time {
}) })
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_context_is_only_projected_when_its_content_changes() {
let first = CanonicalMessage::text(
"request-context:first",
Role::User,
Origin::Prompt,
"<rules>same</rules>",
);
let duplicate = CanonicalMessage::text(
"request-context:second",
Role::User,
Origin::Prompt,
"<rules>same</rules>",
);
let changed = CanonicalMessage::text(
"request-context:third",
Role::User,
Origin::Prompt,
"<rules>changed</rules>",
);
let runtime = CanonicalMessage::text(
"runtime:turn",
Role::User,
Origin::Runtime,
"<user_query>next</user_query>",
);
assert!(should_project_request_context(&[], &first));
assert!(!should_project_request_context(
&[first.clone(), runtime.clone()],
&duplicate
));
assert!(should_project_request_context(
&[first.clone(), runtime.clone()],
&changed
));
assert!(should_project_request_context(
&[first.clone(), changed, runtime],
&CanonicalMessage::text(
"request-context:fourth",
Role::User,
Origin::Prompt,
"<rules>same</rules>",
)
));
}
}
+102 -11
View File
@@ -3,29 +3,24 @@ use axum::{
http::{header, HeaderValue, Response, StatusCode}, http::{header, HeaderValue, Response, StatusCode},
}; };
use bytes::Bytes; use bytes::Bytes;
use std::convert::Infallible;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tokio_util::sync::CancellationToken;
use crate::{ use crate::{
cursor::{observability::CursorTraceRecorder, CursorSessionRegistry}, cursor::{connect::END_STREAM_FLAG, observability::CursorTraceRecorder, CursorSessionRegistry},
Result, Result,
}; };
pub async fn stream(registry: &CursorSessionRegistry, request_id: &str) -> Result<Response<Body>> { pub async fn stream(registry: &CursorSessionRegistry, request_id: &str) -> Result<Response<Body>> {
let handle = registry.get_or_create(request_id).await?; let handle = registry.get_or_create(request_id).await?;
let mut receiver = handle.subscribe(); let receiver = handle.subscribe();
let trace = handle.trace().cloned(); let trace = handle.trace().cloned();
if let Some(trace) = &trace { if let Some(trace) = &trace {
trace.response_started(StatusCode::OK.as_u16()).await; trace.response_started(StatusCode::OK.as_u16()).await;
} }
let body_stream = async_stream::stream! { let body_stream = local_body_stream(receiver, handle.cancellation(), trace);
let mut trace = TraceStreamSink::new(trace, "byok_server");
while let Some(chunk) = receiver.recv().await {
trace.chunk(&chunk);
yield Ok::<Bytes, std::convert::Infallible>(chunk);
}
trace.finish(None);
};
let mut response = Response::new(Body::from_stream(body_stream)); let mut response = Response::new(Body::from_stream(body_stream));
*response.status_mut() = StatusCode::OK; *response.status_mut() = StatusCode::OK;
response.headers_mut().insert( response.headers_mut().insert(
@@ -41,6 +36,63 @@ pub async fn stream(registry: &CursorSessionRegistry, request_id: &str) -> Resul
Ok(response) Ok(response)
} }
fn local_body_stream(
mut receiver: mpsc::UnboundedReceiver<Bytes>,
cancellation: CancellationToken,
trace: Option<CursorTraceRecorder>,
) -> impl tokio_stream::Stream<Item = std::result::Result<Bytes, Infallible>> {
async_stream::stream! {
let mut guard = LocalRunGuard::new(cancellation);
let mut trace = TraceStreamSink::new(trace, "byok_server");
while let Some(chunk) = receiver.recv().await {
let terminal = is_end_stream_frame(&chunk);
trace.chunk(&chunk);
if terminal {
guard.complete();
trace.finish(None);
}
yield Ok::<Bytes, Infallible>(chunk);
if terminal {
return;
}
}
guard.complete();
trace.finish(None);
}
}
fn is_end_stream_frame(frame: &Bytes) -> bool {
frame
.first()
.is_some_and(|flags| flags & END_STREAM_FLAG != 0)
}
struct LocalRunGuard {
cancellation: CancellationToken,
completed: bool,
}
impl LocalRunGuard {
fn new(cancellation: CancellationToken) -> Self {
Self {
cancellation,
completed: false,
}
}
fn complete(&mut self) {
self.completed = true;
}
}
impl Drop for LocalRunGuard {
fn drop(&mut self) {
if !self.completed {
self.cancellation.cancel();
}
}
}
pub async fn upstream( pub async fn upstream(
registry: CursorSessionRegistry, registry: CursorSessionRegistry,
request_id: String, request_id: String,
@@ -127,7 +179,11 @@ impl TraceStreamSink {
impl Drop for TraceStreamSink { impl Drop for TraceStreamSink {
fn drop(&mut self) { fn drop(&mut self) {
self.finish(None); if self.sender.is_some() {
self.finish(Some(
"response stream dropped before completion".to_string(),
));
}
} }
} }
@@ -143,3 +199,38 @@ impl Drop for UpstreamRunGuard {
.finish_upstream(self.request_id.clone(), self.generation); .finish_upstream(self.request_id.clone(), self.generation);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::cursor::{connect, proto::agent::v1 as pb};
#[tokio::test]
async fn local_stream_cancels_when_the_client_disconnects() {
let (sender, receiver) = mpsc::unbounded_channel();
let cancellation = CancellationToken::new();
sender
.send(connect::encode_message(&pb::AgentServerMessage::default()).unwrap())
.unwrap();
let mut stream = Box::pin(local_body_stream(receiver, cancellation.clone(), None));
stream.next().await.unwrap().unwrap();
drop(sender);
drop(stream);
assert!(cancellation.is_cancelled());
}
#[tokio::test]
async fn terminal_frame_does_not_cancel_a_completed_local_run() {
let (sender, receiver) = mpsc::unbounded_channel();
let cancellation = CancellationToken::new();
sender.send(connect::encode_end_stream()).unwrap();
let mut stream = Box::pin(local_body_stream(receiver, cancellation.clone(), None));
let terminal = stream.next().await.unwrap().unwrap();
assert!(is_end_stream_frame(&terminal));
drop(stream);
assert!(!cancellation.is_cancelled());
}
}
+67
View File
@@ -0,0 +1,67 @@
use axum::{
body::Body,
extract::{Extension, State},
http::{Request, Response},
routing::post,
Router,
};
use crate::{
cursor::{proxy, CursorSessionRegistry},
Result,
};
pub const TAB_PATHS: [&str; 17] = [
"/aiserver.v1.AiService/StreamCpp",
"/aiserver.v1.AiService/StreamNextCursorPrediction",
"/aiserver.v1.AiService/GetCppEditClassification",
"/aiserver.v1.AiService/RefreshTabContext",
"/aiserver.v1.AiService/CppConfig",
"/aiserver.v1.AiService/CppEditHistoryStatus",
"/aiserver.v1.AiService/CppAppend",
"/aiserver.v1.AiService/CppEditHistoryAppend",
"/aiserver.v1.AiService/ReportAiCodeChangeMetrics",
"/aiserver.v1.AiService/WriteGitCommitMessage",
"/aiserver.v1.AiService/WriteGitBranchName",
"/aiserver.v1.CppService/AvailableModels",
"/aiserver.v1.CppService/RecordCppFate",
"/aiserver.v1.FileSyncService/FSSyncFile",
"/aiserver.v1.FileSyncService/FSIsEnabledForUser",
"/aiserver.v1.FileSyncService/FSConfig",
"/aiserver.v1.FileSyncService/FSUploadFile",
];
pub fn is_tab_path(path: &str) -> bool {
TAB_PATHS.contains(&path)
}
pub fn router() -> Router<CursorSessionRegistry> {
TAB_PATHS.into_iter().fold(Router::new(), |router, path| {
router.route(path, post(forward))
})
}
async fn forward(
State(registry): State<CursorSessionRegistry>,
Extension(upstream): Extension<proxy::CursorProxy>,
request: Request<Body>,
) -> Result<Response<Body>> {
let settings = registry.store().tab_settings().await?;
match settings.service_url() {
Some(service_url) => proxy::forward_to_service(&upstream, request, service_url).await,
None => proxy::forward(Extension(upstream), request).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_only_legacy_tab_routes() {
assert_eq!(TAB_PATHS.len(), 17);
assert!(is_tab_path("/aiserver.v1.AiService/StreamCpp"));
assert!(is_tab_path("/aiserver.v1.FileSyncService/FSUploadFile"));
assert!(!is_tab_path("/aiserver.v1.AiService/AvailableModels"));
}
}
+21 -2
View File
@@ -9,7 +9,10 @@ use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{store::Store, Error, Result}; use crate::{
store::{Store, TabMode, TabSettings},
Error, Result,
};
use self::{ca::CaManager, proxy::ProxyRuntime}; use self::{ca::CaManager, proxy::ProxyRuntime};
@@ -65,6 +68,7 @@ struct Inner {
ca: CaManager, ca: CaManager,
ca_initialization: Mutex<()>, ca_initialization: Mutex<()>,
backend_addr: RwLock<Option<SocketAddr>>, backend_addr: RwLock<Option<SocketAddr>>,
tab_mode: Arc<RwLock<TabMode>>,
proxy: Mutex<ProxyRuntime>, proxy: Mutex<ProxyRuntime>,
} }
@@ -76,6 +80,7 @@ impl CursorHarness {
ca: CaManager::managed()?, ca: CaManager::managed()?,
ca_initialization: Mutex::new(()), ca_initialization: Mutex::new(()),
backend_addr: RwLock::new(None), backend_addr: RwLock::new(None),
tab_mode: Arc::new(RwLock::new(TabMode::default())),
proxy: Mutex::new(ProxyRuntime::default()), proxy: Mutex::new(ProxyRuntime::default()),
}), }),
}) })
@@ -138,6 +143,12 @@ impl CursorHarness {
self.status().await self.status().await
} }
pub async fn set_tab_settings(&self, settings: TabSettings) -> Result<TabSettings> {
let saved = self.inner.store.set_tab_settings(settings).await?;
*self.inner.tab_mode.write() = saved.mode;
Ok(saved)
}
async fn enable(&self) -> Result<()> { async fn enable(&self) -> Result<()> {
if !matches!(self.inner.ca.state()?, CaState::Ready) { if !matches!(self.inner.ca.state()?, CaState::Ready) {
return Err(Error::Config( return Err(Error::Config(
@@ -158,7 +169,15 @@ impl CursorHarness {
} }
let ca = self.inner.ca.load()?; let ca = self.inner.ca.load()?;
let requested_port = self.inner.store.port_settings().await?.proxy_port; let requested_port = self.inner.store.port_settings().await?.proxy_port;
let (url, actual_port) = proxy.start(backend_addr, ca, requested_port).await?; *self.inner.tab_mode.write() = self.inner.store.tab_settings().await?.mode;
let (url, actual_port) = proxy
.start(
backend_addr,
ca,
requested_port,
self.inner.tab_mode.clone(),
)
.await?;
if let Err(error) = self.inner.store.set_proxy_port(actual_port).await { if let Err(error) = self.inner.store.set_proxy_port(actual_port).await {
proxy.stop().await; proxy.stop().await;
return Err(error); return Err(error);
+29 -4
View File
@@ -1,4 +1,4 @@
use std::net::SocketAddr; use std::{net::SocketAddr, sync::Arc};
use hudsucker::{ use hudsucker::{
certificate_authority::RcgenAuthority, certificate_authority::RcgenAuthority,
@@ -8,7 +8,13 @@ use hudsucker::{
}; };
use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle};
use crate::{cursor::proxy::UPSTREAM_URL_HEADER, Error, Result}; use parking_lot::RwLock;
use crate::{
cursor::{proxy::UPSTREAM_URL_HEADER, tab::is_tab_path},
store::TabMode,
Error, Result,
};
use super::ca::LoadedCa; use super::ca::LoadedCa;
@@ -33,6 +39,7 @@ impl ProxyRuntime {
backend: SocketAddr, backend: SocketAddr,
ca: LoadedCa, ca: LoadedCa,
requested_port: u16, requested_port: u16,
tab_mode: Arc<RwLock<TabMode>>,
) -> Result<(String, u16)> { ) -> Result<(String, u16)> {
if let Some(url) = self.url() { if let Some(url) = self.url() {
return Ok((url, self.port.unwrap_or_default())); return Ok((url, self.port.unwrap_or_default()));
@@ -45,7 +52,7 @@ impl ProxyRuntime {
.with_listener(listener) .with_listener(listener)
.with_ca(authority) .with_ca(authority)
.with_rustls_connector(aws_lc_rs::default_provider()) .with_rustls_connector(aws_lc_rs::default_provider())
.with_http_handler(CursorRelay { backend }) .with_http_handler(CursorRelay { backend, tab_mode })
.with_graceful_shutdown(async move { .with_graceful_shutdown(async move {
let _ = done.await; let _ = done.await;
}) })
@@ -89,6 +96,7 @@ async fn bind_proxy_listener(requested_port: u16) -> Result<TcpListener> {
#[derive(Clone)] #[derive(Clone)]
struct CursorRelay { struct CursorRelay {
backend: SocketAddr, backend: SocketAddr,
tab_mode: Arc<RwLock<TabMode>>,
} }
impl HttpHandler for CursorRelay { impl HttpHandler for CursorRelay {
@@ -98,7 +106,8 @@ impl HttpHandler for CursorRelay {
mut request: Request<Body>, mut request: Request<Body>,
) -> RequestOrResponse { ) -> RequestOrResponse {
let original = request.uri().clone(); let original = request.uri().clone();
if is_cursor_host(original.host().unwrap_or_default()) && is_local_path(original.path()) { let locally_routed = should_route_locally(original.path(), *self.tab_mode.read());
if is_cursor_host(original.host().unwrap_or_default()) && locally_routed {
if let Ok(value) = original.to_string().parse() { if let Ok(value) = original.to_string().parse() {
request.headers_mut().insert(UPSTREAM_URL_HEADER, value); request.headers_mut().insert(UPSTREAM_URL_HEADER, value);
} }
@@ -157,6 +166,10 @@ fn is_local_path(path: &str) -> bool {
) )
} }
fn should_route_locally(path: &str, tab_mode: TabMode) -> bool {
is_local_path(path) || (is_tab_path(path) && tab_mode != TabMode::Direct)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -179,5 +192,17 @@ mod tests {
"/aiserver.v1.AnalyticsService/BootstrapStatsig" "/aiserver.v1.AnalyticsService/BootstrapStatsig"
)); ));
assert!(!is_local_path("/unrelated")); assert!(!is_local_path("/unrelated"));
assert!(should_route_locally(
"/aiserver.v1.AiService/StreamCpp",
TabMode::Public
));
assert!(should_route_locally(
"/aiserver.v1.AiService/StreamCpp",
TabMode::Custom
));
assert!(!should_route_locally(
"/aiserver.v1.AiService/StreamCpp",
TabMode::Direct
));
} }
} }
+2 -2
View File
@@ -140,11 +140,11 @@ impl Provider for OpenAiResponsesProvider {
} }
if text_open { text_open = false; yield ModelEvent::TextEnd; } if text_open { text_open = false; yield ModelEvent::TextEnd; }
} }
"response.reasoning_summary_text.delta" => { "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => {
if !thinking_open { thinking_open = true; yield ModelEvent::ThinkingStart; } if !thinking_open { thinking_open = true; yield ModelEvent::ThinkingStart; }
if let Some(delta) = value.get("delta").and_then(Value::as_str) { yield ModelEvent::ThinkingDelta(delta.into()); } if let Some(delta) = value.get("delta").and_then(Value::as_str) { yield ModelEvent::ThinkingDelta(delta.into()); }
} }
"response.reasoning_summary_text.done" => { "response.reasoning_summary_text.done" | "response.reasoning_text.done" => {
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; } if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
} }
"response.output_item.added" => { "response.output_item.added" => {
+65 -7
View File
@@ -460,11 +460,8 @@ impl RunEngine {
.iter() .iter()
.map(|message| message.message_id.as_str()) .map(|message| message.message_id.as_str())
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
let compactable = messages let (compactable, retained_request_context) =
.iter() auto_compaction_partition(messages, &current_ids);
.filter(|message| !current_ids.contains(message.message_id.as_str()))
.cloned()
.collect::<Vec<_>>();
if compactable.is_empty() { if compactable.is_empty() {
return Ok((revision, None)); return Ok((revision, None));
} }
@@ -533,7 +530,8 @@ impl RunEngine {
}, },
runtime_event_id: Some(event_id), runtime_event_id: Some(event_id),
}; };
let mut replacement = vec![summary_message]; let mut replacement = retained_request_context.into_iter().collect::<Vec<_>>();
replacement.push(summary_message);
replacement.extend(prepared.initial_messages.iter().cloned()); replacement.extend(prepared.initial_messages.iter().cloned());
let revision = self let revision = self
.store .store
@@ -565,6 +563,29 @@ impl RunEngine {
} }
} }
fn auto_compaction_partition(
messages: &[CanonicalMessage],
current_ids: &HashSet<&str>,
) -> (Vec<CanonicalMessage>, Option<CanonicalMessage>) {
let latest_request_context = messages
.iter()
.rposition(|message| message.message_id.starts_with("request-context:"));
let compactable = messages
.iter()
.enumerate()
.filter(|(index, message)| {
Some(*index) != latest_request_context
&& !current_ids.contains(message.message_id.as_str())
})
.map(|(_, message)| message.clone())
.collect();
let retained = latest_request_context
.and_then(|index| messages.get(index))
.filter(|message| !current_ids.contains(message.message_id.as_str()))
.cloned();
(compactable, retained)
}
fn should_auto_compact(prepared: &PreparedRun, messages: &[CanonicalMessage]) -> bool { fn should_auto_compact(prepared: &PreparedRun, messages: &[CanonicalMessage]) -> bool {
if prepared.action != RunAction::Start { if prepared.action != RunAction::Start {
return false; return false;
@@ -724,7 +745,7 @@ fn failure_message(failure: &RunFailure) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{estimate_context_tokens, hydrate_tool_images}; use super::{auto_compaction_partition, estimate_context_tokens, hydrate_tool_images};
use crate::{ use crate::{
model::{ model::{
CanonicalMessage, ContentPart, Origin, ProjectedContent, ProjectedMessage, PromptSpec, CanonicalMessage, ContentPart, Origin, ProjectedContent, ProjectedMessage, PromptSpec,
@@ -732,6 +753,7 @@ mod tests {
}, },
store::Store, store::Store,
}; };
use std::collections::HashSet;
#[test] #[test]
fn context_estimate_grows_with_prompt_history() { fn context_estimate_grows_with_prompt_history() {
@@ -756,6 +778,42 @@ mod tests {
assert!(estimate_context_tokens(&prompt, &long) > estimate_context_tokens(&prompt, &short)); assert!(estimate_context_tokens(&prompt, &long) > estimate_context_tokens(&prompt, &short));
} }
#[test]
fn auto_compaction_preserves_only_the_latest_request_context() {
let first_context = CanonicalMessage::text(
"request-context:first",
Role::User,
Origin::Prompt,
"old rules",
);
let old_runtime =
CanonicalMessage::text("runtime:first", Role::User, Origin::Runtime, "old query");
let latest_context = CanonicalMessage::text(
"request-context:second",
Role::User,
Origin::Prompt,
"new rules",
);
let current_runtime = CanonicalMessage::text(
"runtime:current",
Role::User,
Origin::Runtime,
"current query",
);
let messages = vec![
first_context.clone(),
old_runtime.clone(),
latest_context.clone(),
current_runtime,
];
let current_ids = HashSet::from(["runtime:current"]);
let (compactable, retained) = auto_compaction_partition(&messages, &current_ids);
assert_eq!(compactable, vec![first_context, old_runtime]);
assert_eq!(retained, Some(latest_context));
}
#[tokio::test] #[tokio::test]
async fn read_image_is_loaded_only_for_the_provider_projection() { async fn read_image_is_loaded_only_for_the_provider_projection() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
+93
View File
@@ -6,8 +6,11 @@ use super::{now_ms, Store};
const PORT_SETTINGS_KEY: &str = "network_ports"; const PORT_SETTINGS_KEY: &str = "network_ports";
const PROXY_SETTINGS_KEY: &str = "outbound_proxy"; const PROXY_SETTINGS_KEY: &str = "outbound_proxy";
const TAB_SETTINGS_KEY: &str = "cursor_tab";
const INSTALLATION_ID_KEY: &str = "installation_id"; const INSTALLATION_ID_KEY: &str = "installation_id";
pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn";
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct PortSettings { pub struct PortSettings {
pub proxy_port: u16, pub proxy_port: u16,
@@ -28,6 +31,31 @@ impl ProxyMode {
} }
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TabMode {
#[default]
Public,
Direct,
Custom,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct TabSettings {
pub mode: TabMode,
pub address: String,
}
impl TabSettings {
pub fn service_url(&self) -> Option<&str> {
match self.mode {
TabMode::Public => Some(PUBLIC_TAB_SERVICE_URL),
TabMode::Direct => None,
TabMode::Custom => Some(&self.address),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct ProxySettingsInput { pub struct ProxySettingsInput {
pub mode: ProxyMode, pub mode: ProxyMode,
@@ -139,6 +167,47 @@ impl Store {
self.proxy_settings().await self.proxy_settings().await
} }
pub async fn tab_settings(&self) -> Result<TabSettings> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
)
.bind(TAB_SETTINGS_KEY)
.fetch_optional(&self.pool)
.await?;
value
.map(|value| serde_json::from_str(&value).map_err(Into::into))
.unwrap_or_else(|| Ok(TabSettings::default()))
}
pub async fn set_tab_settings(&self, mut settings: TabSettings) -> Result<TabSettings> {
settings.address = settings.address.trim().trim_end_matches('/').to_owned();
if settings.mode == TabMode::Custom {
let parsed = url::Url::parse(&settings.address).map_err(|error| {
crate::Error::Config(format!("invalid TAB service address: {error}"))
})?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(crate::Error::Config(
"TAB service address must use http or https".into(),
));
}
if parsed.host_str().is_none()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(crate::Error::Config(
"TAB service address must be a base URL without a query or fragment".into(),
));
}
}
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(now_ms())
.execute(&self.pool)
.await?;
Ok(settings)
}
pub async fn port_settings(&self) -> Result<PortSettings> { pub async fn port_settings(&self) -> Result<PortSettings> {
let value = sqlx::query_scalar::<_, String>( let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?", "SELECT value_json FROM service_settings WHERE setting_key = ?",
@@ -244,4 +313,28 @@ mod tests {
"secret" "secret"
); );
} }
#[tokio::test]
async fn tab_settings_default_to_public_and_validate_custom_urls() {
let store = Store::connect("sqlite::memory:").await.unwrap();
assert_eq!(store.tab_settings().await.unwrap(), TabSettings::default());
let saved = store
.set_tab_settings(TabSettings {
mode: TabMode::Custom,
address: " https://tab.example.com/base/ ".into(),
})
.await
.unwrap();
assert_eq!(saved.address, "https://tab.example.com/base");
assert_eq!(store.tab_settings().await.unwrap(), saved);
assert!(store
.set_tab_settings(TabSettings {
mode: TabMode::Custom,
address: "file:///tmp/tab".into(),
})
.await
.is_err());
}
} }
+48
View File
@@ -146,6 +146,54 @@ async fn registry_shutdown_cancels_runs_and_closes_run_sse_outputs() {
assert_eq!(output.recv().await, None); assert_eq!(output.recv().await, None);
} }
#[tokio::test]
async fn client_heartbeat_returns_a_server_protocol_heartbeat() {
let (_directory, store) = fixtures::temp_store().await;
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(fake_provider::FakeProvider::default()),
PromptCompiler::new(assets),
Default::default(),
);
let handle = registry.get_or_create("heartbeat-run").await.unwrap();
let mut output = handle.subscribe();
handle
.command(CursorCommand::Append {
seqno: 0,
message: Box::new(pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::ClientHeartbeat(
pb::ClientHeartbeat {},
)),
}),
})
.await
.unwrap();
let frame = tokio::time::timeout(std::time::Duration::from_secs(1), output.recv())
.await
.unwrap()
.unwrap();
let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
let message = pb::AgentServerMessage::decode(payload).unwrap();
assert!(matches!(
message.message,
Some(pb::agent_server_message::Message::InteractionUpdate(
pb::InteractionUpdate {
message: Some(pb::interaction_update::Message::Heartbeat(_)),
}
))
));
registry.shutdown().await;
}
#[tokio::test] #[tokio::test]
async fn runtime_user_message_action_aborts_active_exec_before_canceled_end_stream() { async fn runtime_user_message_action_aborts_active_exec_before_canceled_end_stream() {
let (_directory, store) = fixtures::temp_store().await; let (_directory, store) = fixtures::temp_store().await;
-1
View File
@@ -323,7 +323,6 @@ fn every_captured_mode_owns_and_renders_its_runtime_template() {
.unwrap(), .unwrap(),
); );
let values = BTreeMap::from([ let values = BTreeMap::from([
("REQUEST_CONTEXT", String::new()),
("OPEN_FILES", String::new()), ("OPEN_FILES", String::new()),
("SELECTED_CONTEXT", String::new()), ("SELECTED_CONTEXT", String::new()),
("ACTION_CONTEXT", String::new()), ("ACTION_CONTEXT", String::new()),
+31
View File
@@ -329,6 +329,37 @@ async fn openai_responses_raw_stream_does_not_invent_reasoning_effort() {
assert_eq!(replayed, ["opaque-1", "opaque-2"]); assert_eq!(replayed, ["opaque-1", "opaque-2"]);
} }
#[tokio::test]
async fn openai_responses_streams_openrouter_reasoning_text_events() {
let (base_url, _requests, server) = fixture_server(
"/v1/responses",
concat!(
"data: {\"type\":\"response.reasoning_text.delta\",\"delta\":\"still working\"}\n\n",
"data: {\"type\":\"response.reasoning_text.done\"}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
),
)
.await;
let provider = OpenAiResponsesProvider::new(
reqwest::Client::new(),
config(ProviderKind::OpenAiResponses, base_url, None),
);
let events = collect(provider.stream(invocation(), CancellationToken::new())).await;
server.abort();
assert!(events
.iter()
.any(|event| matches!(event, ModelEvent::ThinkingStart)));
assert!(events.iter().any(
|event| matches!(event, ModelEvent::ThinkingDelta(delta) if delta == "still working")
));
assert!(events
.iter()
.any(|event| matches!(event, ModelEvent::ThinkingEnd)));
assert_eq!(events.last(), Some(&ModelEvent::Done(FinishReason::Stop)));
}
#[tokio::test] #[tokio::test]
async fn openai_responses_reasoning_item_done_closes_an_open_summary() { async fn openai_responses_reasoning_item_done_closes_an_open_summary() {
let (base_url, _requests, server) = fixture_server( let (base_url, _requests, server) = fixture_server(
+137 -20
View File
@@ -19,9 +19,9 @@ use cursor_server::{
use prost::Message; use prost::Message;
#[tokio::test] #[tokio::test]
async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message() { async fn unchanged_request_context_is_not_repeated_and_preserves_the_provider_prefix() {
let (_directory, store) = fixtures::temp_store().await; let (_directory, store) = fixtures::temp_store().await;
let references = references(&store).await; let first_references = references(&store).await;
let provider = fake_provider::FakeProvider::default(); let provider = fake_provider::FakeProvider::default();
provider.push(vec![ provider.push(vec![
ModelEvent::Start { ModelEvent::Start {
@@ -32,6 +32,15 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message
ModelEvent::TextEnd, ModelEvent::TextEnd,
ModelEvent::Done(FinishReason::Stop), ModelEvent::Done(FinishReason::Stop),
]); ]);
provider.push(vec![
ModelEvent::Start {
model_call_id: "model-2".into(),
},
ModelEvent::TextStart,
ModelEvent::TextDelta("answer again".into()),
ModelEvent::TextEnd,
ModelEvent::Done(FinishReason::Stop),
]);
let assets = PromptAssets::load( let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")) std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("prompt/cursor") .join("prompt/cursor")
@@ -39,7 +48,7 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message
) )
.unwrap(); .unwrap();
let registry = CursorSessionRegistry::new( let registry = CursorSessionRegistry::new(
store, store.clone(),
Arc::new(provider.clone()), Arc::new(provider.clone()),
PromptCompiler::new(assets), PromptCompiler::new(assets),
Default::default(), Default::default(),
@@ -49,12 +58,13 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message
handle handle
.command(CursorCommand::Append { .command(CursorCommand::Append {
seqno: 0, seqno: 0,
message: Box::new(run_request(references)), message: Box::new(run_request(first_references)),
}) })
.await .await
.unwrap(); .unwrap();
let mut seqno = 1; let mut seqno = 1;
let mut checkpoint = None;
loop { loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await .await
@@ -65,15 +75,21 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message
break; break;
} }
let message = pb::AgentServerMessage::decode(payload).unwrap(); let message = pb::AgentServerMessage::decode(payload).unwrap();
if let Some(pb::agent_server_message::Message::KvServerMessage(kv)) = message.message { match message.message {
handle Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
.command(CursorCommand::Append { handle
seqno, .command(CursorCommand::Append {
message: Box::new(kv_ack(kv.id)), seqno,
}) message: Box::new(kv_ack(kv.id)),
.await })
.unwrap(); .await
seqno += 1; .unwrap();
seqno += 1;
}
Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(state)) => {
checkpoint = Some(state);
}
_ => {}
} }
} }
@@ -89,12 +105,23 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message
.tools .tools
.iter() .iter()
.any(|tool| tool.name == "GenerateImage")); .any(|tool| tool.name == "GenerateImage"));
assert_eq!(request.history.len(), 1); assert_eq!(request.history.len(), 2);
assert!(request.history[0]
.message_id
.starts_with("request-context:"));
let ProjectedContent::Parts(context_parts) = &request.history[0].content else {
panic!("request context message must use typed parts")
};
let [ContentPart::Text { text: context_text }] = context_parts.as_slice() else {
panic!("request context message must contain one text part")
};
assert_eq!( assert_eq!(
request.history[0].message_id, request.history[1].message_id,
"runtime:run-request:ask-request" "runtime:run-request:ask-request"
); );
let ProjectedContent::Parts(parts) = &request.history[0].content else { assert!(!request.prompt.instructions.contains("workspace rule"));
assert!(!request.prompt.instructions.contains("<mcp_meta_tools>"));
let ProjectedContent::Parts(parts) = &request.history[1].content else {
panic!("runtime message must use typed parts") panic!("runtime message must use typed parts")
}; };
let [ContentPart::Text { text }] = parts.as_slice() else { let [ContentPart::Text { text }] = parts.as_slice() else {
@@ -109,6 +136,15 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message
"<definition_path>/tmp/mcp-test/lookup.json</definition_path>", "<definition_path>/tmp/mcp-test/lookup.json</definition_path>",
"<input_schema>{&quot;properties&quot;:{&quot;query&quot;:{&quot;type&quot;:&quot;string&quot;}},&quot;type&quot;:&quot;object&quot;}</input_schema>", "<input_schema>{&quot;properties&quot;:{&quot;query&quot;:{&quot;type&quot;:&quot;string&quot;}},&quot;type&quot;:&quot;object&quot;}</input_schema>",
"Call a listed tool directly with CallMcpTool without calling GetMcpTools first.", "Call a listed tool directly with CallMcpTool without calling GetMcpTools first.",
] {
assert!(
context_text.contains(expected),
"missing request context section: {expected}"
);
}
assert!(!context_text.contains("complete skill body"));
assert!(!context_text.contains("complete MCP server instructions"));
for expected in [
"Ask mode is active.", "Ask mode is active.",
"<user_query>\nexplain this\n</user_query>", "<user_query>\nexplain this\n</user_query>",
] { ] {
@@ -117,9 +153,65 @@ async fn current_mode_and_referenced_context_are_consumed_by_one_runtime_message
"missing runtime section: {expected}" "missing runtime section: {expected}"
); );
} }
assert!(!text.contains("complete skill body")); assert!(!text.contains("<rules>"));
assert!(!text.contains("complete MCP server instructions")); assert!(!text.contains("<mcp_meta_tools>"));
assert!(text.contains("/workspace/src/main.rs")); assert!(text.contains("/workspace/src/main.rs"));
let second = registry.get_or_create("ask-request-2").await.unwrap();
let mut second_output = second.subscribe();
second
.command(CursorCommand::Append {
seqno: 0,
message: Box::new(run_request_with_state(
references(&store).await,
checkpoint.expect("first Run must publish a checkpoint"),
)),
})
.await
.unwrap();
let mut second_seqno = 1;
loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), second_output.recv())
.await
.unwrap()
.unwrap();
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
break;
}
let message = pb::AgentServerMessage::decode(payload).unwrap();
if let Some(pb::agent_server_message::Message::KvServerMessage(kv)) = message.message {
second
.command(CursorCommand::Append {
seqno: second_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
second_seqno += 1;
}
}
let requests = provider.requests();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[1].prompt.instructions, requests[0].prompt.instructions,
"unchanged request context must not rewrite the system prompt"
);
assert_eq!(
requests[1].history[..requests[0].history.len()],
requests[0].history,
"the previous provider history must remain an exact prefix"
);
assert_eq!(
requests[1]
.history
.iter()
.filter(|message| message.message_id.starts_with("request-context:"))
.count(),
1,
"identical request context must not be appended again"
);
} }
#[tokio::test] #[tokio::test]
@@ -252,10 +344,10 @@ async fn missing_context_parts_use_current_cursor_response_and_cache_its_content
let requests = provider.requests(); let requests = provider.requests();
assert_eq!(requests.len(), 1); assert_eq!(requests.len(), 1);
let ProjectedContent::Parts(parts) = &requests[0].history[0].content else { let ProjectedContent::Parts(parts) = &requests[0].history[0].content else {
panic!("runtime message must use typed parts") panic!("request context message must use typed parts")
}; };
let [ContentPart::Text { text }] = parts.as_slice() else { let [ContentPart::Text { text }] = parts.as_slice() else {
panic!("this fixture has no images") panic!("request context message must contain one text part")
}; };
assert!(text.contains("<mcp_meta_tool_server name=\"live-mcp\" identifier=\"live-mcp\">")); assert!(text.contains("<mcp_meta_tool_server name=\"live-mcp\" identifier=\"live-mcp\">"));
assert!(text.contains("<mcp_tool name=\"current-tool\">")); assert!(text.contains("<mcp_tool name=\"current-tool\">"));
@@ -449,6 +541,31 @@ fn run_request(references: References) -> pb::AgentClientMessage {
} }
} }
fn run_request_with_state(
references: References,
state: pb::ConversationStateStructure,
) -> pb::AgentClientMessage {
let mut message = run_request(references);
let Some(pb::agent_client_message::Message::RunRequest(request)) = message.message.as_mut()
else {
unreachable!("run_request always returns a RunRequest")
};
request.conversation_state = Some(state);
let Some(pb::conversation_action::Action::UserMessageAction(action)) = request
.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")
.message_id = "wire-user-2".into();
message
}
fn kv_ack(id: u32) -> pb::AgentClientMessage { fn kv_ack(id: u32) -> pb::AgentClientMessage {
pb::AgentClientMessage { pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::KvClientMessage( message: Some(pb::agent_client_message::Message::KvClientMessage(
+22 -7
View File
@@ -259,18 +259,27 @@ async fn text_turn_runs_from_bidi_request_through_checkpoint_and_end_stream() {
.contains("powered by Test Model")); .contains("powered by Test Model"));
let projected = &requests[0].history; let projected = &requests[0].history;
assert_eq!(projected[0].role, Role::User); assert_eq!(projected[0].role, Role::User);
let ProjectedContent::Parts(runtime) = &projected[0].content else { assert!(projected[0].message_id.starts_with("request-context:"));
panic!("runtime context must be text") let ProjectedContent::Parts(context) = &projected[0].content else {
panic!("request context must be text")
};
assert!(matches!(
context.as_slice(),
[cursor_server::model::ContentPart::Text { text }]
if text.contains("<user_info>")
));
let ProjectedContent::Parts(runtime) = &projected[1].content else {
panic!("runtime user message must be text")
}; };
assert!(matches!( assert!(matches!(
runtime.as_slice(), runtime.as_slice(),
[cursor_server::model::ContentPart::Text { text }] [cursor_server::model::ContentPart::Text { text }]
if text.contains("<user_info>") if text.contains("<user_query>\nhello\n</user_query>")
&& text.contains("<user_query>\nhello\n</user_query>") && !text.contains("<user_info>")
)); ));
assert_eq!( assert_eq!(
projected.len(), projected.len(),
1, 2,
"the raw UserMessage is not projected twice" "the raw UserMessage is not projected twice"
); );
@@ -278,9 +287,15 @@ async fn text_turn_runs_from_bidi_request_through_checkpoint_and_end_stream() {
.load_current_messages(&cursor_server::model::ConversationId::new("conversation")) .load_current_messages(&cursor_server::model::ConversationId::new("conversation"))
.await .await
.unwrap(); .unwrap();
assert_eq!(messages[0].message_id, "runtime:run-request:request"); assert!(messages[0].message_id.starts_with("request-context:"));
assert_eq!(messages[0].role, Role::User); assert_eq!(messages[0].role, Role::User);
assert_eq!(messages.len(), 2, "runtime user plus final assistant"); assert_eq!(messages[1].message_id, "runtime:run-request:request");
assert_eq!(messages[1].role, Role::User);
assert_eq!(
messages.len(),
3,
"request context plus runtime user and final assistant"
);
let stored_runs: Vec<String> = sqlx::query_scalar("SELECT run_id FROM runs ORDER BY run_id") let stored_runs: Vec<String> = sqlx::query_scalar("SELECT run_id FROM runs ORDER BY run_id")
.fetch_all(store.pool()) .fetch_all(store.pool())
.await .await