Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d6a501a0 | ||
|
|
82f3ae8d59 | ||
|
|
31dfa8d295 | ||
|
|
995a12df44 | ||
|
|
7058d0193d | ||
|
|
eff642fdcf | ||
|
|
2c95d7f836 | ||
|
|
75babb7334 | ||
|
|
f6be6c2b27 | ||
|
|
ded534fecf | ||
|
|
5450fc76e2 | ||
|
|
df053c3720 | ||
|
|
9deb42915c | ||
|
|
1bc1c3d978 | ||
|
|
e1937233ec | ||
|
|
34f97334dc | ||
|
|
ab7b5e8c00 | ||
|
|
78ff002aad | ||
|
|
847e92c7ea | ||
|
|
ae31757635 | ||
|
|
a773888852 | ||
|
|
bdf4b923d7 | ||
|
|
d95273c49b | ||
|
|
081e1f50e2 | ||
|
|
5f87357681 | ||
|
|
7dded0d81f | ||
|
|
283f2296e7 | ||
|
|
b61059590f | ||
|
|
5509ac45ca | ||
|
|
cfd1e740b2 | ||
|
|
65edad6a21 | ||
|
|
1368a587ca | ||
|
|
58daa91c90 | ||
|
|
eb26b17ba0 | ||
|
|
95fb9be967 | ||
|
|
89631bdab0 | ||
|
|
fe17e15e75 | ||
|
|
24177fcb6e | ||
|
|
7fa4953883 | ||
|
|
4bb4ca5c8d | ||
|
|
4ddd3adb3f | ||
|
|
1a0cf89fe1 | ||
|
|
81a1afdae9 | ||
|
|
6170778de9 | ||
|
|
0f23a9a9c3 | ||
|
|
8a4fc5076a | ||
|
|
b5199de6f1 | ||
|
|
b74291af79 | ||
|
|
2075b61763 | ||
|
|
03075952a6 | ||
|
|
4c2efb05c2 | ||
|
|
2abffcc835 | ||
|
|
4053a7fb20 | ||
|
|
564f2bdcae | ||
|
|
77c8e31a09 | ||
|
|
eb68375306 | ||
|
|
e9d3845de0 | ||
|
|
d4bfa8b938 | ||
|
|
dead4477be | ||
|
|
b7f8237f3b | ||
|
|
5d04b5b08b | ||
|
|
a90c8d9560 | ||
|
|
e9dc2e098d | ||
|
|
a3ec2a0dfc | ||
|
|
38472a3c63 | ||
|
|
2b8d1c9e3d | ||
|
|
3fc04eeb4f | ||
|
|
7a67e76617 | ||
|
|
08ce12aa55 | ||
|
|
8f8d28880d | ||
|
|
988ba63d40 | ||
|
|
edd59dc684 | ||
|
|
da5fa34a4d | ||
|
|
4bd2359282 | ||
|
|
7838ffc6a2 | ||
|
|
ee30775be1 | ||
|
|
80a5093aa9 | ||
|
|
b9742b1667 | ||
|
|
7d3e74be59 | ||
|
|
1285bf9d62 | ||
|
|
7a724595eb | ||
|
|
00aec40e50 | ||
|
|
c10a2d475d | ||
|
|
4864d3675b | ||
|
|
3f95318a49 | ||
|
|
9373e57ebf | ||
|
|
f1992b0cfe | ||
|
|
67a9c27931 | ||
|
|
3cf8bdbc3c | ||
|
|
684953a80b | ||
|
|
297b56aed0 | ||
|
|
2393df1cb8 | ||
|
|
7d622dd039 | ||
|
|
1a7a20c519 | ||
|
|
9eb24bb4d4 | ||
|
|
426bdd6592 | ||
|
|
fa36dc2c60 | ||
|
|
4e649d676f | ||
|
|
917a711b42 | ||
|
|
270bcdb54e | ||
|
|
c6d719d9b9 | ||
|
|
a7f9494828 | ||
|
|
225de8bb85 | ||
|
|
5bd39ed317 | ||
|
|
26502ebec8 | ||
|
|
2e265d415e | ||
|
|
85a43115c7 | ||
|
|
b475166ba8 | ||
|
|
0d1cedda5b | ||
|
|
c7fe198110 | ||
|
|
8c4baf7ade | ||
|
|
4e0d2ee703 | ||
|
|
e1dacab268 | ||
|
|
da15109312 | ||
|
|
320b9fb7b9 | ||
|
|
c55c575a58 | ||
|
|
d3adfffbd8 | ||
|
|
4e9335d82f | ||
|
|
374ff9c217 | ||
|
|
06ab0d8dae | ||
|
|
2f47f02497 | ||
|
|
058aaa532e | ||
|
|
622a57fed7 | ||
|
|
f5a7347f44 | ||
|
|
a9b406ec30 | ||
|
|
639c452a00 | ||
|
|
0c9a07e018 | ||
|
|
1adca7f600 | ||
|
|
5df3e8f512 | ||
|
|
a2e3a07773 | ||
|
|
6b7c077448 | ||
|
|
c64911e8c6 | ||
|
|
d1d56bd6eb | ||
|
|
ee47f187f0 | ||
|
|
726f082e6b | ||
|
|
424235f11d | ||
|
|
58770aa206 | ||
|
|
eb0ad7b636 | ||
|
|
697fa99245 | ||
|
|
1854a3ab9a | ||
|
|
5742073be5 | ||
|
|
90d35085c0 | ||
|
|
7764eca0c3 | ||
|
|
a5437e60fe | ||
|
|
674de0b041 | ||
|
|
d1ffd1a95c | ||
|
|
9d316c0b3d | ||
|
|
834288839b | ||
|
|
68e63c7a72 | ||
|
|
2c650b3765 | ||
|
|
14b286a466 | ||
|
|
828e0a9941 | ||
|
|
00f52166b2 | ||
|
|
3349b13a2b | ||
|
|
4451a6df3b | ||
|
|
7b1c1d39af | ||
|
|
17f30a8852 | ||
|
|
1f976b2a5e | ||
|
|
f3a7aece9d | ||
|
|
3a3be0c9e9 | ||
|
|
9e05739969 | ||
|
|
4bb0cbc1fc | ||
|
|
0f6b0f9484 | ||
|
|
e401303f64 | ||
|
|
799dbda7e0 | ||
|
|
f3cbf1e6c3 | ||
|
|
2380a0d692 | ||
|
|
1da1faa4d4 | ||
|
|
ce05b0bf7a | ||
|
|
44a47992d9 | ||
|
|
319bd3d7d8 | ||
|
|
12c7973a06 | ||
|
|
f93b18740d | ||
|
|
c6ee26b12d | ||
|
|
7c1c6a134b | ||
|
|
e0aa270328 | ||
|
|
556ea21f38 | ||
|
|
315aba49c2 | ||
|
|
6ab537ad4e | ||
|
|
6d7a4e32fe | ||
|
|
50ab63de3d | ||
|
|
802daa8fcd | ||
|
|
eb2c236699 | ||
|
|
86a655fb5d | ||
|
|
c50e7d0606 | ||
|
|
4ec7b4289f | ||
|
|
6bebe37bfc | ||
|
|
90c3c35774 | ||
|
|
64a0e09605 | ||
|
|
47d336ac81 | ||
|
|
268e9fbde0 | ||
|
|
7d89b0af2a | ||
|
|
c083be5ec2 |
@@ -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.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: database-schema
|
||||
description: Implement and review Cursor BYOK SQLite schema changes. Use when adding or changing tables, columns, indexes, constraints, foreign keys, SQLx migrations, persistence mappings, or database fixtures under server.
|
||||
---
|
||||
|
||||
# Cursor BYOK Database Schema
|
||||
|
||||
Treat a schema change as an end-to-end persistence change, not as an isolated SQL edit. Keep the database, Rust store, API contracts, fixtures, and tests aligned.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use the existing layers and keep responsibilities in their current directories:
|
||||
|
||||
```text
|
||||
server/
|
||||
├── migrations/ Ordered SQLite/SQLx migrations
|
||||
├── src/store/ Queries, bindings, row decoding, transactions
|
||||
├── src/ Domain and API types that consume persisted data
|
||||
└── tests/ Migration, store, API, and integration coverage
|
||||
```
|
||||
|
||||
Before editing, inspect the complete table definition, every query that reads or writes it, its Rust types, API projections, and relevant fixtures. Search by the table name and affected field names; do not infer the persistence path from one file.
|
||||
|
||||
## Migration rules
|
||||
|
||||
- `server/src/store/sqlite.rs` runs embedded SQLx migrations from `server/migrations`.
|
||||
- Never modify, rename, reorder, or delete a migration that may already have been applied. SQLx records its checksum; changing an applied file causes startup failure with `migration ... was previously applied but has been modified`.
|
||||
- Add the next numbered forward migration, using a descriptive filename such as `0003_add_request_protocol.sql`.
|
||||
- A fresh database must reach the current schema by applying all migrations in order. Do not duplicate a new column or table in both the initial migration and a later migration.
|
||||
- Only squash or rewrite migration history when the user explicitly asks for a full database reset and accepts that existing databases will no longer start. Do not infer that permission from a development-only workflow.
|
||||
- Do not add compatibility views, triggers, shadow fields, or fallback reads. Migrate once, then make the application consume the new schema directly.
|
||||
- Keep one coherent schema change together. Split unrelated changes into separate migrations.
|
||||
|
||||
## SQLite design
|
||||
|
||||
- Choose nullability from domain meaning. Use `NULL` for genuinely unknown historical data; use a default only when it is correct for every existing row.
|
||||
- Store booleans as constrained integers, for example `INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1))`.
|
||||
- Add `CHECK`, `UNIQUE`, and foreign-key constraints when they express real invariants. Choose `ON DELETE` behavior deliberately.
|
||||
- Add an index only for a demonstrated lookup, join, ordering, or uniqueness requirement. Match its leading columns to actual query shapes.
|
||||
- Use a transaction for changes that must update multiple tables atomically.
|
||||
- SQLite supports only limited `ALTER TABLE`. For an unsupported constraint, type, or destructive column change, create the replacement table with the final schema, copy and transform data, replace the old table, and recreate required indexes and foreign keys in one migration.
|
||||
- Preserve timestamps, identifiers, and existing semantic values during table rebuilds. Do not silently manufacture domain data.
|
||||
|
||||
## Application changes
|
||||
|
||||
Trace every changed field through the full path that applies:
|
||||
|
||||
1. Migration SQL and constraints.
|
||||
2. Rust domain/request/response structs.
|
||||
3. SQL column lists, placeholders, `.bind(...)` order, row decoding, and update statements.
|
||||
4. Transactions and repository/store methods.
|
||||
5. API serialization and frontend TypeScript types when the field is exposed.
|
||||
6. UI creation, editing, listing, and details when requested by the product behavior.
|
||||
7. Test fixtures, literal struct initializers, snapshots, and mock rows.
|
||||
|
||||
List SQL columns explicitly. Keep selected-column order, row decoding, insert columns, and bind order visibly aligned. Avoid `SELECT *` because schema additions can silently invalidate positional decoding assumptions.
|
||||
|
||||
When a value records the effective behavior of a call, persist the value actually consumed at execution time rather than merely the model or provider default. Keep absent, defaulted, and explicitly supplied values distinguishable when that distinction matters.
|
||||
|
||||
## Verification
|
||||
|
||||
Add focused coverage proportional to the change:
|
||||
|
||||
- A fresh database applies every migration.
|
||||
- A database at the previous migration upgrades successfully and preserves existing rows.
|
||||
- Store create/read/update paths round-trip the new fields.
|
||||
- Defaults, nullability, uniqueness, checks, and foreign keys behave as designed.
|
||||
- Multi-table writes roll back atomically on failure.
|
||||
- API and frontend types expose the same semantics when applicable.
|
||||
|
||||
Run the narrow tests first, then the repository checks affected by the change. At minimum for server schema work, run:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
If frontend contracts changed, also run from `apps/desktop`:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
Do not repair unrelated dirty-worktree changes while validating. Report any pre-existing failure separately from failures caused by the schema change.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: floating-ui
|
||||
description: Implement or review floating desktop UI such as dropdowns, popovers, tooltips, menus, comboboxes, and nested overlays under apps/desktop.
|
||||
---
|
||||
|
||||
# Floating UI
|
||||
|
||||
Use `@floating-ui/dom` for every interactive element positioned relative to a trigger. Do not calculate coordinates manually or rely on CSS absolute offsets for dropdowns, popovers, menus, combobox lists, or tooltips.
|
||||
|
||||
## Required structure
|
||||
|
||||
- Render overlays through `createPortal(..., document.body)` so layout and stacking contexts do not clip them.
|
||||
- Position with `computePosition` inside `autoUpdate`; clean up the function returned by `autoUpdate` when the overlay closes or unmounts.
|
||||
- Start from the closest existing control in `apps/desktop/src/components/ui`. Match its `placement`, `offset`, `flip`, `shift`, and `size` middleware unless the interaction requires a deliberate difference.
|
||||
- Store computed `left`, `top`, width, and available height in React state. Apply the state to the portal root; do not mutate element styles directly.
|
||||
- Use `size` when reference width or viewport height constrains the overlay. Lists that can grow must use `components/virtual/VirtualList.tsx`; keep fixed headers and footers outside the virtual viewport.
|
||||
|
||||
## Interaction invariants
|
||||
|
||||
- Keep the trigger's open/focus border visible while focus is inside a portaled overlay.
|
||||
- Close on outside pointer interaction and Escape, then return focus to the trigger.
|
||||
- Outside-click checks must include both trigger and overlay. A nested portaled overlay must stop its pointer event from reaching the parent's outside-click listener, so interacting with a child menu never closes its parent.
|
||||
- Expose trigger state with `aria-expanded`, `aria-controls`, and the appropriate `aria-haspopup`; give the overlay the matching menu, listbox, dialog, or tooltip semantics.
|
||||
- Verify placement near all viewport edges, nested-overlay clicks, keyboard dismissal, virtual scrolling, and fixed footer behavior.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: frontend
|
||||
description: Implement and review the Cursor BYOK React/Tauri desktop frontend. Use for changes under apps/desktop involving routing, page layout, window chrome, settings UI, scrolling, virtualization, charts, themes, or frontend component architecture.
|
||||
---
|
||||
|
||||
# Cursor BYOK Frontend
|
||||
|
||||
Build on the components and theme system already present in `apps/desktop`. Preserve the Tauri HTTP boundary; frontend management features call `/__byok-api__/api` and never add IPC business APIs.
|
||||
|
||||
## Desktop HTTP communication
|
||||
|
||||
- Treat the Rust management server as the single HTTP origin for the desktop UI. The main WebView and browser-opened detail pages must use `http://127.0.0.1:<dynamic-port>/__byok-api__/`; do not load the main UI from `tauri://localhost` or expose a second frontend origin.
|
||||
- Make every frontend management request relative to `/__byok-api__/api`. Do not discover, inject, persist, or pass an `apiOrigin`, and do not construct management URLs from a fixed port.
|
||||
- Keep the `/__byok-api__/` namespace reserved for this boundary:
|
||||
- `/__byok-api__/api/*` is handled locally by the Rust management API.
|
||||
- Other `/__byok-api__/*` paths are frontend documents, assets, modules, and development resources.
|
||||
- In production, serve the frontend embedded by Tauri's `frontendDist` through the Rust server. Reuse Tauri's asset resolver rather than bundling or copying a second set of frontend resources.
|
||||
- In development, let the Rust server reverse-proxy non-API `/__byok-api__/*` requests to Vite. Preserve the request path and query string. Do not configure Vite to proxy management API requests back to Rust.
|
||||
- Configure Vite's base path as `/__byok-api__/` so generated assets, module imports, and development client URLs stay under the reserved namespace.
|
||||
- Bind the development Vite server to an explicit loopback address compatible with the Rust proxy target; do not rely on `localhost` resolving to the same IP family.
|
||||
- Open external detail pages with their normal loopback HTTP URL and hash route. Because the page and API are same-origin, do not add Tauri URL workarounds, API-origin query parameters, or CORS-dependent browser flows.
|
||||
- Use Tauri commands only for native desktop capabilities such as opening Terminal, tray integration, or operating-system actions. Do not create IPC mirrors for HTTP business APIs.
|
||||
|
||||
## Component boundaries
|
||||
|
||||
- Keep Provider, Model, Call, Usage, and persisted settings state outside presentation components.
|
||||
- Let components receive business snapshots and dispatch actions. Local React state is allowed for transient UI behavior such as focus, open state, drag state, and input drafts.
|
||||
- Split pages, layouts, charts, forms, and reusable controls into focused components. Do not collect an entire feature set in `App.tsx`.
|
||||
- Keep `App.tsx` as the route composition root.
|
||||
|
||||
## Page layout
|
||||
|
||||
- Build every full-height page with `components/layout/PageLayout.tsx`; do not hand-roll page shells with grid rows.
|
||||
- Put page-level commands and controls such as create, import, export, filtering, sorting, and view options in the shared page action region through `PageActions`. Keep only operations that target one concrete list item, such as edit and delete, inside that item's row. Do not add duplicate action bars or panel-header controls inside page content.
|
||||
- Never render a title region above a data table or flat data list. Use the page title for context, keep column labels in the table header, place page-level controls in `PageActions`, and wrap the table in a titleless `Card`. A hierarchy label that identifies parent data in a grouped parent-child view is not a table title.
|
||||
- Keep the main scroll viewport full-height and `position: relative`; place the page title and action region absolutely over it.
|
||||
- Reserve the absolute title/action region with `padding-top` on the scroll content, never by shortening or offsetting the scroll viewport itself.
|
||||
- Keep the menu Card fixed and reserve it with the content area's `margin-left`; do not place Header, menu, and content in a flow grid.
|
||||
- Do not use child or section padding to create external spacing. Use flex/grid gaps between siblings and margins or fixed offsets at container boundaries; the scroll-content top padding is reserved only for its absolute title/action overlay.
|
||||
- Use a vertical flex layout with these invariants:
|
||||
- Header: `flex: 0 0 auto`.
|
||||
- Footer: `flex: 0 0 auto`.
|
||||
- Content: `flex: 1 1 auto`, `min-height: 0`, and `overflow: hidden`.
|
||||
- Keep Header and Footer outside the scroll viewport. They must never grow or shrink with content.
|
||||
- Apply macOS, Windows, and Linux window-corner safe-area variables only to edge chrome that can enter native rounded corners.
|
||||
- Keep macOS traffic-light space and the top fixed drag strip free of interactive controls.
|
||||
- Avoid decorative brand text and explanatory copy when it does not help the user perform an action or interpret state.
|
||||
|
||||
## Typography
|
||||
|
||||
- Write component styles in SCSS and source every `font-size` from `src/styles/_typography.scss`; never hardcode a font size in SCSS, React styles, or chart options.
|
||||
- Use `base` for normal UI text and ordinary titles, including title bars, panels, cards, and section headings. Do not increase a font merely because its element is `h1` or `h2`.
|
||||
- Use `xs` only for secondary information. Reserve `lg` for an explicitly oversized display title; do not use it for routine headings.
|
||||
- Audit all typography-token usages after changing the scale, not only raw numeric font sizes.
|
||||
|
||||
## Scrolling and virtualization
|
||||
|
||||
- Use `components/layout/VirtualPage.tsx` for vertically scrolling page content.
|
||||
- Use `components/virtual/VirtualList.tsx` for data collections and menus that can grow.
|
||||
- Treat `ScrollArea` as the low-level primitive owned by the virtual scrolling implementation. Do not import `ScrollArea` directly in pages or layouts.
|
||||
- Use the global `scroll-shadow-top` and `scroll-shadow-bottom` masks for scroll-edge shadows. When a scroll content inset reserves an absolute overlay, source the top shadow distance from the same CSS variable as its `padding-top`.
|
||||
- Model non-list pages as a short sequence of measurable top-level virtual sections.
|
||||
- Do not use document scrolling, native page `overflow: auto`, or nested vertical scroll containers.
|
||||
- Ensure every flex/grid ancestor of a virtual viewport has `min-height: 0` and the viewport has an explicit bounded height.
|
||||
|
||||
## Routing and pages
|
||||
|
||||
- Use `HashRouter` for Tauri compatibility.
|
||||
- Keep `/` as the statistics dashboard and expose every primary page through the persistent menu in the shared `AppLayout`.
|
||||
- Use top-level routes for primary pages: `/`, `/providers`, `/models`, `/calls`, and `/settings`.
|
||||
- Do not create separate Home and Settings shells or add a Home-switching control.
|
||||
- Keep route content independent of the window Header and Footer.
|
||||
|
||||
## Charts
|
||||
|
||||
- Use ECharts through `components/charts/EChart.tsx`.
|
||||
- Register only required ECharts Core charts, components, and renderers.
|
||||
- Keep chart components declarative: accept domain data and construct an option without fetching or persisting data.
|
||||
- Let `ResizeObserver` resize the chart with its layout container and dispose the instance on unmount.
|
||||
|
||||
## Validation
|
||||
|
||||
Run from `apps/desktop`:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run tauri:build -- --debug --no-bundle
|
||||
```
|
||||
|
||||
Verify that Header and Footer remain fixed, only the virtual content viewport scrolls, settings navigation remains reachable at minimum window size, and macOS controls stay outside rounded-corner and traffic-light unsafe regions.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Cursor BYOK Frontend"
|
||||
short_description: "Build consistent React desktop layouts and virtualized pages"
|
||||
default_prompt: "Use $frontend to implement or review the Cursor BYOK React desktop interface."
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: i18n
|
||||
description: Implement and review Cursor BYOK desktop localization, including t() usage, locale catalogs, system-language selection, translation completeness, and language settings under apps/desktop.
|
||||
---
|
||||
|
||||
# Desktop i18n
|
||||
|
||||
Keep localization organized as:
|
||||
|
||||
```text
|
||||
apps/desktop/
|
||||
├── plugins/static-i18n-plugin.ts
|
||||
└── src/i18n/
|
||||
├── I18nRoot.tsx
|
||||
├── runtime.ts
|
||||
├── store.ts
|
||||
├── generated/catalog.json
|
||||
└── locales/
|
||||
├── zh-CN.json
|
||||
└── en-US.json
|
||||
```
|
||||
|
||||
## Author messages
|
||||
|
||||
- Use the global `t()` function with a static Simplified Chinese string literal: `t("保存")`.
|
||||
- Do not import or locally declare `t`.
|
||||
- Keep calls inside render paths when the result must update after a language change. Do not translate module-level UI constants.
|
||||
- Use named placeholders with a static object: `t("第 {page} 页", { page })`.
|
||||
- Preserve every placeholder name exactly in all translations.
|
||||
- Wrap all user-visible labels, descriptions, tooltips, empty states, validation messages, and accessibility labels. Do not wrap protocol tokens, model IDs, URLs, or product names that should remain unchanged.
|
||||
|
||||
## Locale behavior
|
||||
|
||||
- `system` is the default preference.
|
||||
- Resolve supported operating-system languages to their locale and fall back to `en-US` for every unsupported language.
|
||||
- Persist only explicit locale selections. Removing the preference restores system-language behavior.
|
||||
- Apply changes immediately and update the document `lang` attribute.
|
||||
|
||||
## Update catalogs
|
||||
|
||||
From `apps/desktop`:
|
||||
|
||||
1. Run `npm run i18n:scan` after adding or changing `t()` sources.
|
||||
2. Translate every empty entry in `src/i18n/locales/en-US.json`.
|
||||
3. Never edit `src/i18n/generated/catalog.json` or `zh-CN.json` manually; scanning owns them.
|
||||
4. Run `npm run check`. A normal build must fail on a missing translation or mismatched placeholder.
|
||||
|
||||
When adding another locale, add it to the plugin's supported locales, runtime locale type and messages, locale resolver, settings options, and provide a complete locale JSON 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."
|
||||
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
**/node_modules
|
||||
**/target
|
||||
**/dist
|
||||
server/*.db
|
||||
server/*.db-shm
|
||||
server/*.db-wal
|
||||
docs
|
||||
logs
|
||||
*.tar
|
||||
*.zip
|
||||
@@ -0,0 +1,256 @@
|
||||
name: Release desktop app
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: desktop-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Prepare release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_publish: ${{ steps.release.outputs.should_publish }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- 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
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version")
|
||||
package_version=$(node -p "require('./apps/desktop/package.json').version")
|
||||
cargo_version=$(sed -n '/^version = / { s/version = "\([^"]*\)"/\1/p; q; }' apps/desktop/src-tauri/Cargo.toml)
|
||||
test "${version}" = "${package_version}"
|
||||
test "${version}" = "${cargo_version}"
|
||||
test "${GITHUB_REF_TYPE}" = "tag"
|
||||
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
|
||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Check whether this version is already published
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
state=$(gh release view "v${VERSION}" --json isDraft --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null || true)
|
||||
if [[ "${state}" = "published" ]]; then
|
||||
echo "Version ${VERSION} is already published; nothing to do."
|
||||
echo "should_publish=false" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "should_publish=true" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
publish:
|
||||
name: Publish (${{ matrix.platform }})
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_publish == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux-x86_64
|
||||
os: ubuntu-22.04
|
||||
args: ""
|
||||
target: ""
|
||||
- platform: windows-x86_64
|
||||
os: windows-latest
|
||||
args: "--bundles nsis"
|
||||
target: ""
|
||||
- platform: macos-aarch64
|
||||
os: macos-15
|
||||
args: "--target aarch64-apple-darwin"
|
||||
target: aarch64-apple-darwin
|
||||
- platform: macos-x86_64
|
||||
os: macos-15-intel
|
||||
args: "--target x86_64-apple-darwin"
|
||||
target: x86_64-apple-darwin
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: matrix.platform == 'linux-x86_64'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Rust target
|
||||
if: matrix.target != ''
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: apps/desktop/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: apps/desktop
|
||||
run: npm ci
|
||||
|
||||
- uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ contains(matrix.platform, 'macos') && '-' || '' }}
|
||||
with:
|
||||
projectPath: apps/desktop
|
||||
tagName: v__VERSION__
|
||||
releaseName: Cursor BYOK v__VERSION__
|
||||
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
|
||||
prerelease: false
|
||||
uploadUpdaterJson: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Package legacy Linux updater asset
|
||||
if: matrix.platform == 'linux-x86_64'
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
mkdir -p legacy-update
|
||||
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
|
||||
|
||||
- name: Package legacy Windows updater asset
|
||||
if: matrix.platform == 'windows-x86_64'
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force legacy-update | Out-Null
|
||||
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
|
||||
|
||||
- name: Package legacy macOS updater asset
|
||||
if: contains(matrix.platform, 'macos')
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
mkdir -p legacy-update
|
||||
archive=$(find "target/${{ matrix.target }}/release/bundle/macos" -maxdepth 1 -name '*.app.tar.gz' -print -quit)
|
||||
test -n "${archive}"
|
||||
legacy_platform=macos-arm64
|
||||
if [[ "${{ matrix.platform }}" = "macos-x86_64" ]]; then
|
||||
legacy_platform=macos-amd64
|
||||
fi
|
||||
cp "${archive}" "legacy-update/cursor-byok-${VERSION}-${legacy_platform}.tar.gz"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: legacy-update-${{ matrix.platform }}
|
||||
path: legacy-update/*
|
||||
if-no-files-found: error
|
||||
|
||||
finalize:
|
||||
name: Publish GitHub Release
|
||||
needs: [prepare, publish]
|
||||
if: needs.prepare.outputs.should_publish == 'true' && needs.publish.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: legacy-update-*
|
||||
path: legacy-update
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate legacy update manifest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
node scripts/release/generate-legacy-update.mjs \
|
||||
--version "${VERSION}" \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--assets-dir legacy-update \
|
||||
--output legacy-update/update.json \
|
||||
--notes "Cursor BYOK v${VERSION}"
|
||||
|
||||
- name: Normalize Tauri updater download URLs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
mkdir -p tauri-update
|
||||
release_id=$(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||
--jq ".[] | select(.tag_name == \"v${VERSION}\") | .id"
|
||||
)
|
||||
test -n "${release_id}"
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > tauri-update/release.json
|
||||
asset_id=$(node -p 'require("./tauri-update/release.json").assets.find(({ name }) => name === "latest.json")?.id ?? ""')
|
||||
test -n "${asset_id}"
|
||||
gh api \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
||||
> tauri-update/latest.json
|
||||
node scripts/release/normalize-tauri-update.mjs \
|
||||
--manifest tauri-update/latest.json \
|
||||
--release tauri-update/release.json \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--version "${VERSION}"
|
||||
|
||||
- name: Upload normalized Tauri updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: gh release upload "v${VERSION}" tauri-update/latest.json --clobber
|
||||
|
||||
- name: Upload legacy updater assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: gh release upload "v${VERSION}" legacy-update/* --clobber
|
||||
|
||||
- name: Publish the completed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: gh release edit "v${VERSION}" --draft=false --latest
|
||||
@@ -0,0 +1,35 @@
|
||||
claude-server.tar
|
||||
.DS_Store
|
||||
dist
|
||||
.task
|
||||
/local-docs/
|
||||
/bin/
|
||||
logs/
|
||||
cursor-server.db
|
||||
cursor-server.db-shm
|
||||
cursor-server.db-wal
|
||||
gen/
|
||||
logs.zip
|
||||
frontend/bindings
|
||||
dist
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
cursor-server.tar
|
||||
/target/
|
||||
/server/target/
|
||||
/apps/desktop/src-tauri/gen/
|
||||
/.tauri/
|
||||
/server/*.db
|
||||
/server/*.db-shm
|
||||
/server/*.db-wal
|
||||
server-node/cursor.tar
|
||||
server-go/cursor.tar
|
||||
server-go/log/
|
||||
.cursor-local-assistant
|
||||
.cursor-local-assistant-v2
|
||||
.cursor-app-formatted/
|
||||
proto/extensions-cursor-app/
|
||||
cursor-tab-server/cursor-tab-server-linux-amd64.tar
|
||||
/scripts/cursor-proto/proto/*
|
||||
!/scripts/cursor-proto/proto/agent_v1.proto
|
||||
!/scripts/cursor-proto/proto/aiserver_v1.proto
|
||||
@@ -0,0 +1,9 @@
|
||||
# AGENTS.md
|
||||
- **The directory structure is the architecture.** Simple, clear directory and module naming >= module dependency relationships > concrete implementation details; communicate with the user using directory trees.
|
||||
- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
|
||||
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
|
||||
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
|
||||
- Keep components modular and concerns clearly separated.
|
||||
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
|
||||
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
|
||||
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
|
||||
@@ -0,0 +1,25 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"server",
|
||||
"apps/desktop/src-tauri",
|
||||
"crates/semble-core",
|
||||
"benchmarks/semble",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
# Keep local Rust builds compact. Full dependency debug info and incremental
|
||||
# object caches dominate target/ in this workspace, while line tables for our
|
||||
# own crates are enough for normal backtraces and source-level debugging.
|
||||
[profile.dev]
|
||||
debug = 1
|
||||
incremental = false
|
||||
|
||||
[profile.dev.package."*"]
|
||||
debug = 0
|
||||
|
||||
[profile.test]
|
||||
debug = 1
|
||||
incremental = false
|
||||
|
||||
[profile.test.package."*"]
|
||||
debug = 0
|
||||
@@ -0,0 +1,51 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm-slim AS web
|
||||
WORKDIR /src/apps/desktop
|
||||
COPY apps/desktop/package.json apps/desktop/package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
COPY apps/desktop/index.html apps/desktop/tsconfig.json apps/desktop/tsconfig.node.json apps/desktop/vite.config.ts ./
|
||||
COPY apps/desktop/plugins/ plugins/
|
||||
COPY apps/desktop/public/ public/
|
||||
COPY apps/desktop/src/ src/
|
||||
RUN npm run build
|
||||
|
||||
FROM rust:1-bookworm AS server
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY server/ server/
|
||||
COPY apps/desktop/src-tauri/Cargo.toml apps/desktop/src-tauri/Cargo.toml
|
||||
COPY apps/desktop/src-tauri/build.rs apps/desktop/src-tauri/build.rs
|
||||
COPY apps/desktop/src-tauri/src/ apps/desktop/src-tauri/src/
|
||||
COPY apps/desktop/src-tauri/capabilities/ apps/desktop/src-tauri/capabilities/
|
||||
COPY apps/desktop/src-tauri/icons/ apps/desktop/src-tauri/icons/
|
||||
COPY apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/tauri.conf.json
|
||||
COPY scripts/cursor-proto/proto/ scripts/cursor-proto/proto/
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/src/target \
|
||||
cargo build --release --locked --package cursor-server --bin cursor-server && \
|
||||
cp target/release/cursor-server /tmp/cursor-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && \
|
||||
apt-get install --yes --no-install-recommends ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
useradd --system --uid 10001 --home-dir /nonexistent --shell /usr/sbin/nologin cursor-byok && \
|
||||
mkdir -p /app/console /data && \
|
||||
chown cursor-byok:cursor-byok /data
|
||||
|
||||
COPY --from=server /tmp/cursor-server /usr/local/bin/cursor-server
|
||||
COPY --from=web /src/apps/desktop/dist/ /app/console/
|
||||
|
||||
ENV CURSOR_LISTEN_ADDR=0.0.0.0:3000 \
|
||||
CURSOR_DATABASE_URL=sqlite:///data/cursor-server.db \
|
||||
CURSOR_CONSOLE_DIR=/app/console \
|
||||
RUST_LOG=cursor_server=info
|
||||
|
||||
USER cursor-byok
|
||||
EXPOSE 3000
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
|
||||
CMD curl --fail --silent http://127.0.0.1:3000/__byok-api__/healthz || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/cursor-server"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 leookun
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
|
||||
check:
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
npm --prefix apps/desktop run check
|
||||
|
||||
dev-web:
|
||||
npm --prefix apps/desktop run dev:web
|
||||
|
||||
dev-server:
|
||||
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
|
||||
|
||||
dev-desktop:
|
||||
npm --prefix apps/desktop run tauri:dev
|
||||
|
||||
build-web:
|
||||
npm --prefix apps/desktop run build
|
||||
|
||||
build-server:
|
||||
cargo build --release --package cursor-server --bin cursor-server
|
||||
|
||||
$(LOCAL_TAURI_SIGNING_KEY):
|
||||
@install -d -m 700 "$(dir $@)"
|
||||
@apps/desktop/node_modules/.bin/tauri signer generate --ci --write-keys "$@" >/dev/null
|
||||
@chmod 600 "$@" "$@.pub"
|
||||
|
||||
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
|
||||
TAURI_SIGNING_PRIVATE_KEY="$(LOCAL_TAURI_SIGNING_KEY)" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" npm --prefix apps/desktop run tauri:build
|
||||
|
||||
build-docker:
|
||||
docker build --tag cursor-byok:local .
|
||||
@@ -1,16 +1,110 @@
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
|
||||
<div align="center">
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
|
||||
# cursor-byok
|
||||
cursor-byok is a local implementation of Cursor's backend.
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
|
||||
[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=leookun%2Fcursor-byok&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</div>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## About
|
||||
|
||||
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
|
||||
|
||||
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
|
||||
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
|
||||
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
|
||||
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
|
||||
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
|
||||
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
|
||||
- **Cross-platform:** Run on macOS, Windows, and Linux.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
|
||||
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
|
||||
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
|
||||
4. Open Cursor, select the configured model, and start using Agent.
|
||||
|
||||
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
|
||||
|
||||
## Model Management
|
||||
|
||||
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
|
||||
|
||||

|
||||
|
||||
## How It Works
|
||||
|
||||
```text
|
||||
Cursor client
|
||||
│
|
||||
│ Agent requests and tool results
|
||||
▼
|
||||
cursor-byok local service
|
||||
│
|
||||
│ OpenAI- / Anthropic-compatible requests
|
||||
▼
|
||||
Your model API
|
||||
```
|
||||
|
||||
cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
|
||||
|
||||
## Why This Project
|
||||
|
||||
Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
|
||||
|
||||
cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
|
||||
|
||||
## Roadmap
|
||||
|
||||
The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
|
||||
|
||||
See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
|
||||
|
||||
## Community and Support
|
||||
|
||||
- [User Guide](https://docs.leokun.cn)
|
||||
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
|
||||
- [Telegram community](https://t.me/cursor_byok)
|
||||
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
|
||||
|
||||
|
||||
|
||||
## Development and Contributing
|
||||
|
||||
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
|
||||
</a>
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is open source under the [MIT License](./LICENSE).
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#141414" />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background-color: var(--vscode-editor-background, var(--startup-background, #141414));
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
const theme = localStorage.getItem("cursor-byok.theme") === "default-light"
|
||||
? "default-light"
|
||||
: "default-dark";
|
||||
const light = theme === "default-light";
|
||||
const background = light ? "#fcfcfc" : "#141414";
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.setProperty("--startup-background", background);
|
||||
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", background);
|
||||
} catch {
|
||||
// The inline CSS already provides the dark-theme fallback.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<title>Cursor BYOK</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>Cursor BYOK 需要 JavaScript 才能运行。</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.4",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
||||
"build": "vite build",
|
||||
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
|
||||
"serve": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
"@iconify/json": "^2.2.519",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"echarts": "^6.1.0",
|
||||
"keepalive-for-react": "^5.0.11",
|
||||
"keepalive-for-react-router": "^5.0.7",
|
||||
"monaco-editor": "0.56.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
"zrender": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@babel/traverse": "^7.29.8",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/babel__traverse": "^7.28.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"code-inspector-plugin": "^1.6.6",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"sass": "^1.102.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"wait-on": "^9.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "3.4.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { parse as parseJavaScript, type ParserPlugin } from "@babel/parser"
|
||||
import traverseModule from "@babel/traverse"
|
||||
import { normalizePath, type Plugin } from "vite"
|
||||
|
||||
// @ts-expect-error - CJS/ESM 模块互操作。
|
||||
const traverse = traverseModule.default ?? traverseModule
|
||||
|
||||
const SOURCE_LOCALE = "zh-CN"
|
||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
|
||||
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
|
||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
||||
const AUTO_IMPORT_NAME = "__staticI18nT"
|
||||
const AUTO_IMPORT = `import { t as ${AUTO_IMPORT_NAME} } from "/src/i18n/runtime";\n`
|
||||
|
||||
const BABEL_PLUGINS = [
|
||||
"jsx",
|
||||
"typescript",
|
||||
["classProperties", { decoratorsBeforeExport: false }],
|
||||
["classPrivateProperties", { decoratorsBeforeExport: false }],
|
||||
"classPrivateMethods",
|
||||
"topLevelAwait",
|
||||
"importAttributes"
|
||||
] as unknown as ParserPlugin[]
|
||||
|
||||
interface SourceRef {
|
||||
file: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
id: string
|
||||
source: string
|
||||
kind: MessageKind
|
||||
placeholders: string[]
|
||||
ref: SourceRef
|
||||
}
|
||||
|
||||
interface MergedEntry {
|
||||
source: string
|
||||
kind: MessageKind
|
||||
placeholders: string[]
|
||||
refs: SourceRef[]
|
||||
}
|
||||
|
||||
interface CallReplacement {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface BabelArgument {
|
||||
type: string
|
||||
value?: string
|
||||
properties?: BabelObjectProperty[]
|
||||
}
|
||||
|
||||
interface BabelObjectProperty {
|
||||
type: string
|
||||
computed?: boolean
|
||||
key?: { type: string; name?: string; value?: string }
|
||||
}
|
||||
|
||||
interface BabelCallNode {
|
||||
callee: { type: string; name?: string; start?: number | null; end?: number | null }
|
||||
arguments: BabelArgument[]
|
||||
loc?: { start: { line: number; column: number } | null } | null
|
||||
}
|
||||
|
||||
interface BabelCallPath {
|
||||
node: BabelCallNode
|
||||
scope: { getBinding(name: string): unknown }
|
||||
}
|
||||
|
||||
enum MessageKind {
|
||||
Text = "text",
|
||||
Template = "template",
|
||||
}
|
||||
|
||||
enum BabelNodeType {
|
||||
Identifier = "Identifier",
|
||||
StringLiteral = "StringLiteral",
|
||||
ObjectExpression = "ObjectExpression",
|
||||
ObjectProperty = "ObjectProperty",
|
||||
}
|
||||
|
||||
function hashMessageID(message: string): string {
|
||||
return createHash("sha256").update(message).digest("hex").slice(0, 16)
|
||||
}
|
||||
|
||||
function stripQuery(id: string): string {
|
||||
return id.split("?")[0]
|
||||
}
|
||||
|
||||
function isSourceFile(id: string): boolean {
|
||||
return /\.(?:js|jsx|ts|tsx)$/.test(stripQuery(id))
|
||||
}
|
||||
|
||||
function isExcludedFile(sourceRoot: string, id: string): boolean {
|
||||
const cleanID = normalizePath(stripQuery(id))
|
||||
if (cleanID.includes("/node_modules/")) return true
|
||||
const relativePath = normalizePath(path.relative(sourceRoot, cleanID))
|
||||
if (relativePath.startsWith("..")) return true
|
||||
return relativePath.startsWith("i18n/")
|
||||
}
|
||||
|
||||
function readJSONFile(filePath: string): Record<string, string> {
|
||||
if (!fs.existsSync(filePath)) return {}
|
||||
const raw = fs.readFileSync(filePath, "utf8").trim()
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
}
|
||||
|
||||
function writeJSONFile(filePath: string, payload: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function buildRef(
|
||||
filePath: string,
|
||||
sourceRoot: string,
|
||||
loc: { line: number; column: number } | null
|
||||
): SourceRef {
|
||||
return {
|
||||
file: normalizePath(path.relative(sourceRoot, filePath)),
|
||||
line: loc?.line ?? 1,
|
||||
column: loc?.column ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLocation(
|
||||
filePath: string,
|
||||
sourceRoot: string,
|
||||
loc: { line: number; column: number } | null
|
||||
): string {
|
||||
const ref = buildRef(filePath, sourceRoot, loc)
|
||||
return `${ref.file}:${ref.line}:${ref.column}`
|
||||
}
|
||||
|
||||
function placeholderNames(message: string): string[] {
|
||||
return Array.from(message.matchAll(PLACEHOLDER_PATTERN), (match) => match[1])
|
||||
}
|
||||
|
||||
function assertUniquePlaceholders(
|
||||
placeholders: string[],
|
||||
location: string,
|
||||
message: string
|
||||
): void {
|
||||
const duplicate = placeholders.find(
|
||||
(name, index) => placeholders.indexOf(name) !== index
|
||||
)
|
||||
if (!duplicate) return
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 重复使用占位符 {${duplicate}}:${JSON.stringify(message)}`
|
||||
)
|
||||
}
|
||||
|
||||
function objectParameterNames(argument: BabelArgument, location: string): string[] {
|
||||
if (argument.type !== BabelNodeType.ObjectExpression) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t params 必须是静态对象字面量`)
|
||||
}
|
||||
|
||||
const names: string[] = []
|
||||
for (const property of argument.properties ?? []) {
|
||||
if (property.type !== BabelNodeType.ObjectProperty || property.computed) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 的 t params 不允许展开、计算属性或方法`
|
||||
)
|
||||
}
|
||||
const key = property.key
|
||||
const name =
|
||||
key?.type === BabelNodeType.Identifier
|
||||
? key.name
|
||||
: key?.type === BabelNodeType.StringLiteral
|
||||
? key.value
|
||||
: undefined
|
||||
if (!name) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t params key 必须是静态名称`)
|
||||
}
|
||||
if (names.includes(name)) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t params 重复提供 ${name}`)
|
||||
}
|
||||
names.push(name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function validateSourceCall(
|
||||
filePath: string,
|
||||
sourceRoot: string,
|
||||
source: string,
|
||||
args: BabelArgument[],
|
||||
loc: { line: number; column: number } | null
|
||||
): string[] {
|
||||
const location = sourceLocation(filePath, sourceRoot, loc)
|
||||
if (!CHINESE_SOURCE_PATTERN.test(source)) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 的 t source 必须包含中文:${JSON.stringify(source)}`
|
||||
)
|
||||
}
|
||||
|
||||
const placeholders = placeholderNames(source)
|
||||
assertUniquePlaceholders(placeholders, location, source)
|
||||
|
||||
if (!placeholders.length) {
|
||||
if (args.length !== 1) {
|
||||
throw new Error(`[static-i18n] ${location} 的无参数消息不能传入 params`)
|
||||
}
|
||||
return placeholders
|
||||
}
|
||||
|
||||
if (args.length !== 2) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 必须为占位符传入一个具名 params 对象`
|
||||
)
|
||||
}
|
||||
|
||||
const parameters = objectParameterNames(args[1], location)
|
||||
const missing = placeholders.filter((name) => !parameters.includes(name))
|
||||
const extra = parameters.filter((name) => !placeholders.includes(name))
|
||||
if (missing.length || extra.length) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 的 params 与占位符不一致` +
|
||||
`${missing.length ? `,缺少:${missing.join(", ")}` : ""}` +
|
||||
`${extra.length ? `,多余:${extra.join(", ")}` : ""}`
|
||||
)
|
||||
}
|
||||
return placeholders
|
||||
}
|
||||
|
||||
function buildMessageRecord(
|
||||
filePath: string,
|
||||
sourceRoot: string,
|
||||
source: string,
|
||||
placeholders: string[],
|
||||
loc: { line: number; column: number } | null
|
||||
): MessageRecord {
|
||||
return {
|
||||
id: hashMessageID(source),
|
||||
source,
|
||||
kind: placeholders.length ? MessageKind.Template : MessageKind.Text,
|
||||
placeholders,
|
||||
ref: buildRef(filePath, sourceRoot, loc)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeMessageRecords(records: MessageRecord[]) {
|
||||
const entries = new Map<string, MergedEntry>()
|
||||
for (const record of records) {
|
||||
const current = entries.get(record.id)
|
||||
if (!current) {
|
||||
entries.set(record.id, {
|
||||
source: record.source,
|
||||
kind: record.kind,
|
||||
placeholders: record.placeholders,
|
||||
refs: [record.ref]
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (current.source !== record.source) {
|
||||
throw new Error(
|
||||
`[static-i18n] Message id collision for ${record.id}: ${current.source} <> ${record.source}`
|
||||
)
|
||||
}
|
||||
current.refs.push(record.ref)
|
||||
}
|
||||
|
||||
const sortedEntries: Record<string, MergedEntry> = {}
|
||||
for (const id of Array.from(entries.keys()).sort()) {
|
||||
const entry = entries.get(id)!
|
||||
sortedEntries[id] = {
|
||||
...entry,
|
||||
refs: entry.refs.sort(
|
||||
(a, b) =>
|
||||
a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column
|
||||
)
|
||||
}
|
||||
}
|
||||
return { entries: sortedEntries }
|
||||
}
|
||||
|
||||
function mergeLocaleMessages(
|
||||
existingMessages: Record<string, string>,
|
||||
catalogEntries: Record<string, MergedEntry>,
|
||||
locale: string
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(catalogEntries).map(([id, entry]) => [
|
||||
id,
|
||||
locale === SOURCE_LOCALE ? entry.source : existingMessages[id] ?? ""
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function validateTranslations(
|
||||
sourceRoot: string,
|
||||
entries: Record<string, MergedEntry>,
|
||||
requireComplete: boolean
|
||||
): void {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
if (locale === SOURCE_LOCALE) continue
|
||||
const localePath = path.join(sourceRoot, "i18n/locales", `${locale}.json`)
|
||||
const messages = readJSONFile(localePath)
|
||||
for (const [id, entry] of Object.entries(entries)) {
|
||||
const translation = messages[id]
|
||||
if (!translation) {
|
||||
if (requireComplete) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${localePath}:${id} 缺少译文:${entry.source}`
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const actual = placeholderNames(translation)
|
||||
assertUniquePlaceholders(actual, `${localePath}:${id}`, translation)
|
||||
const missing = entry.placeholders.filter((name) => !actual.includes(name))
|
||||
const extra = actual.filter((name) => !entry.placeholders.includes(name))
|
||||
if (!missing.length && !extra.length) continue
|
||||
throw new Error(
|
||||
`[static-i18n] ${localePath}:${id} 的译文占位符与 source 不一致` +
|
||||
`${missing.length ? `,缺少:${missing.join(", ")}` : ""}` +
|
||||
`${extra.length ? `,多余:${extra.join(", ")}` : ""}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walkSourceFiles(dirPath: string, visitor: (filePath: string) => void): void {
|
||||
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
||||
const nextPath = path.join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== "node_modules") walkSourceFiles(nextPath, visitor)
|
||||
} else {
|
||||
visitor(nextPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseProgram(code: string, filename: string) {
|
||||
try {
|
||||
return parseJavaScript(code, {
|
||||
sourceType: "module",
|
||||
sourceFilename: filename,
|
||||
plugins: [...BABEL_PLUGINS]
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[static-i18n] Failed to parse ${filename}: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeTCalls(
|
||||
code: string,
|
||||
filePath: string,
|
||||
sourceRoot: string
|
||||
): { records: MessageRecord[]; replacements: CallReplacement[] } {
|
||||
const records: MessageRecord[] = []
|
||||
const replacements: CallReplacement[] = []
|
||||
const ast = parseProgram(code, filePath)
|
||||
|
||||
traverse(ast, {
|
||||
CallExpression(callPath: BabelCallPath) {
|
||||
const node = callPath.node
|
||||
if (node.callee.type !== BabelNodeType.Identifier || node.callee.name !== "t") return
|
||||
|
||||
let loc: { line: number; column: number } | null = null
|
||||
if (node.loc?.start) {
|
||||
loc = { line: node.loc.start.line, column: node.loc.start.column + 1 }
|
||||
}
|
||||
const location = sourceLocation(filePath, sourceRoot, loc)
|
||||
if (callPath.scope.getBinding("t")) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 的 t 是保留全局函数,请删除本地声明或 import`
|
||||
)
|
||||
}
|
||||
if (!node.arguments.length) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t 调用缺少中文 source`)
|
||||
}
|
||||
|
||||
const firstArg = node.arguments[0]
|
||||
if (firstArg.type !== BabelNodeType.StringLiteral || firstArg.value === undefined) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t source 必须是静态字符串字面量`)
|
||||
}
|
||||
const placeholders = validateSourceCall(
|
||||
filePath,
|
||||
sourceRoot,
|
||||
firstArg.value,
|
||||
node.arguments,
|
||||
loc
|
||||
)
|
||||
records.push(
|
||||
buildMessageRecord(filePath, sourceRoot, firstArg.value, placeholders, loc)
|
||||
)
|
||||
|
||||
const { start, end } = node.callee
|
||||
if (start == null || end == null) {
|
||||
throw new Error(`[static-i18n] ${location} 无法定位 t 调用`)
|
||||
}
|
||||
replacements.push({ start, end })
|
||||
}
|
||||
})
|
||||
return { records, replacements }
|
||||
}
|
||||
|
||||
function collectCatalog(sourceRoot: string) {
|
||||
const records: MessageRecord[] = []
|
||||
walkSourceFiles(sourceRoot, (filePath) => {
|
||||
const cleanPath = normalizePath(filePath)
|
||||
if (!isSourceFile(cleanPath) || isExcludedFile(sourceRoot, cleanPath)) return
|
||||
records.push(
|
||||
...analyzeTCalls(fs.readFileSync(cleanPath, "utf8"), cleanPath, sourceRoot).records
|
||||
)
|
||||
})
|
||||
return mergeMessageRecords(records)
|
||||
}
|
||||
|
||||
function syncCatalogFiles(
|
||||
sourceRoot: string,
|
||||
catalog: ReturnType<typeof collectCatalog>
|
||||
): void {
|
||||
writeJSONFile(path.join(sourceRoot, "i18n/generated/catalog.json"), catalog)
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localePath = path.join(sourceRoot, "i18n/locales", `${locale}.json`)
|
||||
writeJSONFile(
|
||||
localePath,
|
||||
mergeLocaleMessages(readJSONFile(localePath), catalog.entries, locale)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function injectRuntimeImport(code: string, replacements: CallReplacement[]): string {
|
||||
let transformed = code
|
||||
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
||||
transformed =
|
||||
transformed.slice(0, replacement.start) +
|
||||
AUTO_IMPORT_NAME +
|
||||
transformed.slice(replacement.end)
|
||||
}
|
||||
return AUTO_IMPORT + transformed
|
||||
}
|
||||
|
||||
export function staticI18nPlugin(): Plugin {
|
||||
let sourceRoot = path.join(process.cwd(), "src")
|
||||
const shouldScan =
|
||||
process.argv.includes("--scan") || process.env.STATIC_I18N_SCAN === "true"
|
||||
|
||||
return {
|
||||
name: "cursor-byok-static-i18n",
|
||||
enforce: "pre",
|
||||
configResolved(config) {
|
||||
sourceRoot = path.join(config.root, "src")
|
||||
},
|
||||
buildStart() {
|
||||
const catalog = collectCatalog(sourceRoot)
|
||||
if (shouldScan) syncCatalogFiles(sourceRoot, catalog)
|
||||
validateTranslations(sourceRoot, catalog.entries, !shouldScan)
|
||||
},
|
||||
transform(code, id) {
|
||||
if (!isSourceFile(id) || isExcludedFile(sourceRoot, id)) return null
|
||||
const analysis = analyzeTCalls(code, normalizePath(stripQuery(id)), sourceRoot)
|
||||
if (!analysis.replacements.length) return null
|
||||
return { code: injectRuntimeImport(code, analysis.replacements), map: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "cursor_byok_desktop"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
cursor-server = { path = "../../../server" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
url = "2"
|
||||
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
|
||||
.expect("failed to build Tauri application")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Cursor BYOK 主窗口的最小权限",
|
||||
"remote": {
|
||||
"urls": [
|
||||
"http://127.0.0.1:*",
|
||||
"http://localhost:*"
|
||||
]
|
||||
},
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"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",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-terminal-with-command"
|
||||
description = "Enables the open_terminal_with_command command without any pre-configured scope."
|
||||
commands.allow = ["open_terminal_with_command"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-terminal-with-command"
|
||||
description = "Denies the open_terminal_with_command command without any pre-configured scope."
|
||||
commands.deny = ["open_terminal_with_command"]
|
||||
@@ -0,0 +1,267 @@
|
||||
use std::{
|
||||
process::Command,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Json},
|
||||
http::StatusCode,
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use tauri::{
|
||||
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
|
||||
WebviewWindow, WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[cfg(dev)]
|
||||
use cursor_server::config::ConsoleSource;
|
||||
use cursor_server::{App, Config, Result};
|
||||
|
||||
#[cfg(not(dev))]
|
||||
use crate::frontend;
|
||||
use crate::tray;
|
||||
|
||||
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const AUTOSTART_ARG: &str = "--autostart";
|
||||
|
||||
struct DesktopRuntime {
|
||||
shutdown: CancellationToken,
|
||||
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||
exiting: AtomicBool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_terminal_with_command(command: String) -> tauri::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = command;
|
||||
Command::new("open").args(["-a", "Terminal"]).status()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "cmd", "/K", &command])
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
const TERMINALS: &[(&str, &[&str])] = &[
|
||||
("x-terminal-emulator", &["-e"]),
|
||||
("gnome-terminal", &["--"]),
|
||||
("konsole", &["-e"]),
|
||||
("xfce4-terminal", &["--execute"]),
|
||||
("alacritty", &["-e"]),
|
||||
("kitty", &[]),
|
||||
];
|
||||
let script = format!("{command}; exec bash");
|
||||
for (terminal, separator) in TERMINALS {
|
||||
let mut process = Command::new(terminal);
|
||||
process.args(*separator);
|
||||
process.arg("bash").arg("-c").arg(&script);
|
||||
match process.spawn() {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Err(tauri::Error::from(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no supported terminal emulator found",
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OpenExternalUrlRequest {
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn open_external_url(app: &AppHandle, url: &str) -> std::result::Result<(), String> {
|
||||
let parsed = url::Url::parse(url).map_err(|error| format!("invalid URL: {error}"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return Err("only absolute HTTP and HTTPS URLs are allowed".into());
|
||||
}
|
||||
|
||||
app.opener()
|
||||
.open_url(parsed.as_str(), None::<&str>)
|
||||
.map_err(|error| format!("failed to open URL: {error}"))
|
||||
}
|
||||
|
||||
async fn open_external_url_handler(
|
||||
Extension(app): Extension<AppHandle>,
|
||||
Json(request): Json<OpenExternalUrlRequest>,
|
||||
) -> std::result::Result<StatusCode, (StatusCode, String)> {
|
||||
open_external_url(&app, &request.url)
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|error| (StatusCode::BAD_REQUEST, error))
|
||||
}
|
||||
|
||||
fn desktop_api_router(app: AppHandle) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/__byok-api__/api/desktop/open-external-url",
|
||||
post(open_external_url_handler),
|
||||
)
|
||||
.layer(Extension(app))
|
||||
}
|
||||
|
||||
fn create_main_window(
|
||||
app: &AppHandle,
|
||||
address: std::net::SocketAddr,
|
||||
) -> tauri::Result<WebviewWindow> {
|
||||
let url = format!("http://{address}/__byok-api__/")
|
||||
.parse()
|
||||
.expect("local frontend URL");
|
||||
let builder = WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, WebviewUrl::External(url))
|
||||
.title("Cursor BYOK")
|
||||
.inner_size(820.0, 558.0)
|
||||
.min_inner_size(820.0, 558.0)
|
||||
.center()
|
||||
.background_color(Color(20, 20, 20, 255))
|
||||
.decorations(cfg!(target_os = "macos"))
|
||||
.shadow(true)
|
||||
.resizable(true)
|
||||
.visible(false);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true);
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "cursor_server=info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
||||
tray::show_main_window(app);
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(move |app| {
|
||||
app.handle().plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec![AUTOSTART_ARG]),
|
||||
))?;
|
||||
let config = Config::desktop()?;
|
||||
#[cfg(dev)]
|
||||
let config = {
|
||||
let mut config = config;
|
||||
config.console = Some(ConsoleSource::Proxy(
|
||||
"http://127.0.0.1:1420"
|
||||
.parse()
|
||||
.expect("Vite development URL"),
|
||||
));
|
||||
config
|
||||
};
|
||||
let server = tauri::async_runtime::block_on(App::new(config))?
|
||||
.merge_router(desktop_api_router(app.handle().clone()));
|
||||
#[cfg(not(dev))]
|
||||
let server = server.merge_router(frontend::router(app.handle().clone()));
|
||||
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 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();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
let result = server.serve_on(listener, server_shutdown).await;
|
||||
if let Err(error) = &result {
|
||||
tracing::error!(%error, "desktop server stopped unexpectedly");
|
||||
app_handle.exit(1);
|
||||
}
|
||||
result
|
||||
});
|
||||
app.manage(DesktopRuntime {
|
||||
shutdown,
|
||||
server: Mutex::new(Some(task)),
|
||||
exiting: AtomicBool::new(false),
|
||||
});
|
||||
let window = create_main_window(app.handle(), address)?;
|
||||
if desktop_settings.silent_start && started_by_autostart {
|
||||
tracing::info!("silent autostart enabled; keeping the main window hidden");
|
||||
} else {
|
||||
window.show()?;
|
||||
window.set_focus()?;
|
||||
}
|
||||
tray::create(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build Cursor BYOK desktop app");
|
||||
|
||||
app.run(|app, event| match event {
|
||||
RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} if label == MAIN_WINDOW_LABEL => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.load(Ordering::Acquire) {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
RunEvent::ExitRequested { api, .. } => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.swap(true, Ordering::AcqRel) {
|
||||
api.prevent_exit();
|
||||
runtime.shutdown.cancel();
|
||||
let server = runtime.server.lock().expect("server lock poisoned").take();
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(server) = server {
|
||||
match tokio::time::timeout(Duration::from_secs(11), server).await {
|
||||
Ok(Ok(Ok(()))) => {}
|
||||
Ok(Ok(Err(error))) => {
|
||||
tracing::error!(%error, "desktop server shutdown failed")
|
||||
}
|
||||
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
|
||||
Err(_) => tracing::warn!("desktop server shutdown timed out"),
|
||||
}
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Reopen {
|
||||
has_visible_windows: false,
|
||||
..
|
||||
} => tray::show_main_window(app),
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Serves Tauri's embedded frontend assets through the local HTTP server.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, HeaderValue, Response, StatusCode, Uri},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub(crate) fn router(app: AppHandle) -> Router {
|
||||
Router::new()
|
||||
.route("/__byok-api__/", get(asset))
|
||||
.route("/__byok-api__/{*path}", get(asset))
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
async fn asset(State(app): State<AppHandle>, uri: Uri) -> Response<Body> {
|
||||
let path = uri
|
||||
.path()
|
||||
.strip_prefix("/__byok-api__/")
|
||||
.filter(|path| !path.is_empty())
|
||||
.unwrap_or("index.html");
|
||||
let Some(asset) = app.asset_resolver().get(path.to_string()) else {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.expect("static not-found response");
|
||||
};
|
||||
let mut response = Response::new(Body::from(asset.bytes));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(&asset.mime_type)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
if let Some(csp) = asset.csp_header.and_then(|value| value.parse().ok()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_SECURITY_POLICY, csp);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod desktop;
|
||||
#[cfg(not(dev))]
|
||||
mod frontend;
|
||||
mod tray;
|
||||
|
||||
pub use desktop::run;
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
cursor_byok_desktop::run();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
App, AppHandle, Manager,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
|
||||
|
||||
use crate::desktop::MAIN_WINDOW_LABEL;
|
||||
|
||||
const OPEN_MENU_ID: &str = "tray-open";
|
||||
const QUIT_MENU_ID: &str = "tray-quit";
|
||||
|
||||
pub fn create(app: &mut App) -> tauri::Result<()> {
|
||||
let open = MenuItem::with_id(app, OPEN_MENU_ID, "打开 Cursor BYOK", true, None::<&str>)?;
|
||||
let separator = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, QUIT_MENU_ID, "退出", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &separator, &quit])?;
|
||||
|
||||
TrayIconBuilder::with_id("main")
|
||||
.icon(tauri::include_image!("./icons/32x32.png"))
|
||||
.tooltip("Cursor BYOK")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
OPEN_MENU_ID => show_main_window(app),
|
||||
QUIT_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = (tray, event);
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.4",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/linux-32x32.png",
|
||||
"icons/linux-128x128.png",
|
||||
"icons/linux-256x256.png",
|
||||
"icons/linux-512x512.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc1MEY2RjQzRjA0MjhCMDUKUldRRmkwTHdRMjhQZGJJam9zT1NwcW83L1V4ckJvbkQzQ0FYVFZ6MGRpa1N2NVFkUHk1dW14c1AK",
|
||||
"endpoints": [
|
||||
"https://github.com/leookun/cursor-byok/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"bundle": {
|
||||
"targets": ["app"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { MessageProvider } from "./components/ui/MessageProvider";
|
||||
import { useMessage } from "./components/ui/message";
|
||||
import { AppFrame } from "./layouts/AppFrame";
|
||||
import { AppLayout } from "./layouts/AppLayout";
|
||||
import { CallsPage } from "./pages/CallsPage";
|
||||
import { CallDetailsPage } from "./pages/CallDetailsPage";
|
||||
import { CursorSettingsPage } from "./pages/CursorSettingsPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { useAppStore } from "./store/appStore";
|
||||
import { updateStore } from "./store/updateStore";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="calls/:callId" element={<CallDetailsPage />} />
|
||||
<Route element={<AppFrame />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="calls" element={<CallsPage />} />
|
||||
<Route path="harness/cursor" element={<CursorSettingsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
<AppMessages />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppMessages() {
|
||||
const { error } = useAppStore();
|
||||
const previousError = useRef<string | null>(null);
|
||||
const showMessage = useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
if (error && error !== previousError.current) showMessage(error);
|
||||
previousError.current = error;
|
||||
}, [error, showMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
void updateStore.check().then((version) => {
|
||||
if (!version) return;
|
||||
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
|
||||
}).catch(() => {
|
||||
// Startup checks are best-effort; manual checks in Settings report errors.
|
||||
});
|
||||
}, [showMessage]);
|
||||
|
||||
return <MessageProvider />;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import type { AdRuntime } from "./components/ads/types";
|
||||
import type { Locale } from "./i18n/runtime";
|
||||
|
||||
export type ModelType = "openai" | "anthropic";
|
||||
|
||||
export interface Model {
|
||||
model_hash: string;
|
||||
sort_order: number;
|
||||
display_name: string;
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
use_full_url: boolean;
|
||||
api_key: string;
|
||||
tooltip_data: string;
|
||||
model_id: string;
|
||||
reasoning_effort: string | null;
|
||||
openai_endpoint: string;
|
||||
openai_extra_params_enabled: boolean;
|
||||
openai_extra_params: Record<string, unknown>;
|
||||
custom_headers_enabled: boolean;
|
||||
custom_headers: Record<string, string>;
|
||||
anthropic_extra_params_enabled: boolean;
|
||||
anthropic_extra_params: Record<string, unknown>;
|
||||
context_window_tokens: number | null;
|
||||
max_completion_tokens: number | null;
|
||||
anthropic_max_tokens: number | null;
|
||||
anthropic_thinking_effort: string | null;
|
||||
thinking_budget_tokens: number | null;
|
||||
created_at_ms: number;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface ModelInput {
|
||||
sort_order: number;
|
||||
display_name: string;
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
use_full_url: boolean;
|
||||
api_key: string;
|
||||
tooltip_data: string;
|
||||
model_id: string;
|
||||
reasoning_effort: string | null;
|
||||
openai_endpoint: string;
|
||||
openai_extra_params_enabled: boolean;
|
||||
openai_extra_params: Record<string, unknown>;
|
||||
custom_headers_enabled: boolean;
|
||||
custom_headers: Record<string, string>;
|
||||
anthropic_extra_params_enabled: boolean;
|
||||
anthropic_extra_params: Record<string, unknown>;
|
||||
context_window_tokens: number | null;
|
||||
max_completion_tokens: number | null;
|
||||
anthropic_max_tokens: number | null;
|
||||
anthropic_thinking_effort: string | null;
|
||||
thinking_budget_tokens: number | null;
|
||||
}
|
||||
|
||||
export interface ModelDiscoveryInput {
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
custom_headers_enabled: boolean;
|
||||
custom_headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface LegacyModelImportPreviewItem {
|
||||
model_hash: string;
|
||||
display_name: string;
|
||||
model_id: string;
|
||||
type: ModelType;
|
||||
existing: boolean;
|
||||
}
|
||||
|
||||
export interface LegacyModelImportPreview {
|
||||
source: string;
|
||||
total: number;
|
||||
new_models: number;
|
||||
existing_models: number;
|
||||
models: LegacyModelImportPreviewItem[];
|
||||
}
|
||||
|
||||
export interface LegacyModelImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ModelConnectivityResult {
|
||||
duration_ms: number;
|
||||
first_text_ms: number | null;
|
||||
output_tokens: number;
|
||||
tokens_per_second: number;
|
||||
tokens_estimated: boolean;
|
||||
output: string;
|
||||
}
|
||||
|
||||
export type CaState = "missing" | "untrusted" | "ready" | "invalid";
|
||||
export type IntegrationState = "disabled" | "enabled" | "degraded";
|
||||
export interface CursorHarnessStatus {
|
||||
platform: string;
|
||||
ca: CaState;
|
||||
configured_models: number;
|
||||
enabled_models: number;
|
||||
integration: IntegrationState;
|
||||
proxy_url: string | null;
|
||||
ca_install_command: string | null;
|
||||
}
|
||||
|
||||
export interface PortSettings {
|
||||
proxy_port: number;
|
||||
service_port: number;
|
||||
}
|
||||
|
||||
export interface StatisticsStorage {
|
||||
bytes: number;
|
||||
call_count: number;
|
||||
trace_count: number;
|
||||
}
|
||||
|
||||
export type ProxyMode = "system" | "custom";
|
||||
|
||||
export interface ProxySettings {
|
||||
mode: ProxyMode;
|
||||
address: string;
|
||||
auth_enabled: boolean;
|
||||
username: string;
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
export interface ProxySettingsInput {
|
||||
mode: ProxyMode;
|
||||
address: string;
|
||||
auth_enabled: boolean;
|
||||
username: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type TabMode = "public" | "direct" | "custom";
|
||||
|
||||
export interface TabSettings {
|
||||
mode: TabMode;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface DesktopSettings {
|
||||
silent_start: boolean;
|
||||
show_dock_icon: boolean;
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
llm_calls: number;
|
||||
successful_calls: number;
|
||||
failed_calls: number;
|
||||
token_usage: number;
|
||||
prompt_tokens: number;
|
||||
input_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
output_tokens: number;
|
||||
}
|
||||
|
||||
export type TokenUsageGranularity = "minute" | "hour" | "day";
|
||||
|
||||
export interface OverviewTokenUsageBucket {
|
||||
bucket_start_ms: number;
|
||||
input_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
output_tokens: number;
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
metrics: OverviewMetrics;
|
||||
token_usage_granularity: TokenUsageGranularity;
|
||||
token_usage_series: OverviewTokenUsageBucket[];
|
||||
}
|
||||
|
||||
export interface LlmCall {
|
||||
call_kind: "provider_llm" | "cursor_official";
|
||||
route: "local_byok" | "cursor_official";
|
||||
call_id: string;
|
||||
run_id: string;
|
||||
conversation_id: string;
|
||||
provider_call_index: number;
|
||||
model_hash: string | null;
|
||||
provider_type: string;
|
||||
provider_url: string;
|
||||
request_type: string;
|
||||
request_url: string;
|
||||
model_id: string;
|
||||
display_name: string;
|
||||
reasoning_effort: string | null;
|
||||
fast: boolean | null;
|
||||
status: string;
|
||||
finish_reason: string | null;
|
||||
created_at_ms: number;
|
||||
ttfb_ms: number | null;
|
||||
ttft_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
total_tokens: number | null;
|
||||
cache_read_tokens: number | null;
|
||||
cache_write_tokens: number | null;
|
||||
reasoning_tokens: number | null;
|
||||
message_count: number;
|
||||
tool_count: number;
|
||||
http_status: number | null;
|
||||
error_kind: string | null;
|
||||
error_message: string | null;
|
||||
detailed: boolean;
|
||||
}
|
||||
|
||||
export interface CallDetail {
|
||||
call: LlmCall;
|
||||
request: { headers: unknown; body: unknown; byte_count: number } | null;
|
||||
response_chunks: Array<{ seq: number; received_offset_ms: number; data: string; byte_count: number }>;
|
||||
cursor_trace: {
|
||||
trace: {
|
||||
request_id: string;
|
||||
conversation_id: string | null;
|
||||
route: "local_byok" | "cursor_official";
|
||||
model_id: string | null;
|
||||
status: string;
|
||||
request_bytes: number;
|
||||
response_bytes: number;
|
||||
response_event_count: number;
|
||||
http_status: number | null;
|
||||
received_at_ms: number;
|
||||
first_response_at_ms: number | null;
|
||||
finished_at_ms: number | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
artifacts: Array<{
|
||||
seq: number;
|
||||
artifact_type: string;
|
||||
source: string;
|
||||
metadata: unknown;
|
||||
created_at_ms: number;
|
||||
byte_count: number;
|
||||
encoding: "utf8" | "base64";
|
||||
data: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const packagedDesktop = "__TAURI_INTERNALS__" in window
|
||||
|| window.location.protocol === "tauri:"
|
||||
|| window.location.hostname === "tauri.localhost";
|
||||
const API_ROOT = "/__byok-api__/api";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_ROOT}${path}`, {
|
||||
...init,
|
||||
headers: init?.body ? { "content-type": "application/json", ...init.headers } : init?.headers,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new Error(t("无法连接本地管理服务"), { cause });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
let message = body;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { message?: unknown };
|
||||
if (typeof parsed.message === "string") message = parsed.message;
|
||||
} catch {
|
||||
// Plain-text errors are already suitable for display.
|
||||
}
|
||||
throw new Error(message || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
ads: (disabledAdIds: Iterable<string>, locale: Locale) => {
|
||||
const value = [...disabledAdIds].join(",");
|
||||
return request<AdRuntime>("/ads", {
|
||||
headers: {
|
||||
"accept-language": locale,
|
||||
...(value ? { "disable-ad-ids": value } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
|
||||
models: () => request<Model[]>("/models"),
|
||||
createModels: (models: ModelInput[]) => request<Model[]>("/models", { method: "POST", body: JSON.stringify({ models }) }),
|
||||
reorderModels: (modelHashes: string[]) => request<Model[]>("/models/order", { method: "PUT", body: JSON.stringify({ model_hashes: modelHashes }) }),
|
||||
discoverModels: (input: ModelDiscoveryInput) => request<{ models: string[] }>("/models/discover", { method: "POST", body: JSON.stringify(input) }),
|
||||
previewV0049Models: () => request<LegacyModelImportPreview>("/models/import-v0049"),
|
||||
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
|
||||
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
|
||||
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) {
|
||||
params.set("start_ms", String(filter.startMs));
|
||||
params.set("end_ms", String(filter.endMs));
|
||||
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
|
||||
}
|
||||
const query = params.toString();
|
||||
return request<Overview>(`/overview${query ? `?${query}` : ""}`);
|
||||
},
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
openCursorCaInstallTerminal: async (command: string) => {
|
||||
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("open_terminal_with_command", { command });
|
||||
},
|
||||
copyCursorText: async (text: string) => {
|
||||
if (!packagedDesktop) throw new Error(t("请在桌面应用中复制到系统剪贴板"));
|
||||
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");
|
||||
await writeText(text);
|
||||
},
|
||||
setCursorEnabled: (enabled: boolean) => request<CursorHarnessStatus>("/harness/cursor/enabled", { method: "PUT", body: JSON.stringify({ enabled }) }),
|
||||
calls: () => request<LlmCall[]>("/llm-calls?limit=200"),
|
||||
call: (id: string) => request<CallDetail>(`/llm-calls/${encodeURIComponent(id)}`),
|
||||
openCallDetails: async (id: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = `/calls/${encodeURIComponent(id)}`;
|
||||
await request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url: url.toString() }) });
|
||||
},
|
||||
openExternalUrl: (url: string) => request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
observability: () => request<{ detailed: boolean }>("/settings/observability"),
|
||||
setObservability: (detailed: boolean) => request<{ detailed: boolean }>("/settings/observability", { method: "PUT", body: JSON.stringify({ detailed }) }),
|
||||
ports: () => request<PortSettings>("/settings/ports"),
|
||||
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
|
||||
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
|
||||
proxySettings: () => request<ProxySettings>("/settings/proxy"),
|
||||
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
tabSettings: () => request<TabSettings>("/settings/tab"),
|
||||
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
desktopSettings: () => request<DesktopSettings>("/settings/desktop"),
|
||||
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg width="310" height="310" viewBox="0 0 310 310" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Cursor</title>
|
||||
<defs>
|
||||
<linearGradient x1="12.6410966%" y1="0%" x2="93.9769479%" y2="100%" id="cursor-border-gradient">
|
||||
<stop stop-color="#C8C8C8" offset="0%"/>
|
||||
<stop stop-color="#000000" offset="45.1598984%"/>
|
||||
<stop stop-color="#979797" offset="100%"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g stroke="none" fill="none" fill-rule="evenodd" transform="translate(21 21)">
|
||||
<path stroke="url(#cursor-border-gradient)" stroke-width="3" stroke-linejoin="square" fill="#000000" d="M206.569725 2.38059035c9.666379.78977439 16.750829 2.27231033 22.749786 4.86175573l2.508886 1.17873983c11.948293 6.08795909 21.662558 15.80222449 27.750517 27.75051739c3.330466 6.5364062 5.137896 14.2113817 6.040496 25.2586721c.748007 9.1551756.88059 17.5905616.88059 42.5697246v60c0 24.979163-.132583 33.414549-.88059 42.569725c-.9026 11.04729-2.71003 18.722265-6.040496 25.258672c-6.087959 11.948293-15.802224 21.662558-27.750517 27.750517c-6.536407 3.330466-14.211382 5.137896-25.258672 6.040496c-9.155176.748007-17.590562.88059-42.569725.88059h-60c-24.979163 0-33.414549-.132583-42.569725-.88059c-11.04729-.9026-18.722265-2.71003-25.258672-6.040496c-11.948293-6.087959-21.662558-15.802224-27.750517-27.750517c-3.330465-6.536407-5.137896-14.211382-6.040496-25.258672C1.632583 197.414549 1.5 188.979163 1.5 164v-60c0-24.979163.132583-33.414549.88059-42.569725c.9026-11.0472904 2.71003-18.7222659 6.040496-25.2586721C14.509045 24.2233104 24.22331 14.509045 36.171603 8.42108571c6.536407-3.33046534 14.211382-5.13789606 25.258672-6.04049536C70.585451 1.63258295 79.020837 1.5 104 1.5h60c24.979163 0 33.414549.13258295 42.569725.88059035Z"/>
|
||||
<g transform="translate(24.9831 26.1186)">
|
||||
<path d="M0 0h216.898305v216.898305H0z"/>
|
||||
<path fill="#444444" fill-rule="nonzero" d="M26.0277966 61.6955179v94.4712621H192.316497V61.6955179L109.172147 14.459887"/>
|
||||
<path fill="#939393" fill-rule="nonzero" d="M109.172147 109.172147V14.459887L26.0277966 61.8160169L192.316497 156.528277l-83.14435 47.35613l-83.1443504-47.35613"/>
|
||||
<path fill="#E3E3E3" fill-rule="nonzero" d="M190.870508 60.7315254h-82.421355V202.438418"/>
|
||||
<path fill="#FFFFFF" fill-rule="nonzero" d="M26.0277966 60.7315254H192.316497l-83.14435 47.7176276"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,38 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
section { min-width: 0; }
|
||||
h4 { margin: 12px 0 6px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; font-weight: 500; }
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-sideBar-border);
|
||||
border-radius: 6px;
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
dt { color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
|
||||
dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
code { white-space: pre-wrap; font-family: var(--oa-code-font); font-size: type.$font-size-xs; }
|
||||
}
|
||||
.meta{
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.meta,
|
||||
.empty {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { CallDetail } from "../api";
|
||||
import { JsonEditor } from "./ui/JsonEditor";
|
||||
import { Tabs, type TabItem } from "./ui/Tabs";
|
||||
import styles from "./CallDetails.module.scss";
|
||||
|
||||
const show = (value: string | number | null) => value ?? "-";
|
||||
const timing = (value: number | null) => value == null ? "-" : `${value} ms`;
|
||||
|
||||
export function CallDetails({ detail }: { detail: CallDetail }) {
|
||||
const { call, request, response_chunks: chunks, cursor_trace: cursorTrace } = detail;
|
||||
const responseBody = chunks.map((chunk) => chunk.data).join("");
|
||||
const responseBytes = chunks.reduce((total, chunk) => total + chunk.byte_count, 0);
|
||||
const fields: Array<[string, string | number]> = [
|
||||
["Call ID", call.call_id],
|
||||
[t("调用类型"), call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM"],
|
||||
[t("路由"), call.route === "cursor_official" ? t("Cursor 官方") : "BYOK"],
|
||||
["Run ID", call.run_id],
|
||||
["Conversation ID", call.conversation_id],
|
||||
[t("上游调用序号"), call.provider_call_index],
|
||||
["Model Hash", show(call.model_hash)],
|
||||
[t("上游类型"), call.provider_type],
|
||||
[t("上游地址"), call.provider_url],
|
||||
[t("最终请求类型"), call.request_type],
|
||||
[t("最终请求地址"), call.request_url],
|
||||
["Model ID", call.model_id],
|
||||
[t("显示名称"), call.display_name],
|
||||
[t("思考强度"), show(call.reasoning_effort)],
|
||||
["Fast", call.fast == null ? "-" : call.fast ? t("是") : t("否")],
|
||||
[t("状态"), call.status],
|
||||
["Finish Reason", show(call.finish_reason)],
|
||||
["HTTP Status", show(call.http_status)],
|
||||
["Created At", `${call.created_at_ms} · ${new Date(call.created_at_ms).toLocaleString()}`],
|
||||
[t("耗时"), timing(call.duration_ms)],
|
||||
["TTFB", timing(call.ttfb_ms)],
|
||||
["TTFT", timing(call.ttft_ms)],
|
||||
["Input Token", show(call.input_tokens)],
|
||||
["Output Token", show(call.output_tokens)],
|
||||
["Total Token", show(call.total_tokens)],
|
||||
["Cache Read Token", show(call.cache_read_tokens)],
|
||||
["Cache Write Token", show(call.cache_write_tokens)],
|
||||
["Reasoning Token", show(call.reasoning_tokens)],
|
||||
[t("消息数"), call.message_count],
|
||||
[t("工具数"), call.tool_count],
|
||||
[t("详细记录"), call.detailed ? t("是") : t("否")],
|
||||
["Error Kind", show(call.error_kind)],
|
||||
["Error Message", show(call.error_message)],
|
||||
];
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ value: "call", label: t("调用信息"), content: <section>
|
||||
<dl className={styles.details}>{fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd><code>{value}</code></dd></div>)}</dl>
|
||||
</section> },
|
||||
{ value: "request", label: t("请求"), content: <section>
|
||||
{request ? <>
|
||||
<div className={styles.meta}>{t("字节数")}:{request.byte_count}</div>
|
||||
<h4>{t("请求头")}</h4><JsonEditor ariaLabel={t("请求头")} value={JSON.stringify(request.headers)} readOnly />
|
||||
<h4>{t("请求体")}</h4><JsonEditor ariaLabel={t("请求体")} value={JSON.stringify(request.body)} readOnly detail />
|
||||
</> : <div className={styles.empty}>{t("未记录请求内容,请开启详细记录后重试。")}</div>}
|
||||
</section> },
|
||||
{ value: "response", label: t("响应流"), content: <section>
|
||||
{chunks.length > 0 ? <>
|
||||
<div className={styles.meta}>{t("分块数")}:{chunks.length} · {t("字节数")}:{responseBytes}</div>
|
||||
<JsonEditor ariaLabel={t("响应流")} value={responseBody} readOnly detail />
|
||||
</> : <div className={styles.empty}>{t("未记录响应内容,请开启详细记录后重试。")}</div>}
|
||||
</section> },
|
||||
];
|
||||
|
||||
if (cursorTrace) tabs.push({ value: "cursor-trace", label: t("Cursor 追踪"), content: <section>
|
||||
<div className={styles.meta}>
|
||||
Request ID:{cursorTrace.trace.request_id} · {t("工件数")}:{cursorTrace.artifacts.length}
|
||||
</div>
|
||||
<JsonEditor
|
||||
ariaLabel={t("Cursor 追踪")}
|
||||
value={JSON.stringify({ trace: cursorTrace.trace, artifacts: cursorTrace.artifacts })}
|
||||
readOnly
|
||||
detail
|
||||
/>
|
||||
</section> });
|
||||
|
||||
return <div className={styles.root}><Tabs items={tabs} /></div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.status {
|
||||
padding: 3px 7px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-input-background);
|
||||
border-radius: 99px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.completed { color: #56b870; }
|
||||
.failed { color: var(--vscode-errorForeground, #f48771); }
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { LlmCall } from "../api";
|
||||
import controls from "./ui/Controls.module.scss";
|
||||
import { DataTable, type DataTableColumn } from "./ui/DataTable";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { TooltipTrigger } from "./ui/TooltipTrigger";
|
||||
import { eyeIcon } from "./ui/icons";
|
||||
import styles from "./CallTable.module.scss";
|
||||
|
||||
const value = (input: string | number | null) => input ?? "-";
|
||||
const milliseconds = (input: number | null) => input == null ? "-" : `${input} ms`;
|
||||
|
||||
export function CallTable({ calls, onDetails }: { calls: LlmCall[]; onDetails: (call: LlmCall) => void }) {
|
||||
const columns: DataTableColumn<LlmCall>[] = [
|
||||
{
|
||||
key: "status",
|
||||
header: t("状态"),
|
||||
render: (call) => (
|
||||
<span
|
||||
className={[styles.status, styles[call.status]]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: "display_name",
|
||||
header: t("显示名称"),
|
||||
render: (call) => call.display_name,
|
||||
title: (call) => call.display_name,
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: t("时间"),
|
||||
render: (call) => new Date(call.created_at_ms).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "model_id",
|
||||
header: t("模型名称"),
|
||||
render: (call) => call.model_id,
|
||||
title: (call) => call.model_id,
|
||||
},
|
||||
{
|
||||
key: "reasoning_effort",
|
||||
header: t("思考强度"),
|
||||
render: (call) => value(call.reasoning_effort),
|
||||
},
|
||||
{
|
||||
key: "fast",
|
||||
header: "Fast",
|
||||
render: (call) => call.fast == null ? "-" : call.fast ? t("是") : t("否"),
|
||||
},
|
||||
|
||||
{
|
||||
key: "call_kind",
|
||||
header: t("调用类型"),
|
||||
render: (call) =>
|
||||
call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM",
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
header: t("路由"),
|
||||
render: (call) =>
|
||||
call.route === "cursor_official" ? t("Cursor 官方") : "BYOK",
|
||||
},
|
||||
|
||||
// { key: "model_hash", header: "Model Hash", render: (call) => value(call.model_hash), title: (call) => call.model_hash ?? undefined },
|
||||
// { key: "provider_type", header: t("供应商类型"), render: (call) => call.provider_type },
|
||||
// { key: "provider_url", header: t("供应商地址"), render: (call) => call.provider_url, title: (call) => call.provider_url },
|
||||
// { key: "request_type", header: t("最终请求类型"), render: (call) => call.request_type },
|
||||
// { key: "request_url", header: t("最终请求地址"), render: (call) => call.request_url, title: (call) => call.request_url },
|
||||
{
|
||||
key: "finish_reason",
|
||||
header: "Finish Reason",
|
||||
render: (call) => value(call.finish_reason),
|
||||
},
|
||||
{ key: "http", header: "HTTP", render: (call) => value(call.http_status) },
|
||||
{
|
||||
key: "duration",
|
||||
header: t("耗时"),
|
||||
render: (call) => milliseconds(call.duration_ms),
|
||||
},
|
||||
{
|
||||
key: "ttfb",
|
||||
header: "TTFB",
|
||||
render: (call) => milliseconds(call.ttfb_ms),
|
||||
},
|
||||
{
|
||||
key: "ttft",
|
||||
header: "TTFT",
|
||||
render: (call) => milliseconds(call.ttft_ms),
|
||||
},
|
||||
{
|
||||
key: "input_tokens",
|
||||
header: "Input Token",
|
||||
render: (call) => value(call.input_tokens),
|
||||
},
|
||||
{
|
||||
key: "output_tokens",
|
||||
header: "Output Token",
|
||||
render: (call) => value(call.output_tokens),
|
||||
},
|
||||
{
|
||||
key: "total_tokens",
|
||||
header: "Total Token",
|
||||
render: (call) => value(call.total_tokens),
|
||||
},
|
||||
{
|
||||
key: "cache_read",
|
||||
header: "Cache Read",
|
||||
render: (call) => value(call.cache_read_tokens),
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
header: "Cache Write",
|
||||
render: (call) => value(call.cache_write_tokens),
|
||||
},
|
||||
{
|
||||
key: "reasoning_tokens",
|
||||
header: "Reasoning Token",
|
||||
render: (call) => value(call.reasoning_tokens),
|
||||
},
|
||||
{
|
||||
key: "message_count",
|
||||
header: t("消息数"),
|
||||
render: (call) => call.message_count,
|
||||
},
|
||||
{
|
||||
key: "tool_count",
|
||||
header: t("工具数"),
|
||||
render: (call) => call.tool_count,
|
||||
},
|
||||
{
|
||||
key: "detailed",
|
||||
header: t("详细记录"),
|
||||
render: (call) => (call.detailed ? t("是") : t("否")),
|
||||
},
|
||||
|
||||
{
|
||||
key: "error_kind",
|
||||
header: "Error Kind",
|
||||
render: (call) => value(call.error_kind),
|
||||
title: (call) => call.error_kind ?? undefined,
|
||||
},
|
||||
{
|
||||
key: "error_message",
|
||||
header: "Error Message",
|
||||
render: (call) => value(call.error_message),
|
||||
title: (call) => call.error_message ?? undefined,
|
||||
},
|
||||
{
|
||||
key: "call_id",
|
||||
header: "Call ID",
|
||||
render: (call) => call.call_id,
|
||||
title: (call) => call.call_id,
|
||||
},
|
||||
{
|
||||
key: "run_id",
|
||||
header: "Run ID",
|
||||
render: (call) => call.run_id,
|
||||
title: (call) => call.run_id,
|
||||
},
|
||||
{
|
||||
key: "conversation_id",
|
||||
header: "Conversation ID",
|
||||
render: (call) => call.conversation_id,
|
||||
title: (call) => call.conversation_id,
|
||||
},
|
||||
{
|
||||
key: "call_index",
|
||||
header: "Call Index",
|
||||
render: (call) => call.provider_call_index,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: t("操作"),
|
||||
sticky: "right",
|
||||
render: (call) => (
|
||||
<TooltipTrigger label={t("查看详情")}>
|
||||
<button
|
||||
className={controls.iconButton}
|
||||
aria-label={t("查看详情")}
|
||||
onClick={() => onDetails(call)}
|
||||
>
|
||||
<Icon icon={eyeIcon} size="1.1em" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
),
|
||||
},
|
||||
];
|
||||
return <DataTable rows={calls} columns={columns} rowKey={(call) => call.call_id} minWidth={4180} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { RefObject } from "react";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { windowCloseIcon } from "../ui/icons";
|
||||
import type { AdSlot } from "./types";
|
||||
import styles from "./Ads.module.scss";
|
||||
|
||||
type AdMenuProps = {
|
||||
ads: AdSlot[];
|
||||
activeAdId?: string;
|
||||
dismissingAdId?: string;
|
||||
readAdIds: ReadonlySet<string>;
|
||||
triggerRefs: RefObject<Map<string, HTMLButtonElement>>;
|
||||
onOpen: (ad: AdSlot) => void;
|
||||
onDismiss: (ad: AdSlot) => void;
|
||||
};
|
||||
|
||||
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
|
||||
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
|
||||
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
|
||||
<button
|
||||
ref={(node) => {
|
||||
if (node) triggerRefs.current.set(ad.id, node);
|
||||
else triggerRefs.current.delete(ad.id);
|
||||
}}
|
||||
type="button"
|
||||
className={styles.menuSlot}
|
||||
title={`${ad.target.title}\n${ad.target.description}`}
|
||||
aria-label={`${ad.target.title},${ad.target.description}${readAdIds.has(ad.id) ? "" : `,${t("未读")}`}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={activeAdId === ad.id}
|
||||
aria-controls={activeAdId === ad.id ? "menu-ad-dialog" : undefined}
|
||||
onClick={() => onOpen(ad)}
|
||||
>
|
||||
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
|
||||
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
|
||||
<span className={styles.menuCopy}>
|
||||
<span className={styles.menuTitle}>{ad.target.title}</span>
|
||||
<span className={styles.menuSubtitle}>{ad.target.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuDismiss}
|
||||
aria-label={`${t("不再显示广告")}:${ad.target.title}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={dismissingAdId === ad.id}
|
||||
aria-controls={dismissingAdId === ad.id ? "dismiss-ad-dialog" : undefined}
|
||||
onClick={() => onDismiss(ad)}
|
||||
>
|
||||
<Icon icon={windowCloseIcon} size="1em" />
|
||||
</button>
|
||||
</div>)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.menuSlots {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.menuSlotContainer {
|
||||
position: relative;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.menuSlot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 32px 0 8px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.menuDismiss {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: color-mix(in srgb, var(--vscode-foreground) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.unreadDot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 25px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--vscode-errorForeground);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
.menuImage {
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menuCopy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.menuTitle,
|
||||
.menuSubtitle {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menuTitle {
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menuSubtitle {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.floatingAd {
|
||||
position: fixed;
|
||||
z-index: 20000;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: min(360px, calc(100vh - 32px));
|
||||
max-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
|
||||
animation: ad-enter 180ms ease-out;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex: 0 0 112px;
|
||||
position: relative;
|
||||
height: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.promotionLabel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 3px 7px;
|
||||
color: white;
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 48%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: type.$font-size-xl;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 16%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background: rgb(0 0 0 / 30%);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
--scroll-shadow-bottom-size: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bodyContent {
|
||||
padding: 14px 16px 18px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
color: #202020;
|
||||
background: #f2f2f2;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ad-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.floatingAd {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { windowCloseIcon } from "../ui/icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import type { AdAction, AdSlot } from "./types";
|
||||
import styles from "./Ads.module.scss";
|
||||
|
||||
type AdTextSection =
|
||||
| { key: "intro"; kind: "intro" }
|
||||
| { key: string; kind: "detail"; label: string; value: string };
|
||||
|
||||
type FloatingAdProps = {
|
||||
ad: AdSlot;
|
||||
trigger: HTMLButtonElement | null;
|
||||
onClose: () => void;
|
||||
onAction: (action: AdAction) => void;
|
||||
};
|
||||
|
||||
export function FloatingAd({ ad, trigger, onClose, onAction }: FloatingAdProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const textSections = useMemo<AdTextSection[]>(() => [
|
||||
{ key: "intro", kind: "intro" },
|
||||
...ad.content.details.map((detail, index) => ({
|
||||
key: `detail-${index}-${detail.label}`,
|
||||
kind: "detail" as const,
|
||||
label: detail.label,
|
||||
value: detail.value,
|
||||
})),
|
||||
], [ad.content.details]);
|
||||
|
||||
useEffect(() => {
|
||||
dialog.current?.focus();
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!dialog.current?.contains(target) && !trigger?.contains(target)) onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
trigger?.focus();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, trigger]);
|
||||
|
||||
return createPortal(<div
|
||||
id="menu-ad-dialog"
|
||||
ref={dialog}
|
||||
className={styles.floatingAd}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={ad.content.title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={styles.hero}>
|
||||
<img src={ad.content.imageUrl} alt="" />
|
||||
<span className={styles.promotionLabel}>{t("推广")}</span>
|
||||
<button type="button" className={styles.close} aria-label={t("关闭广告")} onClick={() => { onClose(); trigger?.focus(); }}>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>
|
||||
<VirtualList
|
||||
items={textSections}
|
||||
itemKey="key"
|
||||
estimatedItemHeight={58}
|
||||
itemGap={12}
|
||||
className={`${styles.body} scroll-shadow-bottom`}
|
||||
contentClassName={styles.bodyContent}
|
||||
>
|
||||
{(section) => section.kind === "intro"
|
||||
? <div className={styles.intro}>
|
||||
<h2>{ad.content.title}</h2>
|
||||
<p>{ad.content.description}</p>
|
||||
</div>
|
||||
: <div className={styles.detail}>
|
||||
<h3>{section.label}</h3>
|
||||
<p>{section.value}</p>
|
||||
</div>}
|
||||
</VirtualList>
|
||||
<div className={styles.footer}>
|
||||
<button type="button" onClick={() => { onClose(); onAction(ad.content.button.action); }}>{ad.content.button.label}</button>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const AdActionType = {
|
||||
OpenBrowser: "open_browser",
|
||||
} as const;
|
||||
|
||||
export type AdAction = {
|
||||
type: typeof AdActionType.OpenBrowser;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type AdSlot = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
placement: "menu";
|
||||
target: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
content: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
details: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
button: {
|
||||
label: string;
|
||||
action: AdAction;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type AdRuntime = {
|
||||
slots: AdSlot[];
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.axis {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin-top: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 28px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { init, Rect, type ElementEvent } from "zrender";
|
||||
import type { Locale } from "../../i18n/runtime";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||
import styles from "./ContributionCalendarChart.module.scss";
|
||||
|
||||
export type ContributionDay = {
|
||||
date: string;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
type ContributionCalendarChartProps = {
|
||||
data: ContributionDay[];
|
||||
};
|
||||
|
||||
type CalendarCell = ContributionDay & {
|
||||
column: number;
|
||||
row: number;
|
||||
level: number;
|
||||
};
|
||||
|
||||
type CellExtra = CalendarCell & {
|
||||
kind: "calendar-cell";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
date: string;
|
||||
tokens: number;
|
||||
anchor: TooltipAnchor;
|
||||
};
|
||||
|
||||
type AxisLabel = {
|
||||
key: string;
|
||||
text: string;
|
||||
left: number;
|
||||
};
|
||||
|
||||
const levelColors = [
|
||||
"rgba(139, 148, 158, 0.20)",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
];
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const CALENDAR_CONFIG = {
|
||||
cellAspectRatio: 0.9,
|
||||
cellGap: 3,
|
||||
resizeTransitionMs: 180,
|
||||
rowCount: 7,
|
||||
axisLabelGap: 8,
|
||||
axisLabelWidth: 28,
|
||||
} as const;
|
||||
function parseDate(date: string) {
|
||||
return new Date(`${date}T00:00:00Z`);
|
||||
}
|
||||
|
||||
function mondayIndex(date: Date) {
|
||||
return (date.getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
function cellOffset(index: number, cellSize: number) {
|
||||
return index * (cellSize + CALENDAR_CONFIG.cellGap);
|
||||
}
|
||||
|
||||
function isCellExtra(value: unknown): value is CellExtra {
|
||||
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
|
||||
}
|
||||
|
||||
function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
|
||||
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
|
||||
const firstDate = parseDate(data[0].date);
|
||||
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
|
||||
const cells: CalendarCell[] = data.map((day) => {
|
||||
const date = parseDate(day.date);
|
||||
const daysFromStart = Math.round((date.getTime() - calendarStart.getTime()) / DAY_IN_MS);
|
||||
const level = day.tokens === 0 ? 0 : Math.max(1, Math.ceil((day.tokens / maximum) * 4));
|
||||
return { ...day, column: Math.floor(daysFromStart / 7), row: mondayIndex(date), level };
|
||||
});
|
||||
const columnCount = cells.at(-1)!.column + 1;
|
||||
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
|
||||
const date = parseDate(cell.date);
|
||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
|
||||
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column });
|
||||
return ticks;
|
||||
}, []);
|
||||
return { cells, columnCount, monthTicks };
|
||||
}
|
||||
|
||||
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
|
||||
const { locale } = useI18n();
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
|
||||
const scheduleDrawRef = useRef<() => void>(() => undefined);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
|
||||
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
|
||||
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
||||
layoutRef.current = layout;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
const node = canvasRef.current;
|
||||
if (!scroller || !node) return;
|
||||
|
||||
const chart = init(node, {
|
||||
renderer: "canvas",
|
||||
width: 1,
|
||||
height: 1,
|
||||
useDirtyRect: true,
|
||||
});
|
||||
|
||||
const handleMouseOver = (event: ElementEvent) => {
|
||||
const extra = event.target?.extra;
|
||||
if (!isCellExtra(extra)) return;
|
||||
const anchor: TooltipAnchor = {
|
||||
contextElement: node,
|
||||
getBoundingClientRect: () => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
|
||||
},
|
||||
};
|
||||
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
|
||||
};
|
||||
const handleMouseOut = (event: ElementEvent) => {
|
||||
if (isCellExtra(event.target?.extra)) setTooltip(null);
|
||||
};
|
||||
|
||||
chart.on("mouseover", handleMouseOver);
|
||||
chart.on("mouseout", handleMouseOut);
|
||||
|
||||
const cellRects = new Map<string, Rect>();
|
||||
let drawFrame = 0;
|
||||
let lastAvailableWidth = -1;
|
||||
let lastCanvasHeight = -1;
|
||||
let lastLayout: typeof layout = null;
|
||||
const draw = () => {
|
||||
const currentLayout = layoutRef.current;
|
||||
if (!currentLayout) return;
|
||||
const availableWidth = Math.floor(scroller.getBoundingClientRect().width);
|
||||
if (availableWidth <= 0 || (availableWidth === lastAvailableWidth && currentLayout === lastLayout)) return;
|
||||
const gapsWidth = (currentLayout.columnCount - 1) * CALENDAR_CONFIG.cellGap;
|
||||
const cellWidth = Math.max(0, (availableWidth - gapsWidth) / currentLayout.columnCount);
|
||||
const cellHeight = cellWidth / CALENDAR_CONFIG.cellAspectRatio;
|
||||
const width = availableWidth;
|
||||
const height = CALENDAR_CONFIG.rowCount * cellHeight
|
||||
+ (CALENDAR_CONFIG.rowCount - 1) * CALENDAR_CONFIG.cellGap;
|
||||
|
||||
let lastLabelEnd = -Infinity;
|
||||
const nextAxisLabels = currentLayout.monthTicks.flatMap((tick) => {
|
||||
const left = Math.min(
|
||||
cellOffset(tick.column, cellWidth),
|
||||
availableWidth - CALENDAR_CONFIG.axisLabelWidth,
|
||||
);
|
||||
if (left < lastLabelEnd + CALENDAR_CONFIG.axisLabelGap) return [];
|
||||
lastLabelEnd = left + CALENDAR_CONFIG.axisLabelWidth;
|
||||
return [{ ...tick, left }];
|
||||
});
|
||||
setAxisLabels(nextAxisLabels);
|
||||
|
||||
if (availableWidth !== lastAvailableWidth || height !== lastCanvasHeight) {
|
||||
node.style.width = "100%";
|
||||
node.style.height = `${height}px`;
|
||||
chart.resize({ width, height });
|
||||
lastAvailableWidth = availableWidth;
|
||||
lastCanvasHeight = height;
|
||||
}
|
||||
lastLayout = currentLayout;
|
||||
|
||||
const currentDates = new Set(currentLayout.cells.map((cell) => cell.date));
|
||||
for (const [date, rect] of cellRects) {
|
||||
if (currentDates.has(date)) continue;
|
||||
chart.remove(rect);
|
||||
cellRects.delete(date);
|
||||
}
|
||||
|
||||
for (const cell of currentLayout.cells) {
|
||||
const x = cellOffset(cell.column, cellWidth);
|
||||
const y = cellOffset(cell.row, cellHeight);
|
||||
const shape = {
|
||||
x,
|
||||
y,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
r: Math.min(3, Math.min(cellWidth, cellHeight) / 4),
|
||||
};
|
||||
const extra = {
|
||||
...cell,
|
||||
kind: "calendar-cell" as const,
|
||||
x,
|
||||
y,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
} satisfies CellExtra;
|
||||
const current = cellRects.get(cell.date);
|
||||
|
||||
if (current) {
|
||||
current.extra = extra;
|
||||
current.stopAnimation();
|
||||
current.animateTo(
|
||||
{ shape, style: { fill: levelColors[cell.level] } },
|
||||
{ duration: CALENDAR_CONFIG.resizeTransitionMs, easing: "cubicOut" },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = new Rect({
|
||||
shape,
|
||||
style: {
|
||||
fill: levelColors[cell.level],
|
||||
stroke: "rgba(139, 148, 158, 0.10)",
|
||||
lineWidth: 1,
|
||||
},
|
||||
cursor: "default",
|
||||
extra,
|
||||
});
|
||||
cellRects.set(cell.date, rect);
|
||||
chart.add(rect);
|
||||
}
|
||||
setTooltip(null);
|
||||
};
|
||||
const scheduleDraw = () => {
|
||||
window.cancelAnimationFrame(drawFrame);
|
||||
drawFrame = window.requestAnimationFrame(draw);
|
||||
};
|
||||
scheduleDrawRef.current = scheduleDraw;
|
||||
const observer = new ResizeObserver(scheduleDraw);
|
||||
observer.observe(scroller);
|
||||
scheduleDraw();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.cancelAnimationFrame(drawFrame);
|
||||
scheduleDrawRef.current = () => undefined;
|
||||
chart.dispose();
|
||||
};
|
||||
}, [layout !== null]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scheduleDrawRef.current();
|
||||
}, [layout]);
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return (
|
||||
<section className={styles.root} aria-label={t("过去一年的 Token 用量")}>
|
||||
<div ref={scrollerRef} className={styles.scroller}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
role="img"
|
||||
aria-label={t("过去一年的 Token 用量日历")}
|
||||
/>
|
||||
<div className={styles.axis} aria-hidden="true">
|
||||
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip anchor={tooltip?.anchor ?? null}>
|
||||
<div className={styles.tooltipContent}>
|
||||
<strong>{tooltip?.date}</strong>
|
||||
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(tooltip?.tokens ?? 0) })}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { TokenUsageGranularity } from "../../api";
|
||||
import { formatCompactInteger } from "../../utils/numberFormat";
|
||||
import { EChart } from "./EChart";
|
||||
import styles from "./DailyTokenUsageChart.module.scss";
|
||||
|
||||
export type DailyTokenUsage = {
|
||||
bucketStartMs: number;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
outputTokens: number;
|
||||
};
|
||||
|
||||
type TooltipItem = {
|
||||
dataIndex: number;
|
||||
};
|
||||
|
||||
const seriesColors = {
|
||||
input: "#0091ff",
|
||||
cacheRead: "#40c463",
|
||||
cacheWrite: "#3A62BA",
|
||||
output: "#E7B40B",
|
||||
} as const;
|
||||
const levelLineColor = "#E7B40B";
|
||||
const emptyBarColor = "rgba(139, 148, 158, 0.20)";
|
||||
const EMPTY_BAR_RATIO = 1;
|
||||
const DATA_HEIGHT_RATIO = 1;
|
||||
|
||||
const seriesFocus = {
|
||||
emphasis: { focus: "series" },
|
||||
blur: { itemStyle: { opacity: 0.2 } },
|
||||
} as const;
|
||||
|
||||
function colorMark(color: string): string {
|
||||
return `<span style="display:inline-block;width:8px;height:8px;margin-right:6px;border-radius:50%;background-color:${color};vertical-align:middle"></span>`;
|
||||
}
|
||||
|
||||
function formatDay(value: Date) {
|
||||
return `${value.getUTCMonth() + 1}/${value.getUTCDate()}`;
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatAxisLabel(bucketStartMs: number, granularity: TokenUsageGranularity) {
|
||||
const value = new Date(bucketStartMs);
|
||||
if (granularity === "minute") return `${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
if (granularity === "hour") return `${pad(value.getHours())}:00`;
|
||||
const day = value.getUTCDay();
|
||||
if (day === 6) return t("周六");
|
||||
if (day === 0) return t("周日");
|
||||
return formatDay(value);
|
||||
}
|
||||
|
||||
function formatTooltipTime(bucketStartMs: number, granularity: TokenUsageGranularity) {
|
||||
const value = new Date(bucketStartMs);
|
||||
if (granularity === "day") return formatDay(value);
|
||||
const time = `${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
return `${value.getMonth() + 1}/${value.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
function totalTokens(day: DailyTokenUsage) {
|
||||
return day.inputTokens + day.cacheReadTokens + day.cacheWriteTokens + day.outputTokens;
|
||||
}
|
||||
|
||||
export function DailyTokenUsageChart({
|
||||
data,
|
||||
granularity,
|
||||
}: {
|
||||
data: DailyTokenUsage[];
|
||||
granularity: TokenUsageGranularity;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0);
|
||||
const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO);
|
||||
const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO;
|
||||
const nonZeroTotals = data.map(totalTokens).filter((total) => total !== 0);
|
||||
const averageLevel = nonZeroTotals.length === 0
|
||||
? 0
|
||||
: nonZeroTotals.reduce((sum, total) => sum + total, 0) / nonZeroTotals.length;
|
||||
|
||||
const option = useMemo<EChartsCoreOption>(() => ({
|
||||
animationDuration: 450,
|
||||
animationEasing: "cubicOut",
|
||||
grid: { top: 8, right: 0, bottom: 0, left: 0 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
confine: true,
|
||||
backgroundColor: "var(--vscode-editorHoverWidget-background)",
|
||||
borderColor: "var(--vscode-editorHoverWidget-border)",
|
||||
textStyle: { color: "var(--vscode-foreground)", fontFamily: "PingFang-Medium" },
|
||||
extraCssText: "border-radius: 8px; box-shadow: 0 12px 32px rgb(0 0 0 / 30%); font-size: var(--daily-token-tooltip-font-size); line-height: 1.5;",
|
||||
axisPointer: {
|
||||
type: "shadow",
|
||||
shadowStyle: { color: "rgba(139, 148, 158, 0.14)" },
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const first = (params as TooltipItem[])[0];
|
||||
const day = data[first.dataIndex];
|
||||
return [
|
||||
formatTooltipTime(day.bucketStartMs, granularity),
|
||||
`${t("总请求")}:${formatCompactInteger(totalTokens(day))}`,
|
||||
`${colorMark(seriesColors.input)}${t("输入(非缓存)")}:${formatCompactInteger(day.inputTokens)}`,
|
||||
`${colorMark(seriesColors.cacheRead)}${t("缓存输入")}:${formatCompactInteger(day.cacheReadTokens)}`,
|
||||
`${colorMark(seriesColors.cacheWrite)}${t("缓存写入")}:${formatCompactInteger(day.cacheWriteTokens)}`,
|
||||
`${colorMark(seriesColors.output)}${t("模型输出")}:${formatCompactInteger(day.outputTokens)}`,
|
||||
].join("<br/>");
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: data.map(({ bucketStartMs }) => bucketStartMs),
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: "rgba(139, 148, 158, 0.32)" } },
|
||||
axisLabel: {
|
||||
interval: "auto",
|
||||
hideOverlap: true,
|
||||
formatter: (_value: string, index: number) => formatAxisLabel(data[index].bucketStartMs, granularity),
|
||||
color: "#8c8c8c",
|
||||
fontFamily: "HFKos",
|
||||
margin: 14,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
show: false,
|
||||
min: 0,
|
||||
max: axisMaximum,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t("无用量"),
|
||||
type: "bar",
|
||||
stack: "empty-placeholder",
|
||||
data: data.map((day) => totalTokens(day) === 0 ? emptyBarHeight : 0),
|
||||
barMaxWidth: 18,
|
||||
silent: true,
|
||||
z: 0,
|
||||
itemStyle: { color: emptyBarColor, borderRadius: [2, 2, 0, 0] },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
{
|
||||
name: t("输入(非缓存)"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ inputTokens }) => inputTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.input },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("缓存输入"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ cacheReadTokens }) => cacheReadTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.cacheRead },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("缓存写入"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ cacheWriteTokens }) => cacheWriteTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.cacheWrite },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("模型输出"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ outputTokens }) => outputTokens),
|
||||
barMaxWidth: 18,
|
||||
barGap: "-100%",
|
||||
itemStyle: { color: seriesColors.output, borderRadius: [2, 2, 0, 0] },
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: "none",
|
||||
lineStyle: { color: levelLineColor, opacity: hovered ? 1 : 0, type: "dashed", width: 2 },
|
||||
label: {
|
||||
show: hovered,
|
||||
position: "insideStartTop",
|
||||
formatter: t("平均"),
|
||||
color: levelLineColor,
|
||||
distance: 8,
|
||||
},
|
||||
data: [{ yAxis: averageLevel }],
|
||||
},
|
||||
...seriesFocus,
|
||||
},
|
||||
],
|
||||
}), [averageLevel, axisMaximum, data, emptyBarHeight, granularity, hovered]);
|
||||
|
||||
return <EChart
|
||||
option={option}
|
||||
className={styles.root}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BarChart, GaugeChart, LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
|
||||
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { useEffect, useRef, type MouseEventHandler } from "react";
|
||||
import styles from "./EChart.module.scss";
|
||||
|
||||
use([BarChart, GaugeChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
type EChartProps = {
|
||||
option: EChartsCoreOption;
|
||||
className?: string;
|
||||
onMouseEnter?: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function EChart({ option, className = styles.root, onMouseEnter, onMouseLeave }: EChartProps) {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = element.current;
|
||||
if (!node) return;
|
||||
const chart = init(node, undefined, { renderer: "canvas" });
|
||||
const observer = new ResizeObserver(() => chart.resize());
|
||||
observer.observe(node);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const node = element.current;
|
||||
if (node) getInstanceByDom(node)?.setOption(option, { notMerge: true });
|
||||
}, [option]);
|
||||
|
||||
return <div ref={element} className={className} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import type { LlmCall } from "../../api";
|
||||
import { EChart } from "./EChart";
|
||||
|
||||
export function LatencyChart({ calls }: { calls: LlmCall[] }) {
|
||||
const points = calls.slice(0, 20).reverse();
|
||||
const option: EChartsCoreOption = {
|
||||
animationDuration: 280,
|
||||
color: ["#d79a62", "#ad84cf"],
|
||||
grid: { top: 22, right: 18, bottom: 30, left: 48 },
|
||||
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
|
||||
yAxis: { type: "value", axisLabel: { color: "#888", formatter: "{value} ms" }, splitLine: { lineStyle: { color: "#8882" } } },
|
||||
series: [
|
||||
{ name: "TTFT", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttft_ms ?? 0) },
|
||||
{ name: t("总耗时"), type: "line", smooth: true, symbol: "none", data: points.map((call) => call.duration_ms ?? 0) },
|
||||
],
|
||||
};
|
||||
return <EChart option={option} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import type { LlmCall } from "../../api";
|
||||
import { EChart } from "./EChart";
|
||||
|
||||
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
|
||||
const points = calls.slice(0, 20).reverse();
|
||||
const option: EChartsCoreOption = {
|
||||
animationDuration: 280,
|
||||
color: ["#6f91f4", "#62c6a3"],
|
||||
grid: { top: 22, right: 18, bottom: 30, left: 48 },
|
||||
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
|
||||
yAxis: { type: "value", axisLabel: { color: "#888" }, splitLine: { lineStyle: { color: "#8882" } } },
|
||||
series: [
|
||||
{ name: t("输入 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.input_tokens ?? 0), barMaxWidth: 22 },
|
||||
{ name: t("输出 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.output_tokens ?? 0), barMaxWidth: 22 },
|
||||
],
|
||||
};
|
||||
return <EChart option={option} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useAppStore } from "../../store/appStore";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
const CaReady = createContext(false);
|
||||
const ModelsReady = createContext(false);
|
||||
|
||||
export function CursorCaProvider({ children }: { children: ReactNode }) {
|
||||
const { cursorHarness } = useAppStore();
|
||||
return <CaReady.Provider value={cursorHarness?.ca === "ready"}>{children}</CaReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh, children }: { busy: boolean; waitingForRefresh: boolean; onInitialize: () => void; onRefresh: () => void; children: ReactNode }) {
|
||||
const ready = useContext(CaReady);
|
||||
const { cursorHarness } = useAppStore();
|
||||
if (ready) return children;
|
||||
const installedLocally = cursorHarness?.ca === "untrusted";
|
||||
return <div className={styles.gate}>
|
||||
<strong>{installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
|
||||
<span>{installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
|
||||
<button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||
const { models } = useAppStore();
|
||||
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
|
||||
const ready = useContext(ModelsReady);
|
||||
if (ready) return children;
|
||||
return <div className={styles.gate}>
|
||||
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
|
||||
<span>{t("Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。")}</span>
|
||||
<div className={styles.gateActions}>
|
||||
<button className={controls.primary} disabled={busy} onClick={onAdd}>{t("添加模型")}</button>
|
||||
<button className={controls.secondary} disabled={busy} onClick={onImport}>{previewingImport ? t("读取中…") : t("导入旧版配置")}</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { IconifyIcon } from "@iconify/react/offline";
|
||||
import { useEffect, useRef } from "react";
|
||||
import Sortable from "sortablejs";
|
||||
import type { Model } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Card } from "../ui/Card";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelGrouping = "flat" | "provider" | "type";
|
||||
|
||||
export type CursorModelGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: IconifyIcon;
|
||||
models: Model[];
|
||||
};
|
||||
|
||||
type CursorModelCardsProps = {
|
||||
models: Model[];
|
||||
grouping: CursorModelGrouping;
|
||||
disabled: boolean;
|
||||
testingModelHashes: Set<string>;
|
||||
testResults: Map<string, CursorModelTestState>;
|
||||
onTest: (model: Model) => void;
|
||||
onEdit: (model: Model) => void;
|
||||
onDuplicate: (model: Model) => void;
|
||||
onDelete: (model: Model) => void;
|
||||
onReorder: (modelHashes: string[]) => void;
|
||||
};
|
||||
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
|
||||
sortable: boolean;
|
||||
};
|
||||
|
||||
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
|
||||
const groups = new Map<string, CursorModelGroup>();
|
||||
for (const model of models) {
|
||||
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
|
||||
const group = groups.get(descriptor.key);
|
||||
if (group) {
|
||||
group.models.push(model);
|
||||
} else {
|
||||
groups.set(descriptor.key, { ...descriptor, models: [model] });
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
|
||||
<ModelGrid {...props} sortable />
|
||||
</div>;
|
||||
|
||||
return <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
|
||||
<div className={styles.modelGroupHeader}>
|
||||
<Icon icon={group.icon} size="1.1em" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
<ModelGrid {...props} models={group.models} sortable={false} />
|
||||
</section>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ModelGrid({
|
||||
models,
|
||||
sortable: sortableEnabled,
|
||||
disabled,
|
||||
testingModelHashes,
|
||||
testResults,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: ModelGridProps) {
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const sortable = useRef<Sortable | null>(null);
|
||||
const currentModels = useRef(models);
|
||||
const reorder = useRef(onReorder);
|
||||
currentModels.current = models;
|
||||
reorder.current = onReorder;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortableEnabled || !grid.current) return;
|
||||
sortable.current = Sortable.create(grid.current, {
|
||||
animation: 160,
|
||||
dataIdAttr: "data-model-hash",
|
||||
draggable: `.${styles.modelCard}`,
|
||||
handle: `.${styles.sortHandle}`,
|
||||
ghostClass: styles.sortGhost,
|
||||
chosenClass: styles.sortChosen,
|
||||
dragClass: styles.sortDragging,
|
||||
forceFallback: true,
|
||||
fallbackOnBody: true,
|
||||
fallbackTolerance: 3,
|
||||
onEnd: (event) => {
|
||||
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
|
||||
const newIndex = event.newDraggableIndex ?? event.newIndex;
|
||||
if (typeof oldIndex !== "number"
|
||||
|| typeof newIndex !== "number"
|
||||
|| oldIndex === newIndex) {
|
||||
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
|
||||
return;
|
||||
}
|
||||
const reordered = currentModels.current.slice();
|
||||
const [moved] = reordered.splice(oldIndex, 1);
|
||||
if (!moved || newIndex < 0 || newIndex > reordered.length) {
|
||||
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
|
||||
return;
|
||||
}
|
||||
reordered.splice(newIndex, 0, moved);
|
||||
reorder.current(reordered.map((model) => model.model_hash));
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
sortable.current?.destroy();
|
||||
sortable.current = null;
|
||||
};
|
||||
}, [sortableEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
sortable.current?.option("disabled", disabled);
|
||||
sortable.current?.sort(models.map((model) => model.model_hash), false);
|
||||
}, [disabled, models]);
|
||||
|
||||
return <div ref={grid} className={styles.modelGrid}>
|
||||
{models.map((model) => {
|
||||
const result = testResults.get(model.model_hash);
|
||||
const testing = testingModelHashes.has(model.model_hash);
|
||||
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
|
||||
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
|
||||
<Icon icon={dragIcon} size="1.25em" />
|
||||
</button>}
|
||||
<div className={styles.modelCardContent}>
|
||||
<div className={styles.modelCardTop}>
|
||||
<div className={styles.modelCardName}>
|
||||
<span className={styles.modelCardNameText}>{model.display_name}</span>
|
||||
<span className={styles.modelCardModelId}>{model.model_id}</span>
|
||||
</div>
|
||||
<span className={styles.modelTypeBadge}>
|
||||
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
|
||||
{model.type === "anthropic" ? "Anthropic" : "OpenAI"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.modelCardTest}>
|
||||
<CursorModelTestResult state={result} testing={testing} />
|
||||
</div>
|
||||
<div className={styles.modelCardActions}>
|
||||
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
|
||||
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function providerGroup(model: Model) {
|
||||
const label = providerDomain(model.base_url);
|
||||
return { key: label, label, icon: flatColorOrganizationIcon };
|
||||
}
|
||||
|
||||
function providerDomain(baseUrl: string) {
|
||||
const value = baseUrl.trim();
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${value}`).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function typeGroup(model: Model) {
|
||||
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
|
||||
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
|
||||
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { ModelInput, ModelType } from "../../api";
|
||||
import { defaultCustomHeadersText } from "../../utils/modelDefaults";
|
||||
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
|
||||
import { JsonEditor } from "../ui/JsonEditor";
|
||||
import { Combobox, Select } from "../ui/Select";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import { claudeIcon, openAiIcon } from "../ui/icons";
|
||||
import { CursorPresetChips } from "./CursorPresetChips";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
model: ModelInput;
|
||||
openAIExtraParamsText: string;
|
||||
customHeadersText: string;
|
||||
anthropicExtraParamsText: string;
|
||||
};
|
||||
|
||||
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
model: {
|
||||
sort_order: 0,
|
||||
display_name: "",
|
||||
type: "openai",
|
||||
base_url: "",
|
||||
use_full_url: false,
|
||||
api_key: "",
|
||||
tooltip_data: "",
|
||||
model_id: "",
|
||||
reasoning_effort: null,
|
||||
openai_endpoint: "/v1/responses",
|
||||
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: null,
|
||||
max_completion_tokens: null,
|
||||
anthropic_max_tokens: null,
|
||||
anthropic_thinking_effort: "xhigh",
|
||||
thinking_budget_tokens: null,
|
||||
},
|
||||
openAIExtraParamsText: "{}",
|
||||
customHeadersText: defaultCustomHeadersText,
|
||||
anthropicExtraParamsText: "{}",
|
||||
});
|
||||
|
||||
export function CursorModelEditor({ draft, modelOptions, discovering, onChange, onDiscover }: {
|
||||
draft: CursorModelDraft;
|
||||
modelOptions: string[];
|
||||
discovering: boolean;
|
||||
onChange: (draft: CursorModelDraft) => void;
|
||||
onDiscover: () => void;
|
||||
}) {
|
||||
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
|
||||
const setType = (type: ModelType) => {
|
||||
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
|
||||
// 避免出现「类型是 Anthropic、URL 却是 OpenAI chat/completions」的错配
|
||||
const other: ModelType = type === "anthropic" ? "openai" : "anthropic";
|
||||
const preset = modelPresets.find((candidate) => trimTrailingSlash(presetEndpoint(candidate, other).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()));
|
||||
const endpoint = preset ? presetEndpoint(preset, type) : null;
|
||||
onChange({
|
||||
...draft,
|
||||
model: {
|
||||
...draft.model,
|
||||
type,
|
||||
...(endpoint ? {
|
||||
base_url: endpoint.baseUrl,
|
||||
use_full_url: endpoint.useFullUrl,
|
||||
custom_headers_enabled: endpoint.customHeaders !== null,
|
||||
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
|
||||
} : {}),
|
||||
openai_endpoint: type === "openai" ? (endpoint?.openaiEndpoint || draft.model.openai_endpoint || "/v1/responses") : "",
|
||||
anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
|
||||
},
|
||||
customHeadersText: endpoint?.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
|
||||
});
|
||||
};
|
||||
const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value));
|
||||
const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim());
|
||||
// 选中预设后,把该服务商已知的模型 id 并入下拉,方便直接选(仍可用「获取模型」发现)
|
||||
const presetModelOptions = modelPresets
|
||||
.filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()))
|
||||
.flatMap((preset) => preset.models.map((item) => item.model_id));
|
||||
const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])];
|
||||
const applyPreset = (preset: ModelPreset) => {
|
||||
const endpoint = presetEndpoint(preset, draft.model.type);
|
||||
const first = preset.models[0];
|
||||
// 切到别家服务商时清空 API Key(不同家的 Key 不能串用);同一家内切换协议则保留
|
||||
const currentBase = trimTrailingSlash(draft.model.base_url.trim());
|
||||
const sameProvider = [preset.endpoints.anthropic, preset.endpoints.openai]
|
||||
.some((candidate) => trimTrailingSlash(candidate.baseUrl) === currentBase);
|
||||
onChange({
|
||||
...draft,
|
||||
model: {
|
||||
...draft.model,
|
||||
base_url: endpoint.baseUrl,
|
||||
use_full_url: endpoint.useFullUrl,
|
||||
openai_endpoint: draft.model.type === "openai" ? endpoint.openaiEndpoint : draft.model.openai_endpoint,
|
||||
custom_headers_enabled: endpoint.customHeaders !== null,
|
||||
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
|
||||
api_key: sameProvider ? draft.model.api_key : "",
|
||||
model_id: first?.model_id ?? draft.model.model_id,
|
||||
display_name: first?.display_name ?? draft.model.display_name,
|
||||
tooltip_data: !draft.model.tooltip_data.trim() || draft.model.tooltip_data === t("备注") ? preset.name : draft.model.tooltip_data,
|
||||
context_window_tokens: first?.context_window_tokens ?? draft.model.context_window_tokens,
|
||||
...(draft.model.type === "openai"
|
||||
? { max_completion_tokens: first?.max_output_tokens ?? draft.model.max_completion_tokens }
|
||||
: { anthropic_max_tokens: first?.max_output_tokens ?? draft.model.anthropic_max_tokens }),
|
||||
},
|
||||
customHeadersText: endpoint.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
|
||||
});
|
||||
};
|
||||
const requestUrlPlaceholder = draft.model.use_full_url
|
||||
? draft.model.type === "anthropic"
|
||||
? "https://api.anthropic.com/v1/messages"
|
||||
: draft.model.openai_endpoint === "/v1/chat/completions"
|
||||
? "https://api.openai.com/v1/chat/completions"
|
||||
: "https://api.openai.com/v1/responses"
|
||||
: draft.model.type === "anthropic"
|
||||
? "https://api.anthropic.com"
|
||||
: "https://api.openai.com";
|
||||
|
||||
return <div className={styles.editor}>
|
||||
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} />
|
||||
<div className={styles.grid}>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
|
||||
{ value: "openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(value) => setType(value as ModelType)} /></FormField>
|
||||
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
|
||||
{ value: "/v1/responses", label: "Responses API" },
|
||||
{ value: "/v1/chat/completions", label: "Chat Completions API" },
|
||||
]} onChange={(openai_endpoint) => setModel({ openai_endpoint })} /></FormField>}
|
||||
|
||||
<div className={styles.urlField}>
|
||||
<FormField label={draft.model.use_full_url ? t("完整请求 URL") : t("服务器地址")} hint={draft.model.use_full_url ? t("系统会原样使用此地址,不追加或修改请求路径。") : t("系统会根据请求协议自动追加标准端点路径。")}> <TextInput placeholder={requestUrlPlaceholder} value={draft.model.base_url} onChange={(event) => setModel({ base_url: event.target.value })} /></FormField>
|
||||
<Checkbox checked={draft.model.use_full_url} label={t("使用完整请求地址")} onChange={(use_full_url) => setModel({ use_full_url })} />
|
||||
</div>
|
||||
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
|
||||
|
||||
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
|
||||
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
|
||||
|
||||
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
{draft.model.type === "openai" ? <>
|
||||
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
|
||||
</> : <>
|
||||
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
|
||||
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用 adaptive thinking")} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
|
||||
</>}
|
||||
|
||||
<ToggleJsonField
|
||||
label={t("自定义 Headers")}
|
||||
enabled={draft.model.custom_headers_enabled}
|
||||
text={draft.customHeadersText}
|
||||
onEnabledChange={(custom_headers_enabled) => setModel({ custom_headers_enabled })}
|
||||
onTextChange={(customHeadersText) => onChange({ ...draft, customHeadersText })}
|
||||
/>
|
||||
{draft.model.type === "openai" ? <ToggleJsonField
|
||||
label={t("OpenAI 额外参数")}
|
||||
enabled={draft.model.openai_extra_params_enabled}
|
||||
text={draft.openAIExtraParamsText}
|
||||
onEnabledChange={(openai_extra_params_enabled) => setModel({ openai_extra_params_enabled })}
|
||||
onTextChange={(openAIExtraParamsText) => onChange({ ...draft, openAIExtraParamsText })}
|
||||
/> : <ToggleJsonField
|
||||
label={t("Anthropic 额外参数")}
|
||||
enabled={draft.model.anthropic_extra_params_enabled}
|
||||
text={draft.anthropicExtraParamsText}
|
||||
onEnabledChange={(anthropic_extra_params_enabled) => setModel({ anthropic_extra_params_enabled })}
|
||||
onTextChange={(anthropicExtraParamsText) => onChange({ ...draft, anthropicExtraParamsText })}
|
||||
/>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ToggleJsonField({ label, enabled, text, onEnabledChange, onTextChange }: {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
text: string;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onTextChange: (text: string) => void;
|
||||
}) {
|
||||
return <div className={`${styles.fullWidth} ${styles.jsonOption}`}>
|
||||
<label><span>{label}</span><Switch label={label} checked={enabled} onChange={onEnabledChange} /></label>
|
||||
{enabled && <JsonEditor ariaLabel={label} value={text} onChange={onTextChange} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function effortOptions(optional: boolean) {
|
||||
return [
|
||||
...(optional ? [{ value: "", label: t("不设置") }] : []),
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "Extra High" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.idle {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.testing {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: color-mix(in srgb, var(--vscode-textLink-foreground) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-textLink-foreground) 24%, transparent);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 11%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
color: currentColor;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
cursor: help;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ModelConnectivityResult } from "../../api";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { informationOutlineIcon } from "../ui/icons";
|
||||
import styles from "./CursorModelTestResult.module.scss";
|
||||
|
||||
export type CursorModelTestState =
|
||||
| { status: "success"; result: ModelConnectivityResult }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "cancelled" };
|
||||
|
||||
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
|
||||
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
|
||||
const success = state.status === "success";
|
||||
const summary = success
|
||||
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
|
||||
: t("错误:{error}", { error: state.error });
|
||||
const detail = success
|
||||
? t("速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", {
|
||||
speed: formatSpeed(state.result.tokens_per_second),
|
||||
firstText: state.result.first_text_ms ?? "--",
|
||||
duration: state.result.duration_ms,
|
||||
tokens: state.result.output_tokens,
|
||||
estimated: state.result.tokens_estimated ? t("(估算)") : "",
|
||||
output: state.result.output || "--",
|
||||
})
|
||||
: t("测试失败:{error}", { error: state.error });
|
||||
|
||||
return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
|
||||
<span className={styles.summary}>{summary}</span>
|
||||
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function formatSpeed(value: number) {
|
||||
return Number.isFinite(value) ? value.toFixed(1) : "0.0";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--vscode-editorWidget-border, rgba(255, 255, 255, 0.16));
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vscode-focusBorder, #49b0ff);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--vscode-focusBorder, #49b0ff);
|
||||
color: var(--vscode-focusBorder, #49b0ff);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ModelType } from "../../api";
|
||||
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
|
||||
import styles from "./CursorPresetChips.module.scss";
|
||||
|
||||
/** 常用服务商预设:点击按当前协议类型自动填充对应端点与默认模型 */
|
||||
export function CursorPresetChips({ type, baseUrl, onPick }: { type: ModelType; baseUrl: string; onPick: (preset: ModelPreset) => void }) {
|
||||
return <div className={styles.wrap}>
|
||||
<span className={styles.label}>{t("常用预设")}</span>
|
||||
<div className={styles.chips}>
|
||||
{modelPresets.map((preset) => {
|
||||
const active = trimTrailingSlash(baseUrl) === trimTrailingSlash(presetEndpoint(preset, type).baseUrl);
|
||||
return <button
|
||||
type="button"
|
||||
key={preset.key}
|
||||
className={active ? `${styles.chip} ${styles.active}` : styles.chip}
|
||||
title={preset.keyHint}
|
||||
onClick={() => onPick(preset)}
|
||||
>
|
||||
<img className={styles.icon} src={preset.icon} alt="" />
|
||||
{preset.name}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.groupActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
|
||||
button {
|
||||
height: 25px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--vscode-sideBar-border);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child { border-right: 0; }
|
||||
&:hover:not(:disabled) { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
|
||||
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
}
|
||||
|
||||
.gateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gate {
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
strong {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
span {
|
||||
max-width: 440px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.modelGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
}
|
||||
.modelGroups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.modelGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.modelGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
opacity: 0.6;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.modelCard {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.modelCardContent {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.modelCardTop {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.modelCardName {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.modelCardNameText,
|
||||
.modelCardModelId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelCardNameText {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
.modelCardModelId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
}
|
||||
.modelTypeBadge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 7px;
|
||||
color: var(--vscode-foreground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
.modelCardTest {
|
||||
min-height: 31px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.modelCardActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.deleteButton:hover {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
}
|
||||
.sortHandle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
opacity: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease, background-color 160ms ease;
|
||||
|
||||
&:focus-visible,
|
||||
.modelCard:hover & {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border-color: var(--vscode-sideBar-border);
|
||||
opacity: 1;
|
||||
}
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
.sortGhost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.sortChosen {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.sortDragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.command {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
user-select: text;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.discoverButton {
|
||||
min-height: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.urlField {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.switches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
label {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.jsonOption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
> label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.fullWidth {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
top: var(--app-content-top);
|
||||
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
|
||||
margin: 0;
|
||||
padding-left:0;
|
||||
font-size: type.$font-size-xxl;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.fixedContent {
|
||||
width: calc(100% - var(--app-page-padding) * 2);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding-top: var(--app-content-top);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.fixedContent,
|
||||
.fixedSection {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fixedSection {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { VirtualPageSection } from "./VirtualPage";
|
||||
import { VirtualPage } from "./VirtualPage";
|
||||
import styles from "./PageContent.module.scss";
|
||||
|
||||
export function PageContent({ title, sections, contentClassName, fixed = false }: { title?: ReactNode; sections: VirtualPageSection[]; contentClassName?: string; fixed?: boolean }) {
|
||||
return <div className={styles.root}>
|
||||
{fixed && title != null && <div className={styles.title}>{title}</div>}
|
||||
{fixed
|
||||
? <div className={[styles.fixedContent, contentClassName].filter(Boolean).join(" ")}>{sections.map((section) => <section className={styles.fixedSection} key={section.key}>{section.content}</section>)}</div>
|
||||
: <VirtualPage title={title} sections={sections} contentClassName={contentClassName} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header,
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import styles from "./PageLayout.module.scss";
|
||||
|
||||
type PageLayoutProps = {
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function PageLayout({ as: Component = "div", className, header, footer, children }: PageLayoutProps) {
|
||||
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>
|
||||
{header && <div className={styles.header}>{header}</div>}
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</Component>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: calc(100% - var(--app-page-padding) * 2);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding-top: var(--app-content-top);
|
||||
padding-bottom: var(--app-page-padding);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
font-size: type.$font-size-xxl;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { VirtualList } from "../virtual/VirtualListEngine";
|
||||
import styles from "./VirtualPage.module.scss";
|
||||
|
||||
export type VirtualPageSection = {
|
||||
key: string;
|
||||
estimatedHeight: number;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function VirtualPage({ title, sections, className, contentClassName }: { title?: ReactNode; sections: VirtualPageSection[]; className?: string; contentClassName?: string }) {
|
||||
const getKey = useCallback((section: VirtualPageSection) => section.key, []);
|
||||
const estimateSize = useCallback((section: VirtualPageSection) => section.estimatedHeight, []);
|
||||
const renderItem = useCallback((section: VirtualPageSection, { index }: { index: number }) => <section className={styles.section}>
|
||||
{index === 0 && title != null && <div className={styles.title}>{title}</div>}
|
||||
{section.content}
|
||||
</section>, [title]);
|
||||
|
||||
return <VirtualList
|
||||
items={sections}
|
||||
getKey={getKey}
|
||||
estimateSize={estimateSize}
|
||||
renderItem={renderItem}
|
||||
overscan={2}
|
||||
itemGap={16}
|
||||
scrollbarSize={7}
|
||||
scrollbarInsetTop="var(--app-content-top)"
|
||||
className={[styles.root, "scroll-shadow-top", className].filter(Boolean).join(" ")}
|
||||
contentClassName={[styles.content, contentClassName].filter(Boolean).join(" ")}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
height: 82px;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chart {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--oa-number-font);
|
||||
font-size: type.$font-size-xl;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EChart } from "../charts/EChart";
|
||||
import styles from "./CacheHitRateChart.module.scss";
|
||||
|
||||
const valueColor = "#40c463";
|
||||
const trackColor = "rgba(139, 148, 158, 0.20)";
|
||||
|
||||
export function CacheHitRateChart({ rate, animationKey = 0 }: { rate: number; animationKey?: number }) {
|
||||
const finiteRate = Number.isFinite(rate) ? rate : 0;
|
||||
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
|
||||
const [displayedPercentage, setDisplayedPercentage] = useState(0);
|
||||
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedPercentage(0);
|
||||
let frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => setDisplayedPercentage(percentage));
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [animationKey, percentage]);
|
||||
|
||||
const option = useMemo<EChartsCoreOption>(() => ({
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: displayedPercentage > 0 ? 1_000 : 0,
|
||||
animationEasing: "cubicOut",
|
||||
animationEasingUpdate: "cubicOut",
|
||||
series: [{
|
||||
type: "gauge",
|
||||
min: 0,
|
||||
max: 100,
|
||||
startAngle: 180,
|
||||
endAngle: 0,
|
||||
center: ["50%", "50%"],
|
||||
radius: "90%",
|
||||
silent: true,
|
||||
pointer: { show: false },
|
||||
progress: {
|
||||
show: true,
|
||||
roundCap: true,
|
||||
width: 11,
|
||||
itemStyle: { color: displayedPercentage > 0 ? valueColor : "transparent" },
|
||||
},
|
||||
axisLine: {
|
||||
roundCap: true,
|
||||
lineStyle: { width: 11, color: [[1, trackColor]] },
|
||||
},
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
anchor: { show: false },
|
||||
title: { show: false },
|
||||
detail: { show: false },
|
||||
data: [{ value: displayedPercentage }],
|
||||
}],
|
||||
}), [displayedPercentage]);
|
||||
|
||||
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
|
||||
<EChart className={styles.chart} option={option} />
|
||||
<div className={styles.label}>{label}</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.scroller {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 130px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
font-family: var(--oa-metrics-font);
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
transition: color 150ms ease;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--oa-number-font);
|
||||
font-size: type.$font-size-xxl;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tooltipText {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from "react";
|
||||
import { formatCompactInteger, formatInteger } from "../../utils/numberFormat";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||
import { informationOutlineIcon } from "../ui/icons";
|
||||
import { CacheHitRateChart } from "./CacheHitRateChart";
|
||||
import styles from "./HomeMetrics.module.scss";
|
||||
|
||||
export type HomeMetricsData = {
|
||||
llmCalls: number;
|
||||
successfulCalls: number;
|
||||
failedCalls: number;
|
||||
tokenUsage: number;
|
||||
promptTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
};
|
||||
|
||||
const TOKEN_PRICE_PER_MILLION = {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
} as const;
|
||||
|
||||
function formatMetricValue(value: number) {
|
||||
const full = formatInteger(value);
|
||||
const compact = formatCompactInteger(value);
|
||||
return full === compact ? full : `${full} (${compact})`;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null) {
|
||||
return value === null ? t("暂无数据") : `${(Math.max(0, Math.min(1, value)) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function calculateRate(numerator: number, denominator: number) {
|
||||
return denominator > 0 ? numerator / denominator : null;
|
||||
}
|
||||
|
||||
function priceTokens(tokens: number, pricePerMillion: number) {
|
||||
return (tokens / 1_000_000) * pricePerMillion;
|
||||
}
|
||||
|
||||
function formatUSD(value: number) {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function elementAnchor(element: HTMLElement): TooltipAnchor {
|
||||
return {
|
||||
contextElement: element,
|
||||
getBoundingClientRect: () => element.getBoundingClientRect(),
|
||||
};
|
||||
}
|
||||
|
||||
function InfoTooltip({ content }: { content: string }) {
|
||||
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
|
||||
|
||||
return <>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.info}
|
||||
aria-label={t("查看说明")}
|
||||
onMouseEnter={(event) => setAnchor(elementAnchor(event.currentTarget))}
|
||||
onMouseLeave={() => setAnchor(null)}
|
||||
onFocus={(event) => setAnchor(elementAnchor(event.currentTarget))}
|
||||
onBlur={() => setAnchor(null)}
|
||||
><Icon icon={informationOutlineIcon} size="1.1em" /></button>
|
||||
<Tooltip anchor={anchor}>
|
||||
<div className={styles.tooltipText}>{content}</div>
|
||||
</Tooltip>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
|
||||
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
||||
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
||||
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
||||
const cacheReuseRate = calculateRate(
|
||||
data.cacheReadTokens,
|
||||
data.cacheReadTokens + data.cacheWriteTokens + inputTokens,
|
||||
);
|
||||
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
|
||||
const costs = {
|
||||
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
|
||||
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
|
||||
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
|
||||
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
|
||||
};
|
||||
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
|
||||
const cacheCost = costs.cacheRead + costs.cacheWrite;
|
||||
const cacheTooltip = [
|
||||
t("当前:{rate}", { rate: formatRate(defaultCacheHitRate) }),
|
||||
t("公式:缓存读取 /(缓存读取 + 非缓存输入)"),
|
||||
t("默认 {defaultRate} / 计入创建 {reuseRate}", {
|
||||
defaultRate: formatRate(defaultCacheHitRate),
|
||||
reuseRate: formatRate(cacheReuseRate),
|
||||
}),
|
||||
].join("\n");
|
||||
const callsTooltip = [
|
||||
t("按历史 LLM 调用记录汇总,进行中的调用不计入。"),
|
||||
"",
|
||||
t("总调用:{count}", { count: formatMetricValue(data.llmCalls) }),
|
||||
t("成功调用:{count}", { count: formatMetricValue(data.successfulCalls) }),
|
||||
t("异常调用:{count}", { count: formatMetricValue(data.failedCalls) }),
|
||||
t("成功占比:{rate}", { rate: formatRate(successfulCallRate) }),
|
||||
].join("\n");
|
||||
const tokensTooltip = [
|
||||
t("总请求 Token 包含提示词和模型输出。"),
|
||||
"",
|
||||
t("总请求:{tokens}", { tokens: formatMetricValue(data.tokenUsage) }),
|
||||
t("提示词:{tokens}", { tokens: formatMetricValue(data.promptTokens) }),
|
||||
t("输出推算:{tokens}", { tokens: formatMetricValue(outputTokens) }),
|
||||
t("非缓存输入:{tokens}", { tokens: formatMetricValue(inputTokens) }),
|
||||
t("缓存读取:{tokens}", { tokens: formatMetricValue(data.cacheReadTokens) }),
|
||||
t("缓存写入:{tokens}", { tokens: formatMetricValue(data.cacheWriteTokens) }),
|
||||
"",
|
||||
t("缓存读写已计入提示词侧统计。"),
|
||||
].join("\n");
|
||||
const costTooltip = [
|
||||
t("按 Claude Opus 4.7 价格估算。"),
|
||||
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
|
||||
"",
|
||||
t("普通输入:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(inputTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.input,
|
||||
cost: formatUSD(costs.input),
|
||||
}),
|
||||
t("模型输出:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(outputTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.output,
|
||||
cost: formatUSD(costs.output),
|
||||
}),
|
||||
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(data.cacheReadTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.cacheRead,
|
||||
cost: formatUSD(costs.cacheRead),
|
||||
}),
|
||||
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(data.cacheWriteTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
|
||||
cost: formatUSD(costs.cacheWrite),
|
||||
}),
|
||||
"",
|
||||
t("合计:{cost}", { cost: formatUSD(totalCost) }),
|
||||
].join("\n");
|
||||
|
||||
return <div className={styles.scroller}>
|
||||
<section className={styles.root} aria-label={t("调用统计")}>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
|
||||
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} animationKey={refreshVersion} />
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatInteger(data.llmCalls)}>{formatCompactInteger(data.llmCalls)}</div>
|
||||
<div className={styles.secondary}>{t("成功 {successful} / 异常 {failed}", {
|
||||
successful: formatCompactInteger(data.successfulCalls),
|
||||
failed: formatCompactInteger(data.failedCalls),
|
||||
})}</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("Token 消耗")}<InfoTooltip content={tokensTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatInteger(data.tokenUsage)}>{formatCompactInteger(data.tokenUsage)}</div>
|
||||
<div className={styles.secondary}>{t("提示词 {tokens}", { tokens: formatCompactInteger(data.promptTokens) })}</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("价值估算")}<InfoTooltip content={costTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatUSD(totalCost)}>{formatUSD(totalCost)}</div>
|
||||
<div className={styles.secondary}>{t("缓存读写 {cost}", { cost: formatUSD(cacheCost) })}</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.counts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 10px;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.models {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.model {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
background: var(--vscode-list-inactiveSelectionBackground);
|
||||
border-radius: 5px;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
small {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
> span {
|
||||
flex: 0 0 auto;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.new {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
}
|
||||
|
||||
.existing,
|
||||
.hint {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.model {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { api, type LegacyModelImportPreview } from "../../api";
|
||||
import { appStore } from "../../store/appStore";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { useMessage } from "../ui/message";
|
||||
import styles from "./LegacyModelImport.module.scss";
|
||||
|
||||
type LegacyModelImportControl = {
|
||||
busy: boolean;
|
||||
previewing: boolean;
|
||||
open: () => void;
|
||||
};
|
||||
|
||||
export function LegacyModelImport({ children }: { children: (control: LegacyModelImportControl) => ReactNode }) {
|
||||
const message = useMessage();
|
||||
const [preview, setPreview] = useState<LegacyModelImportPreview | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const open = async () => {
|
||||
try {
|
||||
setPreviewing(true);
|
||||
setPreview(await api.previewV0049Models());
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
try {
|
||||
setImporting(true);
|
||||
const result = await appStore.importV0049Models();
|
||||
if (!result) {
|
||||
const error = appStore.getSnapshot().error;
|
||||
if (error) message(error);
|
||||
return;
|
||||
}
|
||||
setPreview(null);
|
||||
message(result.imported > 0
|
||||
? t("导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型", { imported: result.imported, skipped: result.skipped })
|
||||
: t("配置中的 {count} 个模型均已存在,无需重复导入", { count: result.skipped }));
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
{children({ busy: previewing || importing, previewing, open: () => void open() })}
|
||||
<ConfirmDialog
|
||||
open={preview !== null}
|
||||
title={t("确认导入旧版模型配置")}
|
||||
busy={importing}
|
||||
wide
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("确认导入")}
|
||||
onCancel={() => setPreview(null)}
|
||||
onConfirm={() => void confirm()}
|
||||
>
|
||||
{preview && <div className={styles.content}>
|
||||
<div className={styles.source}>
|
||||
<strong>{t("配置文件")}</strong>
|
||||
<code>{preview.source}</code>
|
||||
</div>
|
||||
<div className={styles.counts}>
|
||||
<div><strong>{preview.total}</strong><small>{t("配置模型")}</small></div>
|
||||
<div><strong>{preview.new_models}</strong><small>{t("将新增")}</small></div>
|
||||
<div><strong>{preview.existing_models}</strong><small>{t("已存在")}</small></div>
|
||||
</div>
|
||||
<div className={styles.models}>
|
||||
{preview.models.map((model) => <div className={styles.model} key={model.model_hash}>
|
||||
<div>
|
||||
<strong>{model.display_name}</strong>
|
||||
<small>{model.model_id} · {model.type === "openai" ? "OpenAI" : "Anthropic"}</small>
|
||||
</div>
|
||||
<span className={model.existing ? styles.existing : styles.new}>
|
||||
{model.existing ? t("已存在,跳过") : t("新增")}
|
||||
</span>
|
||||
</div>)}
|
||||
</div>
|
||||
<small className={styles.hint}>{t("重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。")}</small>
|
||||
</div>}
|
||||
</ConfirmDialog>
|
||||
</>;
|
||||
}
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root,
|
||||
.presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterRow { width: 100%; }
|
||||
|
||||
.root {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.presets {
|
||||
overflow: hidden;
|
||||
// border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 5px;
|
||||
|
||||
button {
|
||||
height: 25px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--vscode-sideBar-border);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child { border-right: 0; }
|
||||
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
|
||||
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: fixed;
|
||||
z-index: 14000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
overflow: visible;
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
|
||||
&:focus { border-color: var(--vscode-focusBorder); }
|
||||
}
|
||||
}
|
||||
|
||||
.popoverActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseTimeInput } from "../../utils/parseTimeInput";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { MultiSelect, type MultiSelectOption } from "../ui/MultiSelect";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { refreshIcon } from "../ui/icons";
|
||||
import styles from "./OverviewTimeRangeFilter.module.scss";
|
||||
|
||||
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
|
||||
|
||||
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
|
||||
value: OverviewRangePreset;
|
||||
customOpen: boolean;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
modelOptions: MultiSelectOption[];
|
||||
selectedModels: string[];
|
||||
busy: boolean;
|
||||
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
|
||||
onCustomOpenChange: (open: boolean) => void;
|
||||
onCustomStartChange: (value: string) => void;
|
||||
onCustomEndChange: (value: string) => void;
|
||||
onSelectedModelsChange: (value: string[]) => void;
|
||||
onCustomApply: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const presets: Array<{ value: Exclude<OverviewRangePreset, "custom">; label: string }> = [
|
||||
{ value: "hour", label: t("近1小时") },
|
||||
{ value: "today", label: t("近1自然日") },
|
||||
{ value: "ten-minutes", label: t("近10分钟") },
|
||||
{ value: "week", label: t("近一周") },
|
||||
{ value: "month", label: t("近一个月") },
|
||||
];
|
||||
const customButton = useRef<HTMLButtonElement>(null);
|
||||
const popover = useRef<HTMLDivElement>(null);
|
||||
const popoverId = useId();
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!customOpen || !customButton.current || !popover.current) return;
|
||||
return autoUpdate(customButton.current, popover.current, () => void computePosition(customButton.current!, popover.current!, {
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
maxHeight: Math.max(240, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [customOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customOpen) return;
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [customOpen, onCustomOpenChange]);
|
||||
|
||||
const parsedStart = parseTimeInput(customStart);
|
||||
const parsedEnd = parseTimeInput(customEnd);
|
||||
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
|
||||
|
||||
return <div className={styles.root} aria-label={t("概览时间范围")}>
|
||||
<div className={styles.presets}>
|
||||
{presets.map((preset) => <button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
aria-pressed={value === preset.value}
|
||||
onClick={() => onSelect(preset.value)}
|
||||
>{preset.label}</button>)}
|
||||
<button
|
||||
ref={customButton}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={customOpen ? popoverId : undefined}
|
||||
aria-expanded={customOpen}
|
||||
aria-pressed={value === "custom"}
|
||||
onClick={() => onCustomOpenChange(!customOpen)}
|
||||
>{t("自定义")}</button>
|
||||
</div>
|
||||
<TooltipTrigger label={t("刷新")}><button className={controls.iconButton} aria-label={t("刷新")} disabled={busy} onClick={onRefresh}>
|
||||
<Icon className={busy ? controls.spin : ""} icon={refreshIcon} size="1.1em" />
|
||||
</button></TooltipTrigger>
|
||||
{customOpen && createPortal(<div
|
||||
id={popoverId}
|
||||
ref={popover}
|
||||
className={styles.popover}
|
||||
role="dialog"
|
||||
aria-label={t("自定义概览筛选")}
|
||||
style={position}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCustomOpenChange(false);
|
||||
customButton.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
|
||||
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
|
||||
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||
<div className={styles.popoverActions}>
|
||||
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
||||
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
||||
</div>
|
||||
</div>, document.body)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.root {
|
||||
--oa-scrollbar-size: 8px;
|
||||
--oa-scrollbar-min-thumb-size: 20px;
|
||||
--oa-scrollbar-inset-top: 0px;
|
||||
--oa-scrollbar-thumb: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.1));
|
||||
--oa-scrollbar-thumb-hover: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.12));
|
||||
--oa-scrollbar-thumb-active: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.12));
|
||||
--oa-scrollbar-hit-size: max(12px, var(--oa-scrollbar-size));
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&.always .scrollbar,
|
||||
&.active .scrollbar,
|
||||
&:hover .scrollbar {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.active .thumb {
|
||||
background: var(--oa-scrollbar-thumb-active);
|
||||
}
|
||||
}
|
||||
|
||||
.viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
position: absolute;
|
||||
top: var(--oa-scrollbar-inset-top);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
width: var(--oa-scrollbar-hit-size);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: var(--oa-scrollbar-size);
|
||||
min-height: var(--oa-scrollbar-min-thumb-size);
|
||||
background: var(--oa-scrollbar-thumb);
|
||||
border-radius: 999px;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: var(--oa-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react"
|
||||
import {
|
||||
useScrollableModel,
|
||||
type ScrollAreaApi,
|
||||
type ScrollAreaState,
|
||||
} from "./useScrollableModel"
|
||||
import styles from "./ScrollArea.module.scss"
|
||||
|
||||
export type { ScrollAreaApi, ScrollAreaState } from "./useScrollableModel"
|
||||
|
||||
export interface ScrollAreaProps {
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
viewportClassName?: string
|
||||
contentClassName?: string
|
||||
|
||||
children: ReactNode | ((state: ScrollAreaState) => ReactNode)
|
||||
|
||||
contentHeight?: number
|
||||
defaultScrollTop?: number
|
||||
scrollTop?: number
|
||||
onScroll?: (state: ScrollAreaState) => void
|
||||
onViewportResize?: (size: { width: number; height: number }) => void
|
||||
|
||||
scrollbarSize?: number
|
||||
minThumbSize?: number
|
||||
scrollbarVisibility?: "auto" | "always" | "hidden"
|
||||
scrollbarInsetTop?: CSSProperties["top"]
|
||||
|
||||
viewportRef?: Ref<HTMLDivElement>
|
||||
contentRef?: Ref<HTMLDivElement>
|
||||
onReady?: (api: ScrollAreaApi) => void
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
|
||||
if (!ref) return
|
||||
if (typeof ref === "function") {
|
||||
ref(value)
|
||||
} else {
|
||||
ref.current = value
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function normalizeWheelDelta(event: WheelEvent, viewportHeight: number): number {
|
||||
let deltaY = event.deltaY
|
||||
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||
deltaY *= 16
|
||||
} else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||
deltaY *= viewportHeight
|
||||
}
|
||||
|
||||
if (deltaY !== 0 && Math.abs(deltaY) < 1) {
|
||||
if (deltaY < 0) return -1
|
||||
return 1
|
||||
}
|
||||
|
||||
return deltaY
|
||||
}
|
||||
|
||||
export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
function ScrollArea(props, forwardedRef) {
|
||||
const {
|
||||
className,
|
||||
style,
|
||||
viewportClassName,
|
||||
contentClassName,
|
||||
children,
|
||||
contentHeight,
|
||||
defaultScrollTop,
|
||||
scrollTop,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
scrollbarSize = 8,
|
||||
minThumbSize = 20,
|
||||
scrollbarVisibility = "auto",
|
||||
scrollbarInsetTop = 0,
|
||||
viewportRef,
|
||||
contentRef,
|
||||
onReady,
|
||||
} = props
|
||||
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const localViewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const localContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
const thumbRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
pointerOffsetY: number
|
||||
} | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
|
||||
viewportRef: localViewportRef,
|
||||
contentRef: localContentRef,
|
||||
trackRef,
|
||||
thumbRef,
|
||||
contentHeight,
|
||||
defaultScrollTop,
|
||||
scrollTop,
|
||||
minThumbSize,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
})
|
||||
|
||||
useImperativeHandle(forwardedRef, () => hostRef.current as HTMLDivElement)
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.(api)
|
||||
}, [api, onReady])
|
||||
|
||||
const setViewportRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
localViewportRef.current = node
|
||||
assignRef(viewportRef, node)
|
||||
},
|
||||
[viewportRef]
|
||||
)
|
||||
|
||||
const setContentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
localContentRef.current = node
|
||||
assignRef(contentRef, node)
|
||||
},
|
||||
[contentRef]
|
||||
)
|
||||
|
||||
const scrollbarHidden =
|
||||
scrollbarVisibility === "hidden" ||
|
||||
!state.isScrollable ||
|
||||
state.viewportHeight <= 0
|
||||
|
||||
const hostClassNames = [styles.root]
|
||||
if (scrollbarVisibility === "always") hostClassNames.push(styles.always)
|
||||
if (dragging || state.isScrolling) hostClassNames.push(styles.active)
|
||||
if (className) hostClassNames.push(className)
|
||||
const hostClassName = hostClassNames.join(" ")
|
||||
|
||||
const hostStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
...style,
|
||||
"--oa-scrollbar-size": `${scrollbarSize}px`,
|
||||
"--oa-scrollbar-min-thumb-size": `${minThumbSize}px`,
|
||||
"--oa-scrollbar-inset-top":
|
||||
typeof scrollbarInsetTop === "number"
|
||||
? `${scrollbarInsetTop}px`
|
||||
: scrollbarInsetTop,
|
||||
} as CSSProperties),
|
||||
[minThumbSize, scrollbarInsetTop, scrollbarSize, style]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const thumb = thumbRef.current
|
||||
const track = trackRef.current
|
||||
if (!thumb || !track) return
|
||||
|
||||
const setScrollTopFromThumb = (clientY: number) => {
|
||||
const currentDrag = dragRef.current
|
||||
if (!currentDrag) return
|
||||
|
||||
const metrics = metricsRef.current
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
const desiredThumbTop = clamp(
|
||||
clientY - trackRect.top - currentDrag.pointerOffsetY,
|
||||
0,
|
||||
metrics.maxThumbTop
|
||||
)
|
||||
let nextScrollTop = 0
|
||||
if (metrics.maxThumbTop > 0) {
|
||||
nextScrollTop = (desiredThumbTop / metrics.maxThumbTop) * metrics.maxScrollTop
|
||||
}
|
||||
|
||||
scrollToNow(nextScrollTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
const stopDrag = (event: PointerEvent) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
if (thumb.hasPointerCapture(event.pointerId)) {
|
||||
thumb.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
dragRef.current = null
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
const handleThumbPointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return
|
||||
|
||||
event.preventDefault()
|
||||
thumb.setPointerCapture(event.pointerId)
|
||||
|
||||
const metrics = metricsRef.current
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
pointerOffsetY: clamp(
|
||||
event.clientY - trackRect.top - metrics.thumbTop,
|
||||
0,
|
||||
metrics.thumbHeight
|
||||
),
|
||||
}
|
||||
setDragging(true)
|
||||
}
|
||||
|
||||
const handleThumbPointerMove = (event: PointerEvent) => {
|
||||
if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return
|
||||
event.preventDefault()
|
||||
setScrollTopFromThumb(event.clientY)
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent) => {
|
||||
if (event.target !== track) return
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const rect = track.getBoundingClientRect()
|
||||
const metrics = metricsRef.current
|
||||
const desiredThumbTop = clamp(
|
||||
event.clientY - rect.top - metrics.thumbHeight / 2,
|
||||
0,
|
||||
metrics.maxThumbTop
|
||||
)
|
||||
let nextScrollTop = 0
|
||||
if (metrics.maxThumbTop > 0) {
|
||||
nextScrollTop = (desiredThumbTop / metrics.maxThumbTop) * metrics.maxScrollTop
|
||||
}
|
||||
scrollToNow(nextScrollTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
thumb.addEventListener("pointerdown", handleThumbPointerDown)
|
||||
thumb.addEventListener("pointermove", handleThumbPointerMove)
|
||||
thumb.addEventListener("pointerup", stopDrag)
|
||||
thumb.addEventListener("pointercancel", stopDrag)
|
||||
thumb.addEventListener("lostpointercapture", stopDrag)
|
||||
track.addEventListener("pointerdown", handleTrackPointerDown)
|
||||
|
||||
return () => {
|
||||
thumb.removeEventListener("pointerdown", handleThumbPointerDown)
|
||||
thumb.removeEventListener("pointermove", handleThumbPointerMove)
|
||||
thumb.removeEventListener("pointerup", stopDrag)
|
||||
thumb.removeEventListener("pointercancel", stopDrag)
|
||||
thumb.removeEventListener("lostpointercapture", stopDrag)
|
||||
track.removeEventListener("pointerdown", handleTrackPointerDown)
|
||||
}
|
||||
}, [metricsRef, scrollToNow])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
const viewport = localViewportRef.current
|
||||
if (!host || !viewport) return
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.defaultPrevented || event.ctrlKey) return
|
||||
|
||||
const metrics = metricsRef.current
|
||||
if (!metrics.isScrollable) return
|
||||
|
||||
const deltaY = normalizeWheelDelta(event, metrics.viewportHeight)
|
||||
if (deltaY === 0) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const currentTop = viewport.scrollTop
|
||||
const nextTop = clamp(currentTop + deltaY, 0, metrics.maxScrollTop)
|
||||
if (nextTop === currentTop) return
|
||||
|
||||
scrollToNow(nextTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
host.addEventListener("wheel", handleWheel, { passive: false })
|
||||
return () => {
|
||||
host.removeEventListener("wheel", handleWheel)
|
||||
}
|
||||
}, [metricsRef, scrollToNow])
|
||||
|
||||
let renderedChildren: ReactNode = children as ReactNode
|
||||
if (typeof children === "function") renderedChildren = children(state)
|
||||
let scrollbarClassName = styles.scrollbar
|
||||
if (scrollbarHidden) scrollbarClassName = `${styles.scrollbar} ${styles.hidden}`
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={hostClassName}
|
||||
style={hostStyle}
|
||||
data-scrolled={state.scrollTop > 0 ? "true" : "false"}
|
||||
>
|
||||
<div
|
||||
ref={setViewportRef}
|
||||
className={[styles.viewport, viewportClassName ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div ref={setContentRef} className={[styles.content, contentClassName ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{renderedChildren}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={scrollbarClassName}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div ref={trackRef} className={styles.track} />
|
||||
<div
|
||||
ref={thumbRef}
|
||||
className={styles.thumb}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react"
|
||||
|
||||
export interface ScrollAreaApi {
|
||||
scrollTo(top: number): void
|
||||
scrollBy(deltaTop: number): void
|
||||
scrollToTop(): void
|
||||
scrollToBottom(): void
|
||||
getScrollElement(): HTMLDivElement | null
|
||||
}
|
||||
|
||||
export interface ScrollAreaState {
|
||||
scrollTop: number
|
||||
viewportHeight: number
|
||||
scrollHeight: number
|
||||
isScrollable: boolean
|
||||
isScrolling: boolean
|
||||
}
|
||||
|
||||
export interface ScrollMetrics extends ScrollAreaState {
|
||||
maxScrollTop: number
|
||||
trackHeight: number
|
||||
thumbHeight: number
|
||||
thumbTop: number
|
||||
maxThumbTop: number
|
||||
}
|
||||
|
||||
interface UseScrollableModelOptions {
|
||||
viewportRef: RefObject<HTMLDivElement | null>
|
||||
contentRef: RefObject<HTMLDivElement | null>
|
||||
trackRef: RefObject<HTMLDivElement | null>
|
||||
thumbRef: RefObject<HTMLDivElement | null>
|
||||
contentHeight?: number
|
||||
defaultScrollTop?: number
|
||||
scrollTop?: number
|
||||
minThumbSize?: number
|
||||
onScroll?: (state: ScrollAreaState) => void
|
||||
onViewportResize?: (size: { width: number; height: number }) => void
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function readScrollHeight(
|
||||
element: HTMLDivElement | null,
|
||||
contentHeight?: number
|
||||
): number {
|
||||
if (typeof contentHeight === "number") return Math.max(0, contentHeight)
|
||||
if (!element) return contentHeight ?? 0
|
||||
return element.scrollHeight
|
||||
}
|
||||
|
||||
function measureScrollArea(
|
||||
element: HTMLDivElement | null,
|
||||
track: HTMLDivElement | null,
|
||||
contentHeight: number | undefined,
|
||||
minThumbSize: number,
|
||||
isScrolling: boolean
|
||||
): ScrollMetrics {
|
||||
const scrollHeight = readScrollHeight(element, contentHeight)
|
||||
const viewportHeight = element?.clientHeight ?? 0
|
||||
const trackHeight = track?.clientHeight ?? viewportHeight
|
||||
const maxScrollTop = Math.max(0, scrollHeight - viewportHeight)
|
||||
const scrollTop = clamp(element?.scrollTop ?? 0, 0, maxScrollTop)
|
||||
const isScrollable = scrollHeight > viewportHeight + 1
|
||||
|
||||
let rawThumbHeight = 0
|
||||
if (scrollHeight > 0) {
|
||||
rawThumbHeight = (trackHeight * viewportHeight) / scrollHeight
|
||||
}
|
||||
let thumbHeight = 0
|
||||
if (isScrollable && trackHeight > 0) {
|
||||
thumbHeight = Math.min(trackHeight, Math.max(minThumbSize, rawThumbHeight))
|
||||
}
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
let thumbTop = 0
|
||||
if (maxScrollTop > 0) thumbTop = (scrollTop / maxScrollTop) * maxThumbTop
|
||||
|
||||
return {
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
scrollHeight,
|
||||
isScrollable,
|
||||
isScrolling,
|
||||
maxScrollTop,
|
||||
trackHeight,
|
||||
thumbHeight,
|
||||
thumbTop,
|
||||
maxThumbTop,
|
||||
}
|
||||
}
|
||||
|
||||
function toState(metrics: ScrollMetrics): ScrollAreaState {
|
||||
return {
|
||||
scrollTop: metrics.scrollTop,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
scrollHeight: metrics.scrollHeight,
|
||||
isScrollable: metrics.isScrollable,
|
||||
isScrolling: metrics.isScrolling,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCommitState(
|
||||
previous: ScrollAreaState,
|
||||
next: ScrollAreaState
|
||||
): boolean {
|
||||
return (
|
||||
previous.viewportHeight !== next.viewportHeight ||
|
||||
previous.scrollHeight !== next.scrollHeight ||
|
||||
previous.isScrollable !== next.isScrollable ||
|
||||
previous.isScrolling !== next.isScrolling ||
|
||||
(previous.scrollTop > 0) !== (next.scrollTop > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function applyThumbStyle(
|
||||
thumb: HTMLDivElement | null,
|
||||
metrics: ScrollMetrics
|
||||
): void {
|
||||
if (!thumb) return
|
||||
thumb.style.height = `${metrics.thumbHeight}px`
|
||||
thumb.style.transform = `translateY(${metrics.thumbTop}px)`
|
||||
}
|
||||
|
||||
export function useScrollableModel(options: UseScrollableModelOptions) {
|
||||
const {
|
||||
viewportRef,
|
||||
contentRef,
|
||||
trackRef,
|
||||
thumbRef,
|
||||
contentHeight,
|
||||
defaultScrollTop = 0,
|
||||
scrollTop,
|
||||
minThumbSize = 20,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
} = options
|
||||
|
||||
const [state, setState] = useState<ScrollAreaState>(() =>
|
||||
toState(measureScrollArea(null, null, contentHeight, minThumbSize, false))
|
||||
)
|
||||
const stateRef = useRef(state)
|
||||
const reactStateRef = useRef(state)
|
||||
const metricsRef = useRef<ScrollMetrics>(
|
||||
measureScrollArea(null, null, contentHeight, minThumbSize, false)
|
||||
)
|
||||
const scrollEndTimerRef = useRef<number | null>(null)
|
||||
const programmaticScrollTopRef = useRef<number | null>(null)
|
||||
const pendingNotificationRef = useRef<ScrollAreaState | null>(null)
|
||||
const notificationScheduledRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const defaultScrollTopAppliedRef = useRef(false)
|
||||
const onScrollRef = useRef(onScroll)
|
||||
const onViewportResizeRef = useRef(onViewportResize)
|
||||
|
||||
onScrollRef.current = onScroll
|
||||
onViewportResizeRef.current = onViewportResize
|
||||
|
||||
const scheduleNotification = useCallback((nextState: ScrollAreaState) => {
|
||||
pendingNotificationRef.current = nextState
|
||||
if (notificationScheduledRef.current) return
|
||||
|
||||
notificationScheduledRef.current = true
|
||||
queueMicrotask(() => {
|
||||
notificationScheduledRef.current = false
|
||||
const pendingState = pendingNotificationRef.current
|
||||
pendingNotificationRef.current = null
|
||||
if (!pendingState || !mountedRef.current) return
|
||||
|
||||
if (shouldCommitState(reactStateRef.current, pendingState)) {
|
||||
reactStateRef.current = pendingState
|
||||
setState(pendingState)
|
||||
}
|
||||
onScrollRef.current?.(pendingState)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const snapshot = useCallback(
|
||||
(isScrolling = false): ScrollMetrics => {
|
||||
const metrics = measureScrollArea(
|
||||
viewportRef.current,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
isScrolling
|
||||
)
|
||||
metricsRef.current = metrics
|
||||
applyThumbStyle(thumbRef.current, metrics)
|
||||
|
||||
const nextState = toState(metrics)
|
||||
stateRef.current = nextState
|
||||
scheduleNotification(nextState)
|
||||
|
||||
return metrics
|
||||
},
|
||||
[contentHeight, minThumbSize, scheduleNotification, thumbRef, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
const scheduleScrollEnd = useCallback(() => {
|
||||
if (scrollEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollEndTimerRef.current)
|
||||
}
|
||||
|
||||
scrollEndTimerRef.current = window.setTimeout(() => {
|
||||
scrollEndTimerRef.current = null
|
||||
snapshot(false)
|
||||
}, 140)
|
||||
}, [snapshot])
|
||||
|
||||
const scrollToNow = useCallback(
|
||||
(
|
||||
top: number,
|
||||
isScrolling = true,
|
||||
knownMaxScrollTop?: number
|
||||
): ScrollMetrics | null => {
|
||||
const element = viewportRef.current
|
||||
if (!element) return null
|
||||
|
||||
let maxScrollTop = knownMaxScrollTop
|
||||
if (typeof maxScrollTop !== "number") {
|
||||
maxScrollTop = measureScrollArea(
|
||||
element,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
isScrolling
|
||||
).maxScrollTop
|
||||
}
|
||||
const nextTop = clamp(top, 0, maxScrollTop)
|
||||
|
||||
if (element.scrollTop !== nextTop) {
|
||||
programmaticScrollTopRef.current = nextTop
|
||||
element.scrollTop = nextTop
|
||||
}
|
||||
|
||||
const nextMetrics = snapshot(isScrolling)
|
||||
if (isScrolling) scheduleScrollEnd()
|
||||
return nextMetrics
|
||||
},
|
||||
[contentHeight, minThumbSize, scheduleScrollEnd, snapshot, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
const api = useMemo<ScrollAreaApi>(
|
||||
() => ({
|
||||
scrollTo(top: number) {
|
||||
scrollToNow(top)
|
||||
},
|
||||
scrollBy(deltaTop: number) {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
scrollToNow(element.scrollTop + deltaTop)
|
||||
},
|
||||
scrollToTop() {
|
||||
scrollToNow(0)
|
||||
},
|
||||
scrollToBottom() {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
const metrics = measureScrollArea(
|
||||
element,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
true
|
||||
)
|
||||
scrollToNow(metrics.maxScrollTop)
|
||||
},
|
||||
getScrollElement() {
|
||||
return viewportRef.current
|
||||
},
|
||||
}),
|
||||
[contentHeight, minThumbSize, scrollToNow, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
if (!element || defaultScrollTopAppliedRef.current) return
|
||||
|
||||
defaultScrollTopAppliedRef.current = true
|
||||
element.scrollTop = defaultScrollTop
|
||||
snapshot(false)
|
||||
}, [defaultScrollTop, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof scrollTop !== "number") return
|
||||
const element = viewportRef.current
|
||||
if (!element || element.scrollTop === scrollTop) return
|
||||
element.scrollTop = scrollTop
|
||||
snapshot(false)
|
||||
}, [scrollTop, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
programmaticScrollTopRef.current !== null &&
|
||||
Math.abs(element.scrollTop - programmaticScrollTopRef.current) < 0.5
|
||||
) {
|
||||
programmaticScrollTopRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
snapshot(true)
|
||||
scheduleScrollEnd()
|
||||
}
|
||||
|
||||
element.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => {
|
||||
element.removeEventListener("scroll", handleScroll)
|
||||
}
|
||||
}, [scheduleScrollEnd, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
const content = contentRef.current
|
||||
const track = trackRef.current
|
||||
if (!element || !content || !track) return
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const viewportEntry = entries.find((entry) => entry.target === element)
|
||||
if (viewportEntry) {
|
||||
onViewportResizeRef.current?.({
|
||||
width: viewportEntry.contentRect.width,
|
||||
height: viewportEntry.contentRect.height,
|
||||
})
|
||||
}
|
||||
snapshot(false)
|
||||
})
|
||||
|
||||
resizeObserver.observe(element)
|
||||
resizeObserver.observe(content)
|
||||
resizeObserver.observe(track)
|
||||
snapshot(false)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [contentRef, snapshot, trackRef, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
snapshot(false)
|
||||
}, [contentHeight, snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
pendingNotificationRef.current = null
|
||||
notificationScheduledRef.current = false
|
||||
if (scrollEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollEndTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { state, api, metricsRef, snapshot, scrollToNow }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--vscode-widget-border);
|
||||
}
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.updateDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
currentAppVersion,
|
||||
hasDockVisibilitySetting,
|
||||
hasNativeAppLifecycle,
|
||||
readAutostart,
|
||||
readDesktopSettings,
|
||||
writeAutostart,
|
||||
writeDockIconVisibility,
|
||||
writeSilentStart,
|
||||
} from "../../native/appLifecycle";
|
||||
import { updateStore, useUpdateStore } from "../../store/updateStore";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import { TitledCard } from "../ui/TitledCard";
|
||||
import { useMessage } from "../ui/message";
|
||||
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 [dockIconVisible, setDockIconVisible] = useState(true);
|
||||
const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
void currentAppVersion().then((next) => { if (!disposed) setVersion(next); });
|
||||
if (native) {
|
||||
void readAutostart()
|
||||
.then((enabled) => { if (!disposed) setAutostart(enabled); })
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
|
||||
.finally(() => { if (!disposed) setLoadingAutostart(false); });
|
||||
void readDesktopSettings()
|
||||
.then((settings) => {
|
||||
if (disposed) return;
|
||||
setSilentStart(settings.silent_start);
|
||||
setDockIconVisible(settings.show_dock_icon);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (disposed) return;
|
||||
setLoadingDesktopSettings(false);
|
||||
});
|
||||
}
|
||||
return () => { disposed = true; };
|
||||
}, [message, native]);
|
||||
|
||||
const toggleAutostart = async (enabled: boolean) => {
|
||||
try {
|
||||
setLoadingAutostart(true);
|
||||
await writeAutostart(enabled);
|
||||
setAutostart(await readAutostart());
|
||||
message(enabled ? t("已开启开机启动") : t("已关闭开机启动"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoadingAutostart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSilentStart = async (enabled: boolean) => {
|
||||
try {
|
||||
setLoadingDesktopSettings(true);
|
||||
await writeSilentStart(enabled);
|
||||
setSilentStart(enabled);
|
||||
message(enabled ? t("已开启静默启动") : t("已关闭静默启动"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoadingDesktopSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDockIcon = async (visible: boolean) => {
|
||||
try {
|
||||
setLoadingDesktopSettings(true);
|
||||
await writeDockIconVisibility(visible);
|
||||
setDockIconVisible(visible);
|
||||
message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoadingDesktopSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
const nextVersion = await updateStore.check();
|
||||
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
};
|
||||
|
||||
const updateNow = async () => {
|
||||
try {
|
||||
await updateStore.install();
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
};
|
||||
|
||||
return <TitledCard title={t("应用设置")}>
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("开机启动")}</strong>
|
||||
<small>{t("登录系统后自动启动 Cursor BYOK。")}</small>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autostart}
|
||||
disabled={!native || loadingAutostart}
|
||||
label={t("开机启动")}
|
||||
onChange={(enabled) => void toggleAutostart(enabled)}
|
||||
/>
|
||||
</div>
|
||||
{autostart && <div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("静默启动")}</strong>
|
||||
<small>{t("开机启动时不显示主窗口,仅保留系统托盘图标。")}</small>
|
||||
</div>
|
||||
<Switch
|
||||
checked={silentStart}
|
||||
disabled={!native || loadingDesktopSettings}
|
||||
label={t("静默启动")}
|
||||
onChange={(enabled) => void toggleSilentStart(enabled)}
|
||||
/>
|
||||
</div>}
|
||||
{dockVisibilitySetting && <div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("在 Dock 栏显示")}</strong>
|
||||
<small>{t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")}</small>
|
||||
</div>
|
||||
<Switch
|
||||
checked={dockIconVisible}
|
||||
disabled={loadingDesktopSettings}
|
||||
label={t("在 Dock 栏显示")}
|
||||
onChange={(visible) => void toggleDockIcon(visible)}
|
||||
/>
|
||||
</div>}
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("软件更新")}</strong>
|
||||
<small>{availableVersion
|
||||
? t("版本 {version} 可以安装", { version: availableVersion })
|
||||
: t("当前版本 {version}", { version })}</small>
|
||||
</div>
|
||||
{availableVersion
|
||||
? <Button size="small" variant="primary" disabled={installing} onClick={() => void updateNow()}>
|
||||
{installing ? t("安装中…") : t("下载并安装")}
|
||||
<span className={styles.updateDot} aria-hidden="true" />
|
||||
</Button>
|
||||
: <Button size="small" disabled={!native || checking} onClick={() => void checkUpdate()}>
|
||||
{checking ? t("检查中…") : t("检查更新")}
|
||||
</Button>}
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.content,
|
||||
.customFields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
padding: 4px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover { color: var(--vscode-textLink-activeForeground); }
|
||||
&:disabled { opacity: .5; }
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.control { width: 68%; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ProxySettings, ProxySettingsInput } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { TextInput } from "../ui/FormControls";
|
||||
import { Select } from "../ui/Select";
|
||||
import { TitledCard } from "../ui/TitledCard";
|
||||
import styles from "./ProxySettingsCard.module.scss";
|
||||
|
||||
export function ProxySettingsCard({
|
||||
settings,
|
||||
draft,
|
||||
editing,
|
||||
saving,
|
||||
onDraftChange,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
settings: ProxySettings | null;
|
||||
draft: ProxySettingsInput;
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
onDraftChange: (draft: ProxySettingsInput) => void;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const custom = draft.mode === "custom";
|
||||
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
|
||||
const action = editing ? (
|
||||
<div className={styles.actionGroup}>
|
||||
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={styles.headerAction} disabled={!settings} onClick={onEdit}>
|
||||
{t("编辑")}
|
||||
</button>
|
||||
);
|
||||
|
||||
return <TitledCard title={t("代理设置")} action={action}>
|
||||
<div className={styles.content}>
|
||||
{editing ? <>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("代理方式")}</strong>
|
||||
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "system", label: t("使用系统代理") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
|
||||
</div>
|
||||
{custom && <div className={styles.customFields}>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("代理地址")}</strong>
|
||||
<div className={styles.control}><TextInput value={draft.address} placeholder="http://127.0.0.1:7890" onChange={(event) => onDraftChange({ ...draft, address: event.target.value })} /></div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("认证")}</strong>
|
||||
<Checkbox checked={draft.auth_enabled} label={t("代理需要认证")} onChange={(auth_enabled) => onDraftChange({ ...draft, auth_enabled })} />
|
||||
</div>
|
||||
{draft.auth_enabled && <div className={styles.customFields}>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("用户名")}</strong>
|
||||
<div className={styles.control}><TextInput value={draft.username} autoComplete="off" onChange={(event) => onDraftChange({ ...draft, username: event.target.value })} /></div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("密码")}</strong>
|
||||
<div className={styles.control}><TextInput type="password" value={draft.password ?? ""} autoComplete="new-password" placeholder={settings?.has_password ? t("留空表示保留当前密码") : ""} onChange={(event) => onDraftChange({ ...draft, password: event.target.value })} /></div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>}
|
||||
</> : <>
|
||||
<div className={styles.row}><strong>{t("代理方式")}</strong><span className={styles.value}>{settings ? modeLabel(settings.mode) : t("加载中…")}</span></div>
|
||||
{settings?.mode === "custom" && <div className={styles.customFields}>
|
||||
<div className={styles.row}><strong>{t("代理地址")}</strong><span className={styles.value}>{settings.address}</span></div>
|
||||
<div className={styles.row}><strong>{t("认证")}</strong><span className={styles.value}>{settings.auth_enabled ? t("已启用") : t("未启用")}</span></div>
|
||||
{settings.auth_enabled && <>
|
||||
<div className={styles.row}><strong>{t("用户名")}</strong><span className={styles.value}>{settings.username || "—"}</span></div>
|
||||
<div className={styles.row}><strong>{t("密码")}</strong><span className={styles.value}>{settings.has_password ? t("已设置") : t("未设置")}</span></div>
|
||||
</>}
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
padding: 4px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover { color: var(--vscode-textLink-activeForeground); }
|
||||
&:disabled { opacity: .5; }
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.control,
|
||||
.value {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
draft,
|
||||
editing,
|
||||
saving,
|
||||
onDraftChange,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
settings: TabSettings | null;
|
||||
draft: TabSettings;
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
onDraftChange: (settings: TabSettings) => void;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const modeLabel = (mode: TabMode) => {
|
||||
if (mode === "public") return t("使用公益服务");
|
||||
if (mode === "direct") return t("直连");
|
||||
return t("自定义");
|
||||
};
|
||||
const action = editing ? (
|
||||
<div className={styles.actionGroup}>
|
||||
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={styles.headerAction} disabled={!settings} onClick={onEdit}>{t("编辑")}</button>
|
||||
);
|
||||
|
||||
return <TitledCard
|
||||
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
|
||||
action={action}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{editing ? <>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.description}>
|
||||
<strong>{t("TAB 选择")}</strong>
|
||||
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
|
||||
</div>
|
||||
<div className={styles.control}><Select
|
||||
value={draft.mode}
|
||||
ariaLabel={t("TAB 选择")}
|
||||
options={[
|
||||
{ value: "public", label: t("使用公益服务") },
|
||||
{ value: "direct", label: t("直连") },
|
||||
{ value: "custom", label: t("自定义") },
|
||||
]}
|
||||
onChange={(mode) => onDraftChange({ ...draft, mode: mode as TabMode })}
|
||||
/></div>
|
||||
</div>
|
||||
{draft.mode === "custom" && <div className={styles.row}>
|
||||
<div className={styles.description}>
|
||||
<strong>{t("TAB 服务地址")}</strong>
|
||||
<small>{t("原接口路径会追加到此服务地址。")}</small>
|
||||
</div>
|
||||
<div className={styles.control}><TextInput
|
||||
value={draft.address}
|
||||
placeholder="https://tab.leokun.cn"
|
||||
aria-label={t("TAB 服务地址")}
|
||||
onChange={(event) => onDraftChange({ ...draft, address: event.target.value })}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
|
||||
/></div>
|
||||
</div>}
|
||||
</> : <>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("TAB 选择")}</strong>
|
||||
<span className={styles.value}>{settings ? modeLabel(settings.mode) : t("加载中…")}</span>
|
||||
</div>
|
||||
{settings?.mode === "custom" && <div className={styles.row}>
|
||||
<strong>{t("TAB 服务地址")}</strong>
|
||||
<span className={styles.value}>{settings.address}</span>
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||