diff --git a/Cargo.lock b/Cargo.lock index 50f296b..80aad04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "cursor-byok-desktop" -version = "0.1.3" +version = "0.1.4" dependencies = [ "axum", "cursor-server", diff --git a/Makefile b/Makefile index b23ddc2..f937d23 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,20 @@ LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key -.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker +.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker check: cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace --all-targets npm --prefix apps/desktop run check + npm --prefix apps/docs run check dev-web: npm --prefix apps/desktop run dev:web +dev-docs: + npm --prefix apps/docs run dev + dev-server: CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server @@ -20,6 +24,9 @@ dev-desktop: build-web: npm --prefix apps/desktop run build +build-docs: + npm --prefix apps/docs run build + build-server: cargo build --release --package cursor-server --bin cursor-server diff --git a/apps/desktop/demo/index.html b/apps/desktop/demo/index.html new file mode 100644 index 0000000..7d430f0 --- /dev/null +++ b/apps/desktop/demo/index.html @@ -0,0 +1,23 @@ + + + + + + + + Cursor BYOK 产品演示 + + +
+ + + diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index af7962c..75daa20 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "cursor-byok-desktop", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cursor-byok-desktop", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.8.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2cc6a7d..985bcb5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "cursor-byok-desktop", - "version": "0.1.3", + "version": "0.1.4", "description": "Cursor BYOK desktop management application", "type": "module", "scripts": { @@ -10,6 +10,7 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json", "i18n:scan": "STATIC_I18N_SCAN=true vite build", "build": "vite build", + "build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts", "check": "npm run typecheck && npm run typecheck:node && npm run build", "dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"", "serve": "vite preview", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 84ca450..4c2db08 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cursor-byok-desktop" -version = "0.1.3" +version = "0.1.4" edition = "2021" publish = false diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 07b264a..1954d26 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "core:window:allow-toggle-maximize", "core:window:allow-is-maximized", "core:window:allow-close", + "core:app:allow-set-dock-visibility", "allow-open-terminal-with-command", "clipboard-manager:allow-write-text", "autostart:default", diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index bb75f6b..e4b767b 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -186,9 +186,12 @@ pub fn run() { let listener = tauri::async_runtime::block_on(server.bind())?; let address = listener.local_addr()?; tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?; - let silent_start = tauri::async_runtime::block_on(server.store().desktop_settings()) - .map(|settings| settings.silent_start) - .unwrap_or(false); + let desktop_settings = + tauri::async_runtime::block_on(server.store().desktop_settings()) + .unwrap_or_default(); + #[cfg(target_os = "macos")] + app.handle() + .set_dock_visibility(desktop_settings.show_dock_icon)?; let shutdown = CancellationToken::new(); let server_shutdown = shutdown.clone(); let app_handle = app.handle().clone(); @@ -206,7 +209,7 @@ pub fn run() { exiting: AtomicBool::new(false), }); let window = create_main_window(app.handle(), address)?; - if silent_start && started_by_autostart { + if desktop_settings.silent_start && started_by_autostart { tracing::info!("silent autostart enabled; keeping the main window hidden"); } else { window.show()?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 9f04274..a4cb5a3 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Cursor BYOK", - "version": "0.1.3", + "version": "0.1.4", "identifier": "dev.cursorbyok.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 9eabcad..7ebaf90 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -143,6 +143,7 @@ export interface TabSettings { export interface DesktopSettings { silent_start: boolean; + show_dock_icon: boolean; } export interface OverviewMetrics { diff --git a/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx b/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx index 9160069..ef8c5c1 100644 --- a/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx +++ b/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx @@ -1,10 +1,12 @@ import { useEffect, useState } from "react"; import { currentAppVersion, + hasDockVisibilitySetting, hasNativeAppLifecycle, readAutostart, - readSilentStart, + readDesktopSettings, writeAutostart, + writeDockIconVisibility, writeSilentStart, } from "../../native/appLifecycle"; import { updateStore, useUpdateStore } from "../../store/updateStore"; @@ -17,12 +19,14 @@ import styles from "./AppLifecycleSettingsCard.module.scss"; export function AppLifecycleSettingsCard() { const message = useMessage(); const native = hasNativeAppLifecycle(); + const dockVisibilitySetting = hasDockVisibilitySetting(); const { availableVersion, checking, installing } = useUpdateStore(); const [version, setVersion] = useState("…"); const [autostart, setAutostart] = useState(false); const [loadingAutostart, setLoadingAutostart] = useState(native); const [silentStart, setSilentStart] = useState(false); - const [loadingSilentStart, setLoadingSilentStart] = useState(native); + const [dockIconVisible, setDockIconVisible] = useState(true); + const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native); useEffect(() => { let disposed = false; @@ -32,10 +36,17 @@ export function AppLifecycleSettingsCard() { .then((enabled) => { if (!disposed) setAutostart(enabled); }) .catch((cause) => message(cause instanceof Error ? cause.message : String(cause))) .finally(() => { if (!disposed) setLoadingAutostart(false); }); - void readSilentStart() - .then((silent) => { if (!disposed) setSilentStart(silent); }) + void readDesktopSettings() + .then((settings) => { + if (disposed) return; + setSilentStart(settings.silent_start); + setDockIconVisible(settings.show_dock_icon); + }) .catch(() => {}) - .finally(() => { if (!disposed) setLoadingSilentStart(false); }); + .finally(() => { + if (disposed) return; + setLoadingDesktopSettings(false); + }); } return () => { disposed = true; }; }, [message, native]); @@ -55,14 +66,27 @@ export function AppLifecycleSettingsCard() { const toggleSilentStart = async (enabled: boolean) => { try { - setLoadingSilentStart(true); + setLoadingDesktopSettings(true); await writeSilentStart(enabled); - setSilentStart(await readSilentStart()); + setSilentStart(enabled); message(enabled ? t("已开启静默启动") : t("已关闭静默启动")); } catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); } finally { - setLoadingSilentStart(false); + setLoadingDesktopSettings(false); + } + }; + + const toggleDockIcon = async (visible: boolean) => { + try { + setLoadingDesktopSettings(true); + await writeDockIconVisibility(visible); + setDockIconVisible(visible); + message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标")); + } catch (cause) { + message(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoadingDesktopSettings(false); } }; @@ -103,11 +127,23 @@ export function AppLifecycleSettingsCard() { void toggleSilentStart(enabled)} /> } + {dockVisibilitySetting &&
+
+ {t("在 Dock 栏显示")} + {t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")} +
+ void toggleDockIcon(visible)} + /> +
}
{t("软件更新")} diff --git a/apps/desktop/src/demo/api.ts b/apps/desktop/src/demo/api.ts new file mode 100644 index 0000000..06ed380 --- /dev/null +++ b/apps/desktop/src/demo/api.ts @@ -0,0 +1,287 @@ +import type { + CallDetail, + CursorHarnessStatus, + LlmCall, + Model, + Overview, + OverviewTokenUsageBucket, + ProxySettings, + StatisticsStorage, + TabSettings, +} from "../api"; + +const API_ROOT = "/__byok-api__/api"; +const FIXED_NOW = Date.UTC(2026, 7, 27, 8, 0, 0); + +const models: Model[] = [ + createModel({ hash: "mock-claude-sonnet", order: 1, name: "Claude Sonnet 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-sonnet-4-20250514" }), + createModel({ hash: "mock-claude-opus", order: 2, name: "Claude Opus 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-opus-4-20250514" }), + createModel({ hash: "mock-gpt", order: 3, name: "GPT-5.2", type: "openai", url: "https://api.openai.com", modelId: "gpt-5.2" }), + createModel({ hash: "mock-o3", order: 4, name: "o3", type: "openai", url: "https://api.openai.com", modelId: "o3" }), + createModel({ hash: "mock-deepseek-v3", order: 5, name: "DeepSeek V3.2", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-chat", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-deepseek-r1", order: 6, name: "DeepSeek R1", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-reasoner", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-gemini-pro", order: 7, name: "Gemini 2.5 Pro", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-pro", endpoint: "/v1beta/openai/chat/completions" }), + createModel({ hash: "mock-gemini-flash", order: 8, name: "Gemini 2.5 Flash", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-flash", endpoint: "/v1beta/openai/chat/completions" }), + createModel({ hash: "mock-qwen-max", order: 9, name: "Qwen3 Max", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen3-max", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-qwen-plus", order: 10, name: "Qwen Plus", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen-plus", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-kimi-k2", order: 11, name: "Kimi K2", type: "openai", url: "https://api.moonshot.cn", modelId: "kimi-k2-0711-preview", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-kimi-128k", order: 12, name: "Moonshot V1 128K", type: "openai", url: "https://api.moonshot.cn", modelId: "moonshot-v1-128k", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-glm-45", order: 13, name: "GLM-4.5", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5", endpoint: "/api/paas/v4/chat/completions" }), + createModel({ hash: "mock-glm-air", order: 14, name: "GLM-4.5-Air", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5-air", endpoint: "/api/paas/v4/chat/completions" }), + createModel({ hash: "mock-mistral-large", order: 15, name: "Mistral Large", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-large-latest", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-mistral-small", order: 16, name: "Mistral Small", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-small-latest", endpoint: "/v1/chat/completions" }), +]; + +const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => { + const model = models[index % models.length]; + const failed = index === 7 || index === 19; + return { + call_kind: "provider_llm", + route: "local_byok", + call_id: `mock-call-${String(index + 1).padStart(3, "0")}`, + run_id: `mock-run-${Math.floor(index / 3) + 1}`, + conversation_id: `mock-conversation-${Math.floor(index / 4) + 1}`, + provider_call_index: index + 1, + model_hash: model.model_hash, + provider_type: model.type, + provider_url: model.base_url, + request_type: model.type === "anthropic" ? "messages" : "responses", + request_url: model.type === "anthropic" ? `${model.base_url}/v1/messages` : `${model.base_url}${model.openai_endpoint}`, + model_id: model.model_id, + display_name: model.display_name, + reasoning_effort: index % 2 === 0 ? "high" : null, + fast: index % 3 === 0, + status: failed ? "failed" : "completed", + finish_reason: failed ? null : "stop", + created_at_ms: FIXED_NOW - index * 3 * 60_000, + ttfb_ms: 210 + index * 13, + ttft_ms: 370 + index * 17, + duration_ms: failed ? 812 : 1_420 + index * 71, + input_tokens: 4_800 + index * 337, + output_tokens: failed ? 0 : 820 + index * 43, + total_tokens: failed ? 4_800 + index * 337 : 5_620 + index * 380, + cache_read_tokens: 3_100 + index * 251, + cache_write_tokens: 320 + index * 19, + reasoning_tokens: index % 2 === 0 ? 420 + index * 11 : null, + message_count: 14 + (index % 8), + tool_count: 3 + (index % 5), + http_status: failed ? 429 : 200, + error_kind: failed ? "provider_rate_limit" : null, + error_message: failed ? "Mock provider rate limit" : null, + detailed: true, + }; +}); + +const harnessStatus: CursorHarnessStatus = { + platform: "macos", + ca: "ready", + configured_models: models.length, + enabled_models: models.length, + integration: "enabled", + proxy_url: "http://127.0.0.1:54321", + ca_install_command: null, +}; + +let detailed = true; +let portSettings = { proxy_port: 0, service_port: 0 }; +let proxySettings: ProxySettings = { + mode: "system", + address: "", + auth_enabled: false, + username: "", + has_password: false, +}; +let tabSettings: TabSettings = { mode: "public", address: "" }; +let storage: StatisticsStorage = { bytes: 26_004_480, call_count: calls.length, trace_count: calls.length }; + +export function installDemoApi() { + const nativeFetch = window.fetch.bind(window); + + window.fetch = async (input, init) => { + const requestUrl = input instanceof Request ? input.url : input instanceof URL ? input.href : input; + const url = new URL(requestUrl, window.location.href); + if (!url.pathname.startsWith(API_ROOT)) return nativeFetch(input, init); + + const path = url.pathname.slice(API_ROOT.length) || "/"; + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + const body = await readBody(input, init); + + if (path === "/ads") return json({ slots: [] }); + if (path === "/models" && method === "GET") return json(models); + if (path === "/models" && method === "POST") return json(models); + if (path === "/models/order") return json(models); + if (path === "/models/discover") return json({ models: models.map((model) => model.model_id) }); + if (path === "/models/import-v0049" && method === "GET") { + return json({ source: "demo", total: 0, new_models: 0, existing_models: 0, models: [] }); + } + if (path === "/models/import-v0049") return json({ imported: 0, skipped: 0, total: 0 }); + if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) && method === "POST") { + return json({ duration_ms: 1_284, first_text_ms: 418, output_tokens: 42, tokens_per_second: 38.6, tokens_estimated: false, output: "Mock connectivity test passed." }); + } + if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) || /^\/models\/[^/]+$/.test(path)) { + return method === "DELETE" ? empty() : json(models[0]); + } + if (path === "/overview") return json(createOverview(url.searchParams)); + if (path === "/llm-calls") return json(calls); + if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length))); + if (path === "/harness/cursor/status") return json(harnessStatus); + if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus); + if (path === "/settings/observability" && method === "GET") return json({ detailed }); + if (path === "/settings/observability") { + detailed = Boolean((body as { detailed?: unknown } | null)?.detailed); + return json({ detailed }); + } + if (path === "/settings/ports" && method === "GET") return json(portSettings); + if (path === "/settings/ports") { + portSettings = body as typeof portSettings; + return json(portSettings); + } + if (path === "/settings/storage/statistics" && method === "GET") return json(storage); + if (path === "/settings/storage/statistics") { + storage = { bytes: 0, call_count: 0, trace_count: 0 }; + return json(storage); + } + if (path === "/settings/proxy" && method === "GET") return json(proxySettings); + if (path === "/settings/proxy") { + const next = body as Partial; + proxySettings = { ...proxySettings, ...next, has_password: Boolean(next.has_password) }; + return json(proxySettings); + } + if (path === "/settings/tab" && method === "GET") return json(tabSettings); + if (path === "/settings/tab") { + tabSettings = body as TabSettings; + return json(tabSettings); + } + if (path === "/settings/desktop" && method === "GET") return json({ silent_start: false, show_dock_icon: true }); + if (path === "/settings/desktop") return json(body); + if (path === "/desktop/open-external-url") { + const target = (body as { url?: string } | null)?.url; + if (target) { + const next = new URL(target, window.location.href); + if (next.origin === window.location.origin && next.hash) window.location.hash = next.hash; + } + return empty(); + } + if (path.endsWith("/dismissals")) return empty(); + + return json({ message: `Unhandled demo endpoint: ${method} ${path}` }, 404); + }; +} + +function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/responses" }: { + hash: string; + order: number; + name: string; + type: Model["type"]; + url: string; + modelId: string; + endpoint?: string; +}): Model { + return { + model_hash: hash, + sort_order: order, + display_name: name, + type, + base_url: url, + use_full_url: false, + api_key: "demo-key", + tooltip_data: `${name} Mock 通道`, + model_id: modelId, + reasoning_effort: type === "openai" ? "high" : null, + openai_endpoint: type === "openai" ? endpoint : "", + openai_extra_params_enabled: false, + openai_extra_params: {}, + custom_headers_enabled: false, + custom_headers: {}, + anthropic_extra_params_enabled: false, + anthropic_extra_params: {}, + context_window_tokens: 200_000, + max_completion_tokens: type === "openai" ? 32_000 : null, + anthropic_max_tokens: type === "anthropic" ? 32_000 : null, + anthropic_thinking_effort: type === "anthropic" ? "high" : null, + thinking_budget_tokens: null, + created_at_ms: FIXED_NOW - order * 86_400_000, + updated_at_ms: FIXED_NOW, + }; +} + +function createOverview(params: URLSearchParams): Overview { + const start = Number(params.get("start_ms")); + const end = Number(params.get("end_ms")); + const duration = Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : 365 * 86_400_000; + const granularity = duration <= 2 * 60 * 60_000 ? "minute" : duration <= 2 * 86_400_000 ? "hour" : "day"; + const step = granularity === "minute" ? 60_000 : granularity === "hour" ? 3_600_000 : 86_400_000; + const count = granularity === "minute" ? Math.min(60, Math.max(10, Math.ceil(duration / step))) : granularity === "hour" ? Math.min(24, Math.max(8, Math.ceil(duration / step))) : Math.min(365, Math.max(7, Math.ceil(duration / step))); + const series = createSeries(count, step, Number.isFinite(end) && end > 0 ? end : FIXED_NOW); + const totals = series.reduce((sum, bucket) => ({ + input: sum.input + bucket.input_tokens, + cacheRead: sum.cacheRead + bucket.cache_read_tokens, + cacheWrite: sum.cacheWrite + bucket.cache_write_tokens, + output: sum.output + bucket.output_tokens, + }), { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }); + const llmCalls = Math.max(12, Math.round(count * 5.4)); + + return { + metrics: { + llm_calls: llmCalls, + successful_calls: llmCalls - Math.max(1, Math.floor(llmCalls * 0.008)), + failed_calls: Math.max(1, Math.floor(llmCalls * 0.008)), + token_usage: totals.input + totals.cacheRead + totals.cacheWrite + totals.output, + prompt_tokens: totals.input + totals.cacheRead + totals.cacheWrite, + input_tokens: totals.input, + cache_read_tokens: totals.cacheRead, + cache_write_tokens: totals.cacheWrite, + output_tokens: totals.output, + }, + token_usage_granularity: granularity, + token_usage_series: series, + }; +} + +function createSeries(count: number, step: number, end: number): OverviewTokenUsageBucket[] { + return Array.from({ length: count }, (_, index) => { + const wave = 0.72 + ((index * 17) % 31) / 50; + return { + bucket_start_ms: end - (count - index) * step, + input_tokens: Math.round(18_000 * wave), + cache_read_tokens: Math.round(62_000 * wave), + cache_write_tokens: Math.round(7_500 * wave), + output_tokens: Math.round(12_500 * wave), + }; + }); +} + +function createCallDetail(id: string): CallDetail { + const call = calls.find((item) => item.call_id === decodeURIComponent(id)) ?? calls[0]; + return { + call, + request: { + headers: { authorization: "Bearer sk-demo-••••", "content-type": "application/json" }, + body: { model: call.model_id, stream: true, messages: [{ role: "user", content: "Mock Agent request" }] }, + byte_count: 8_426, + }, + response_chunks: [ + { seq: 1, received_offset_ms: 418, data: "event: response.created", byte_count: 128 }, + { seq: 2, received_offset_ms: 512, data: "event: response.output_text.delta", byte_count: 256 }, + ], + cursor_trace: null, + }; +} + +async function readBody(input: RequestInfo | URL, init?: RequestInit): Promise { + const raw = init?.body ?? (input instanceof Request ? await input.clone().text() : null); + if (typeof raw !== "string" || raw.length === 0) return null; + try { return JSON.parse(raw) as unknown; } + catch { return raw; } +} + +function json(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function empty() { + return new Response(null, { status: 204 }); +} diff --git a/apps/desktop/src/demo/index.tsx b/apps/desktop/src/demo/index.tsx new file mode 100644 index 0000000..3a4164a --- /dev/null +++ b/apps/desktop/src/demo/index.tsx @@ -0,0 +1,8 @@ +import { installDemoApi } from "./api"; + +installDemoApi(); +document.documentElement.dataset.platform = "macos"; +localStorage.setItem("cursor-byok.locale", "zh-CN"); +localStorage.setItem("cursor-byok.theme", "default-dark"); + +void import("../index"); diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index 6c6d5ea..a278fc2 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -516,7 +516,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 120, + "line": 156, "column": 27 } ] @@ -735,7 +735,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 72, + "line": 96, "column": 29 } ] @@ -773,7 +773,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 116, + "line": 152, "column": 13 } ] @@ -1095,18 +1095,6 @@ } ] }, - "393df9bb13ea4900": { - "source": "命中", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "components/metrics/CacheHitRateChart.tsx", - "line": 60, - "column": 14 - } - ] - }, "393e1241552b1870": { "source": "请求", "kind": "text", @@ -1196,6 +1184,18 @@ } ] }, + "3b67824289b5fa1e": { + "source": "已隐藏 Dock 栏图标", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 85, + "column": 45 + } + ] + }, "3c94b4c75940c178": { "source": "输入 Token", "kind": "text", @@ -1392,7 +1392,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 102, + "line": 126, "column": 17 } ] @@ -1432,7 +1432,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 115, + "line": 151, "column": 13 } ] @@ -1868,7 +1868,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 48, + "line": 59, "column": 40 } ] @@ -2004,7 +2004,7 @@ "refs": [ { "file": "api.ts", - "line": 314, + "line": 315, "column": 43 } ] @@ -2016,7 +2016,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 113, + "line": 149, "column": 18 } ] @@ -2095,6 +2095,23 @@ } ] }, + "680680288a6d2ad2": { + "source": "在 Dock 栏显示", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 137, + "column": 18 + }, + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 143, + "column": 16 + } + ] + }, "68ad603fafe4e0d6": { "source": "导入旧版配置", "kind": "text", @@ -2189,7 +2206,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 72, + "line": 96, "column": 78 } ] @@ -2280,7 +2297,7 @@ "refs": [ { "file": "api.ts", - "line": 309, + "line": 310, "column": 43 } ] @@ -2395,7 +2412,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 124, + "line": 160, "column": 37 } ] @@ -2407,7 +2424,7 @@ "refs": [ { "file": "api.ts", - "line": 258, + "line": 259, "column": 21 } ] @@ -2446,12 +2463,12 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 89, + "line": 113, "column": 18 }, { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 95, + "line": 119, "column": 16 } ] @@ -2475,7 +2492,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 61, + "line": 72, "column": 25 } ] @@ -2810,7 +2827,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 61, + "line": 72, "column": 40 } ] @@ -2896,7 +2913,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 86, + "line": 110, "column": 29 } ] @@ -2908,12 +2925,12 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 101, + "line": 125, "column": 18 }, { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 107, + "line": 131, "column": 16 } ] @@ -3142,7 +3159,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 48, + "line": 59, "column": 25 } ] @@ -3166,7 +3183,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 120, + "line": 156, "column": 39 } ] @@ -3199,6 +3216,18 @@ } ] }, + "aecb952b1e6cce36": { + "source": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 138, + "column": 17 + } + ] + }, "aee88743413144a2": { "source": "刷新", "kind": "text", @@ -3308,6 +3337,18 @@ } ] }, + "b6725f218ebaef26": { + "source": "已显示 Dock 栏图标", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 85, + "column": 25 + } + ] + }, "b75a46aad3e7c132": { "source": "非缓存输入:{tokens}", "kind": "template", @@ -3493,7 +3534,7 @@ "refs": [ { "file": "components/metrics/CacheHitRateChart.tsx", - "line": 71, + "line": 58, "column": 62 } ] @@ -3724,7 +3765,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 90, + "line": 114, "column": 17 } ] @@ -4323,18 +4364,6 @@ } ] }, - "f3a76d896853c1df": { - "source": "未命中", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "components/metrics/CacheHitRateChart.tsx", - "line": 60, - "column": 23 - } - ] - }, "f4694c46b1e19602": { "source": "最终请求类型", "kind": "text", @@ -4493,7 +4522,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 124, + "line": 160, "column": 25 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 317ebfa..af4c6f1 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -71,7 +71,6 @@ "37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required", "378bb0eec39fa8a2": "Last page", "37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}", - "393df9bb13ea4900": "Hit", "393e1241552b1870": "Request", "398f8e6c6f0a0b97": "Continue selecting or typing", "39f52eee100131d7": "Cached input", @@ -79,6 +78,7 @@ "3a3f595df70ec8ff": "Clear storage", "3a5040b68abf75f9": "Select all", "3a8c76b2ce785f96": "Review and import", + "3b67824289b5fa1e": "Dock icon hidden", "3c94b4c75940c178": "Input Tokens", "3cfae5728b92b334": "Token usage: {tokens}", "3d13868593ae4eeb": "Display language", @@ -143,6 +143,7 @@ "656ab25e264cc4e4": "No models are available to Cursor yet", "65a6318e07ec1e07": "Tools", "65cb9a7b4f620b6b": "Prompt {tokens}", + "680680288a6d2ad2": "Show in Dock", "68ad603fafe4e0d6": "Import legacy configuration", "68ea5dd4d7af20e6": "System settings", "6a9906c79f26c0ba": "Start time", @@ -222,12 +223,14 @@ "ac69f68b7010ec79": "Download and install", "ad6a60ee93d3ba3e": "Loading call details…", "ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}", + "aecb952b1e6cce36": "Hide the Dock icon when disabled. You can still open the app from the menu bar icon.", "aee88743413144a2": "Refresh", "b06325c5660f0c29": "Direct", "b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.", "b4411558b932266f": "Provider type", "b502b1d414664337": "Prompt: {tokens}", "b5141d3d19e9a048": "Yes", + "b6725f218ebaef26": "Dock icon shown", "b75a46aad3e7c132": "Non-cached input: {tokens}", "b79354009c614ae9": "Statistics", "b86967982067d295": " (estimated)", @@ -303,7 +306,6 @@ "ee239f3943293f87": "Sunday", "ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.", "f2bdc88464c51c2e": "Show API Key", - "f3a76d896853c1df": "Miss", "f4694c46b1e19602": "Final request type", "f4dcb6a3ceb32247": "Page {page} of {count}", "f4fa9f31ea2ae58d": "Token usage calendar for the past year", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 6682ace..caac4a4 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -71,7 +71,6 @@ "37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空", "378bb0eec39fa8a2": "最后一页", "37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}", - "393df9bb13ea4900": "命中", "393e1241552b1870": "请求", "398f8e6c6f0a0b97": "继续选择或输入", "39f52eee100131d7": "缓存输入", @@ -79,6 +78,7 @@ "3a3f595df70ec8ff": "清理存储空间", "3a5040b68abf75f9": "全选", "3a8c76b2ce785f96": "查看并导入", + "3b67824289b5fa1e": "已隐藏 Dock 栏图标", "3c94b4c75940c178": "输入 Token", "3cfae5728b92b334": "Token 用量:{tokens}", "3d13868593ae4eeb": "界面语言", @@ -143,6 +143,7 @@ "656ab25e264cc4e4": "还没有可供 Cursor 使用的模型", "65a6318e07ec1e07": "工具数", "65cb9a7b4f620b6b": "提示词 {tokens}", + "680680288a6d2ad2": "在 Dock 栏显示", "68ad603fafe4e0d6": "导入旧版配置", "68ea5dd4d7af20e6": "系统设置", "6a9906c79f26c0ba": "开始时间", @@ -222,12 +223,14 @@ "ac69f68b7010ec79": "下载并安装", "ad6a60ee93d3ba3e": "正在加载调用详情…", "ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}", + "aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。", "aee88743413144a2": "刷新", "b06325c5660f0c29": "直连", "b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。", "b4411558b932266f": "上游类型", "b502b1d414664337": "提示词:{tokens}", "b5141d3d19e9a048": "是", + "b6725f218ebaef26": "已显示 Dock 栏图标", "b75a46aad3e7c132": "非缓存输入:{tokens}", "b79354009c614ae9": "统计数据", "b86967982067d295": "(估算)", @@ -303,7 +306,6 @@ "ee239f3943293f87": "周日", "ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。", "f2bdc88464c51c2e": "显示 API Key", - "f3a76d896853c1df": "未命中", "f4694c46b1e19602": "最终请求类型", "f4dcb6a3ceb32247": "第 {page} / {count} 页", "f4fa9f31ea2ae58d": "过去一年的 Token 用量日历", diff --git a/apps/desktop/src/layouts/AppFrame.tsx b/apps/desktop/src/layouts/AppFrame.tsx index 969fd1b..5b84a4c 100644 --- a/apps/desktop/src/layouts/AppFrame.tsx +++ b/apps/desktop/src/layouts/AppFrame.tsx @@ -1,4 +1,5 @@ import { Outlet } from "react-router-dom"; +import { desktopPlatform } from "../native/platform"; import styles from "./AppFrame.module.scss"; import { AppHeader } from "./AppHeader"; @@ -17,13 +18,3 @@ export function AppFrame() {
); } - -export type DesktopPlatform = "macos" | "windows" | "linux"; - -function desktopPlatform(): DesktopPlatform { - // return "windows"; - const agent = navigator.userAgent; - if (/Macintosh|Mac OS X/.test(agent)) return "macos"; - if (/Windows/.test(agent)) return "windows"; - return "linux"; -} diff --git a/apps/desktop/src/layouts/AppHeader.tsx b/apps/desktop/src/layouts/AppHeader.tsx index accf2c8..2af714a 100644 --- a/apps/desktop/src/layouts/AppHeader.tsx +++ b/apps/desktop/src/layouts/AppHeader.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import appIcon from "../../src-tauri/icons/32x32.png"; import { currentAppVersion } from "../native/appLifecycle"; -import type { DesktopPlatform } from "./AppFrame"; +import type { DesktopPlatform } from "../native/platform"; import { WindowControls } from "./WindowControls"; +import { MacTrafficLights } from "./MacTrafficLights"; import styles from "./AppHeader.module.scss"; type AppHeaderProps = { @@ -11,6 +12,7 @@ type AppHeaderProps = { }; export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) { + const showMacTrafficLights = !nativeDesktop && platform === "macos"; const showNativeUi = nativeDesktop && platform !== "macos"; const [version, setVersion] = useState("…"); @@ -25,6 +27,7 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) { return