Compare commits

...
24 Commits
Author SHA1 Message Date
ZyyandCodex ed3d622554 chore: bump version to 3.3.1
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 15:53:32 +08:00
dependabot[bot] 0f71cf6df2 chore(deps): bump rust in /deploy in the minor-and-patch group (#46)
Bumps the minor-and-patch group in /deploy with 1 update: rust.


Updates `rust` from 1.97-bookworm to 1.98-bookworm

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.98-bookworm
  dependency-type: direct:production
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:37:44 +08:00
dependabot[bot] a1d3eba874 chore(deps): bump the minor-and-patch group in /frontend with 8 updates (#45)
Bumps the minor-and-patch group in /frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.31.0` | `1.33.0` |
| [dayjs](https://github.com/iamkun/dayjs) | `1.11.21` | `1.11.23` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.13` | `3.4.14` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.50.0` | `1.51.0` |
| [marked](https://github.com/markedjs/marked) | `18.0.9` | `18.0.10` |
| [eslint](https://github.com/eslint/eslint) | `10.8.1` | `10.9.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.1` | `8.2.2` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.10` | `3.3.11` |


Updates `@lucide/vue` from 1.31.0 to 1.33.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.33.0/packages/vue)

Updates `dayjs` from 1.11.21 to 1.11.23
- [Release notes](https://github.com/iamkun/dayjs/releases)
- [Changelog](https://github.com/iamkun/dayjs/blob/v1.11.23/CHANGELOG.md)
- [Commits](https://github.com/iamkun/dayjs/compare/v1.11.21...v1.11.23)

Updates `dompurify` from 3.4.13 to 3.4.14
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.14)

Updates `es-toolkit` from 1.50.0 to 1.51.0
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.50.0...v1.51.0)

Updates `marked` from 18.0.9 to 18.0.10
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.9...v18.0.10)

Updates `eslint` from 10.8.1 to 10.9.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.8.1...v10.9.0)

Updates `vite` from 8.2.1 to 8.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite)

Updates `vue-tsc` from 3.3.10 to 3.3.11
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.11/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dayjs
  dependency-version: 1.11.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:37:10 +08:00
dependabot[bot] d22c1d4ab9 chore(deps): bump the minor-and-patch group in /backend with 4 updates (#44)
Bumps the minor-and-patch group in /backend with 4 updates: [uuid](https://github.com/uuid-rs/uuid), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust), [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) and [redis](https://github.com/redis-rs/redis-rs).


Updates `uuid` from 1.24.0 to 1.24.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1)

Updates `aws-sdk-s3` from 1.141.0 to 1.142.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `aws-smithy-runtime-api` from 1.14.0 to 1.15.0
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `redis` from 1.5.0 to 1.6.0
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.5.0...redis-1.6.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.142.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:36:39 +08:00
dependabot[bot] cc8dfe3dd8 chore(deps): bump the minor-and-patch group with 2 updates (#43)
Bumps the minor-and-patch group with 2 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:36:01 +08:00
ZyyandCodex f25edadd5f fix(ci): keep OpenAI regression tests out of sources
Move the thread-spawn transport identity regression behind the public initialized-provider boundary so production source files remain free of embedded test modules.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 15:27:28 +08:00
ZyyandCodex 4f50c67332 fix(openai): preserve root session account affinity
Share the preferred account across derived child executions while keeping their continuation and WebSocket transport identities isolated.

Claim and migrate affinity atomically, clear account-bound state on fallback, reject cross-account previous-response continuations, and preserve complete replay transcripts.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 14:22:36 +08:00
ZyyandCodex 75f9d2a290 fix(openai): remove websocket pool slot caps
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 11:22:07 +08:00
ZyyandCodex b93b8859f9 fix(frontend): polish theme controls and page headers
Remove the redundant preview mode switch, round the color picker input, and restore inline right-aligned page header actions.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 20:21:16 +08:00
ZyyandCodex 3510a45742 chore: bump version to 3.3.0
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:07:37 +08:00
ZyyandCodex 85eb283b40 docs(readme): streamline project introduction
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:06:49 +08:00
ZyyandCodex b8e1c5077d docs(theme): document runtime theme architecture
Describe the token model, preset themes, runtime stylesheet, isolated editor preview, naming conventions, and extension workflow.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:59 +08:00
ZyyandCodex 8422240967 feat(theme): add runtime token theme editor
Introduce Seed-to-Component token derivation, isolated live previews, semantic base component theming, and responsive theme controls.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:22 +08:00
ZyyandCodex fbd922117e fix(accounts): return persisted OAuth account ID
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 19:37:03 +08:00
ZyyandCodex 941c4d57fa chore: bump version to 3.2.16
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:28:34 +08:00
ZyyandCodex cf046ad9a4 fix(admin): close quota reset modal on success
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:25:05 +08:00
ZyyandCodex 7699a954ac fix(admin): format account counts with separators
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:21:11 +08:00
ZyyandCodex 2c31708d1b feat(admin): allow selecting quota reset credits
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:19:20 +08:00
ZyyandCodex 80f338ce01 fix(admin): show raw account counts
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 23:12:54 +08:00
ZyyandCodex 299088a6e7 chore: bump version to 3.2.15
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 20:00:59 +08:00
ZyyandCodex a23bfca848 fix(admin): normalize OAuth credential formats
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:57:16 +08:00
ZyyandCodex 8951a64a05 fix(admin): raise billing abbreviation threshold
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:15:22 +08:00
ZyyandCodex eb20561893 fix(admin): align xAI quota usage displays
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:58:02 +08:00
ZyyandCodex a33637a99e fix(openai): use Codex image endpoint paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:02:40 +08:00
199 changed files with 8496 additions and 2600 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ jobs:
ref: ${{ inputs.ref }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -79,7 +79,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
sarif_file: trivy-results.sarif
category: runtime-container
+2 -2
View File
@@ -448,7 +448,7 @@ jobs:
PY
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -519,7 +519,7 @@ jobs:
path: dist/release
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
-3
View File
@@ -16,9 +16,6 @@
</div>
Codex Proxy RS 在一个网关中管理 OpenAI 与 xAI 账号,为客户端提供统一鉴权、模型路由、额度状态、
请求观测和管理控制面。
> [!NOTE]
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
+18 -18
View File
@@ -216,9 +216,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.141.0"
version = "1.142.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f9420d3a2467eed22ed3635ca653653162c386a0b0f65c78189f9bd3c1379e"
checksum = "f9e15a5c55e05f4b0b7e483160b3c85cccdf77cff02c95504f3e71d460855cd2"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -308,9 +308,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
version = "0.61.1"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
checksum = "6de526c7b567420a31bc283657a7921b45c4cafe0827fdf2490713dcc770c28f"
dependencies = [
"aws-smithy-types",
"bytes",
@@ -341,9 +341,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -385,9 +385,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.12.1"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1"
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -411,9 +411,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.14.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb"
checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -1246,7 +1246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2714,7 +2714,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2813,9 +2813,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.5.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3257df217f7eab0044627a268c9cc6cdb60c0c421c88f83ac41c4e31520b6b84"
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
dependencies = [
"arc-swap",
"arcstr",
@@ -2984,7 +2984,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3628,7 +3628,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4100,9 +4100,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.24.0"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -729,21 +729,19 @@ pub(crate) fn dashboard_view(
last_used: relative_time(credential.last_used_at, range.end),
});
}
let unavailable_accounts = provider_accounts
.total
.saturating_sub(provider_accounts.normal);
DashboardDataView {
cards: DashboardCardsView {
credentials: DashboardCredentialsCardView {
total: format_compact_number(provider_accounts.total),
total: provider_accounts.total.to_string(),
total_value: provider_accounts.total,
available: format_compact_number(provider_accounts.normal),
available: provider_accounts.normal.to_string(),
available_value: provider_accounts.normal,
unavailable: format_compact_number(
provider_accounts
.total
.saturating_sub(provider_accounts.normal),
),
unavailable_value: provider_accounts
.total
.saturating_sub(provider_accounts.normal),
unavailable: unavailable_accounts.to_string(),
unavailable_value: unavailable_accounts,
},
traffic: DashboardTrafficCardView {
today_requests: format_compact_number(today.request_count),
@@ -221,9 +221,9 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
attempts: AttemptMetrics::default(),
totals: Default::default(),
provider_accounts: AccountPoolMetrics {
total: 807,
normal: 15,
quota_exhausted: 785,
total: 1002,
normal: 943,
quota_exhausted: 52,
rate_limited: 0,
disabled: 3,
error: 4,
@@ -259,9 +259,21 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
&value["data"]["cards"]["credentials"]["unavailableValue"],
),
(
&serde_json::json!(807),
&serde_json::json!(15),
&serde_json::json!(792)
&serde_json::json!(1002),
&serde_json::json!(943),
&serde_json::json!(59)
),
);
assert_eq!(
(
&value["data"]["cards"]["credentials"]["total"],
&value["data"]["cards"]["credentials"]["available"],
&value["data"]["cards"]["credentials"]["unavailable"],
),
(
&serde_json::json!("1002"),
&serde_json::json!("943"),
&serde_json::json!("59")
),
);
}
@@ -231,6 +231,32 @@ pub trait ProviderSessionAffinityPort: Send + Sync {
ttl: Duration,
) -> BoxFuture<'a, Result<(), ProviderStoreError>>;
/// 仅在亲和键尚未绑定时写入候选账号,并返回原子操作后的实际绑定。
///
/// 已存在的绑定绝不会被候选账号覆盖;同一根会话的并发首次请求据此收敛到
/// 单一账号。TTL 只在首次写入时设置,已有绑定由成功反馈负责刷新。
fn claim_or_load<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
candidate_account_id: &'a ProviderAccountId,
ttl: Duration,
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>>;
/// 仅当当前绑定等于 `expected_account_id`(或键已过期)时写入新账号,
/// 并返回原子操作后的实际绑定。
///
/// Provider 用它迁移不可用账号,以及在成功后以 `expected == replacement`
/// 刷新 TTL;迟到的旧账号成功不能覆盖较新的会话 winner。
fn compare_and_bind<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
expected_account_id: &'a ProviderAccountId,
replacement_account_id: &'a ProviderAccountId,
ttl: Duration,
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>>;
fn clear<'a>(
&'a self,
provider_kind: &'a ProviderKind,
@@ -682,8 +682,10 @@ impl AccountStore for PgAdminAccountStore {
) -> AdminStoreResult<CredentialMutationResult> {
match command.credential {
AuthorizationCredentialCommit::Create(credential) => {
let account_id = credential.account_id.clone();
let result = self
let CredentialImportResult {
config_revision,
credential_ids,
} = self
.commit_prepared_import(
PreparedCredentialImport {
provider_kind: credential.provider_kind.clone(),
@@ -693,6 +695,14 @@ impl AccountStore for PgAdminAccountStore {
"authorize",
)
.await?;
let [account_id]: [CoreProviderAccountId; 1] =
credential_ids.try_into().map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
ENTITY,
"authorization import returned an unexpected account count",
)
})?;
let details = self
.accounts
.load_provider_account(account_id.as_str())
@@ -706,7 +716,7 @@ impl AccountStore for PgAdminAccountStore {
)
})?;
Ok(CredentialMutationResult {
config_revision: result.config_revision,
config_revision,
account_id,
credential_revision: Some(admin_revision(details.summary.credential_revision)?),
})
@@ -16,6 +16,24 @@ use super::{namespace, resource_fingerprint};
const MAX_SESSION_AFFINITY_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const CLAIM_OR_LOAD_SCRIPT: &str = r#"
local current = redis.call('GET', KEYS[1])
if current then
return current
end
redis.call('PSETEX', KEYS[1], tonumber(ARGV[2]), ARGV[1])
return ARGV[1]
"#;
const COMPARE_AND_BIND_SCRIPT: &str = r#"
local current = redis.call('GET', KEYS[1])
if not current or current == ARGV[1] then
redis.call('PSETEX', KEYS[1], tonumber(ARGV[3]), ARGV[2])
return ARGV[2]
end
return current
"#;
#[derive(Clone)]
pub struct RedisProviderSessionAffinityRepository {
connection: ConnectionManager,
@@ -79,11 +97,7 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<(), ProviderStoreError>> {
Box::pin(async move {
if ttl.is_zero() || ttl > MAX_SESSION_AFFINITY_TTL {
return Err(provider_invalid("validate provider session affinity TTL"));
}
let ttl_millis = u64::try_from(ttl.as_millis())
.map_err(|_| provider_invalid("validate provider session affinity TTL"))?;
let ttl_millis = session_affinity_ttl_millis(ttl)?;
let mut connection = self.connection.clone();
redis::cmd("PSETEX")
.arg(self.key(provider_kind, key)?)
@@ -95,6 +109,50 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
})
}
fn claim_or_load<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
candidate_account_id: &'a ProviderAccountId,
ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move {
let ttl_millis = session_affinity_ttl_millis(ttl)?;
let mut connection = self.connection.clone();
let effective_account_id = redis::Script::new(CLAIM_OR_LOAD_SCRIPT)
.key(self.key(provider_kind, key)?)
.arg(candidate_account_id.as_str())
.arg(ttl_millis)
.invoke_async::<String>(&mut connection)
.await
.map_err(|_| provider_unavailable("claim provider session affinity"))?;
decode_account_id(effective_account_id)
})
}
fn compare_and_bind<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
expected_account_id: &'a ProviderAccountId,
replacement_account_id: &'a ProviderAccountId,
ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move {
let ttl_millis = session_affinity_ttl_millis(ttl)?;
let mut connection = self.connection.clone();
let effective_account_id = redis::Script::new(COMPARE_AND_BIND_SCRIPT)
.key(self.key(provider_kind, key)?)
.arg(expected_account_id.as_str())
.arg(replacement_account_id.as_str())
.arg(ttl_millis)
.invoke_async::<String>(&mut connection)
.await
.map_err(|_| provider_unavailable("compare provider session affinity"))?;
decode_account_id(effective_account_id)
})
}
fn clear<'a>(
&'a self,
provider_kind: &'a ProviderKind,
@@ -112,6 +170,19 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
}
}
fn session_affinity_ttl_millis(ttl: Duration) -> Result<u64, ProviderStoreError> {
if ttl.is_zero() || ttl > MAX_SESSION_AFFINITY_TTL {
return Err(provider_invalid("validate provider session affinity TTL"));
}
u64::try_from(ttl.as_millis())
.map_err(|_| provider_invalid("validate provider session affinity TTL"))
}
fn decode_account_id(account_id: String) -> Result<ProviderAccountId, ProviderStoreError> {
ProviderAccountId::new(account_id)
.map_err(|_| provider_invalid("decode provider session affinity"))
}
fn provider_unavailable(operation: &'static str) -> ProviderStoreError {
ProviderStoreError::new(ProviderStoreErrorKind::Unavailable, operation)
}
@@ -10,7 +10,12 @@ use gateway_admin::{
UpdateAccount,
},
observability::TimeRange,
provider_credentials::{CredentialListQuery, CredentialListWindow, CredentialStateFilter},
provider_credentials::{
AuthorizationCommit, AuthorizationCredentialCommit, AuthorizationMutationTarget,
AuthorizationOwnerBinding, CredentialListQuery, CredentialListWindow,
CredentialStateFilter, PendingAuthorizationMutation, PreparedCredentialCreate,
ProviderDocument,
},
},
ports::store::AccountStore,
};
@@ -1326,6 +1331,75 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
database.close().await;
}
#[tokio::test]
async fn authorization_create_returns_existing_account_id_when_identity_is_upserted() {
let Some(database) = TestDatabase::create("provider_account_authorization_upsert").await else {
return;
};
PgProviderAccountRepository::new(database.pool.clone())
.insert_provider_account(account(
"acct_authorization_existing",
"user-authorization-upsert",
))
.await
.expect("seed existing authorized identity");
let context = MutationContext {
actor: MutationActor::System,
request_id: "request_authorization_upsert".to_owned(),
};
let provider_kind = ProviderKind::new("openai").expect("OpenAI Provider kind");
let serde_json::Value::Object(provider_material) =
json!({ "access_token": "authorized-secret" })
else {
unreachable!("credential fixture must be an object");
};
let result = PgAdminAccountStore::new(database.pool.clone(), None)
.commit_authorization(
AuthorizationCommit {
pending: PendingAuthorizationMutation::new(
provider_kind.clone(),
AuthorizationMutationTarget::Create {
name: "authorized account".to_owned(),
},
AuthorizationOwnerBinding::from_context(&context),
),
credential: AuthorizationCredentialCommit::Create(PreparedCredentialCreate {
account_id: ProviderAccountId::new("acct_authorization_candidate")
.expect("candidate account ID"),
provider_kind,
name: "authorized account".to_owned(),
email: Some("authorized@example.invalid".to_owned()),
upstream_user_id: Some("user-authorization-upsert".to_owned()),
upstream_account_id: None,
plan_type: Some("free".to_owned()),
authentication_kind: "oauth".to_owned(),
provider_material: ProviderDocument::new(OpaqueProviderData::new(
provider_material,
)),
has_refresh_token: true,
access_token_expires_at: Some(Utc::now() + TimeDelta::hours(1)),
next_refresh_at: None,
enabled: true,
credential_state: CredentialState::Ready,
credential_observed_at: Utc::now(),
}),
},
&context,
)
.await
.expect("authorize existing identity");
assert_eq!(
(
result.account_id.as_str(),
result.credential_revision.map(|revision| revision.get()),
),
("acct_authorization_existing", Some(2)),
);
database.close().await;
}
#[tokio::test]
async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
let Some(database) = TestDatabase::create("provider_account_core_refresh").await else {
@@ -65,6 +65,55 @@ async fn session_affinity_should_overwrite_the_previous_account() {
);
}
#[tokio::test]
async fn session_affinity_claim_should_keep_the_first_account() {
let Some((repository, _connection, _namespace)) = affinity_repository().await else {
return;
};
let provider = ProviderKind::new("openai").expect("provider");
let key = ProviderSessionAffinityKey::try_new("claim-session").expect("affinity key");
let first = ProviderAccountId::new("acct_first").expect("first account");
let second = ProviderAccountId::new("acct_second").expect("second account");
let first_winner = repository
.claim_or_load(&provider, &key, &first, Duration::from_secs(60))
.await
.expect("claim first affinity");
let second_winner = repository
.claim_or_load(&provider, &key, &second, Duration::from_secs(60))
.await
.expect("load existing affinity");
assert_eq!((first_winner, second_winner), (first.clone(), first));
}
#[tokio::test]
async fn session_affinity_compare_should_not_replace_a_newer_winner() {
let Some((repository, _connection, _namespace)) = affinity_repository().await else {
return;
};
let provider = ProviderKind::new("openai").expect("provider");
let key = ProviderSessionAffinityKey::try_new("compare-session").expect("affinity key");
let first = ProviderAccountId::new("acct_first").expect("first account");
let second = ProviderAccountId::new("acct_second").expect("second account");
let stale = ProviderAccountId::new("acct_stale").expect("stale account");
repository
.bind(&provider, &key, &first, Duration::from_secs(60))
.await
.expect("seed affinity");
let migrated = repository
.compare_and_bind(&provider, &key, &first, &second, Duration::from_secs(60))
.await
.expect("migrate expected affinity");
let stale_result = repository
.compare_and_bind(&provider, &key, &first, &stale, Duration::from_secs(60))
.await
.expect("reject stale affinity migration");
assert_eq!((migrated, stale_result), (second.clone(), second));
}
#[tokio::test]
async fn session_affinity_should_apply_ttl_and_support_explicit_clear() {
let Some((repository, mut connection, namespace)) = affinity_repository().await else {
+1 -12
View File
@@ -155,8 +155,6 @@ impl CodexApiConfig {
pub struct CodexWebSocketPoolSettings {
pub enabled: bool,
pub max_age_ms: u64,
pub max_per_account: usize,
pub max_total: usize,
pub max_connecting: usize,
pub initial_event_timeout_ms: u64,
}
@@ -166,8 +164,6 @@ impl Default for CodexWebSocketPoolSettings {
Self {
enabled: true,
max_age_ms: 55 * 60 * 1000,
max_per_account: 8,
max_total: 64,
max_connecting: 8,
initial_event_timeout_ms: 20_000,
}
@@ -176,12 +172,7 @@ impl Default for CodexWebSocketPoolSettings {
impl CodexWebSocketPoolSettings {
fn validate(&self) -> Result<(), OpenAiConfigError> {
if self.max_age_ms == 0
|| self.max_per_account == 0
|| self.max_total == 0
|| self.max_connecting == 0
|| self.max_connecting > self.max_total
{
if self.max_age_ms == 0 || self.max_connecting == 0 {
return Err(OpenAiConfigError::InvalidField("openai.ws_pool"));
}
Ok(())
@@ -191,8 +182,6 @@ impl CodexWebSocketPoolSettings {
CodexWebSocketPoolConfig {
enabled: self.enabled,
max_age: Duration::from_millis(self.max_age_ms),
max_per_account: self.max_per_account,
max_total: self.max_total,
max_connecting: self.max_connecting,
initial_event_timeout: (self.initial_event_timeout_ms != 0)
.then(|| Duration::from_millis(self.initial_event_timeout_ms)),
@@ -1288,9 +1288,9 @@ fn parse_oauth_import_tokens(
Value::Object(object) => {
for (key, value) in object {
let token = match key.as_str() {
"accessToken" => &mut access_token,
"refreshToken" => &mut refresh_token,
"idToken" => &mut id_token,
"accessToken" | "access_token" => &mut access_token,
"refreshToken" | "refresh_token" => &mut refresh_token,
"idToken" | "id_token" => &mut id_token,
_ => {
pending.push(value);
continue;
@@ -1,4 +1,4 @@
//! OpenAI 会话锚点到 Store 不透明亲和键的单向派生。
//! OpenAI 根会话锚点到 Store 不透明账号亲和键的单向派生。
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::ProviderSessionAffinityKey;
@@ -11,34 +11,8 @@ pub(crate) fn derive_codex_session_affinity_key(
request: &CodexResponsesRequest,
client_api_key_id: &ClientApiKeyId,
) -> Option<ProviderSessionAffinityKey> {
let session_key = if let Some(conversation_id) = request
.local_conversation_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Ok(key) = ProviderSessionAffinityKey::try_new(conversation_id.to_owned()) {
Some(key)
} else {
opaque_affinity_key("local-conversation", conversation_id)
}
} else {
let (domain, value) = derive_conversation_anchor(request)?;
opaque_affinity_key(domain, &value)
};
let session_discriminator = request.subagent_kind();
let session_key =
session_key.and_then(|session_key| match session_discriminator.as_deref() {
None => Some(session_key),
Some(discriminator) => {
// 仅扩展原会话锚点的键空间;后续查找、绑定和调度仍使用同一套亲和逻辑。
opaque_affinity_key(
"subagent-session",
&format!("{discriminator}\0{}", session_key.expose_to_store()),
)
}
})?;
let (domain, value) = derive_account_affinity_anchor(request)?;
let session_key = opaque_affinity_key(domain, &value)?;
opaque_affinity_key(
"client-session",
&format!(
@@ -49,6 +23,33 @@ pub(crate) fn derive_codex_session_affinity_key(
)
}
/// 账号选择优先使用根会话事实;`local_conversation_id` 仍可按 child thread 隔离
/// WebSocket 与 continuation 状态,但 child 身份不再拆分账号首选项。账号不可调度时
/// selector 仍可按既有策略切换,并由 request scope 清理账号绑定状态。
fn derive_account_affinity_anchor(
request: &CodexResponsesRequest,
) -> Option<(&'static str, String)> {
non_empty(request.client_session_id.as_deref())
.map(|value| ("root-session", value.to_owned()))
.or_else(|| {
non_empty(request.client_conversation_id.as_deref())
.map(|value| ("root-conversation", value.to_owned()))
})
.or_else(|| {
request
.explicit_prompt_cache_key
.then(|| request.prompt_cache_key())
.flatten()
.and_then(|value| non_empty(Some(value)))
.map(|value| ("root-prompt-cache", value.to_owned()))
})
.or_else(|| {
non_empty(request.local_conversation_id.as_deref())
.map(|value| ("local-conversation", value.to_owned()))
})
.or_else(|| derive_conversation_anchor(request))
}
fn opaque_affinity_key(domain: &str, value: &str) -> Option<ProviderSessionAffinityKey> {
let mut hasher = Sha256::new();
hasher.update(b"codex-session-affinity-v1\0");
@@ -476,14 +476,16 @@ impl CodexCredentialSelector {
{
affinity.escape(AffinityEscapeReason::PinnedAccount);
}
let preferred = pinned_account
.clone()
.or_else(|| affinity.preferred_account().cloned());
let mut observed_affinity_account = affinity.bound_account().cloned();
let mut shortest_retry = None;
let policy = request.attempt.account_selection_policy();
loop {
let preferred = pinned_account
.clone()
.or_else(|| affinity.preferred_account().cloned());
let context = AccountSelectionContext {
policy: request.attempt.account_selection_policy(),
policy,
now: SystemTime::now(),
excluded_accounts: excluded.clone(),
preferred_account: preferred.clone(),
@@ -511,7 +513,6 @@ impl CodexCredentialSelector {
.map(|candidate| candidate.account.clone())
.ok_or(CredentialSelectionError::InvalidCredential)?;
let allows_account_state_mutation = !diagnostic || account.enabled();
let policy = request.attempt.account_selection_policy();
match self
.leases
.try_acquire(ProviderLeaseRequest::Scheduling(
@@ -532,6 +533,40 @@ impl CodexCredentialSelector {
excluded.insert(account.id().clone());
}
ProviderLeaseAcquisition::Acquired(guard) => {
let initial_affinity_claim = if !diagnostic
&& observed_affinity_account.is_none()
&& let Some(key) = request.session_affinity_key
{
self.claim_initial_session_affinity(key, account.id()).await
} else {
None
};
// 原生 continuation/required account 比根亲和绑定更严格;它可以
// 使用 owner 账号,但不能把已经迁移的根会话拉回旧号。
if pinned_account.is_none()
&& let Some(effective_account) = initial_affinity_claim
&& &effective_account != account.id()
{
drop(guard);
observed_affinity_account = Some(effective_account.clone());
affinity = affinity_selection_for_bound_account(
effective_account,
&candidates,
SystemTime::now(),
);
continue;
}
let affinity_expected_account_id = if pinned_account
.as_ref()
.zip(observed_affinity_account.as_ref())
.is_some_and(|(pinned, bound)| pinned != bound)
{
account.id().clone()
} else {
observed_affinity_account
.clone()
.unwrap_or_else(|| account.id().clone())
};
let affinity_telemetry = affinity.telemetry(account.id());
tracing::info!(
request_id = %request.attempt.request_id(),
@@ -583,6 +618,7 @@ impl CodexCredentialSelector {
cyber_policy_scope,
allows_account_state_mutation,
affinity_telemetry,
affinity_expected_account_id,
_guard: guard,
});
}
@@ -603,24 +639,7 @@ impl CodexCredentialSelector {
else {
return AffinitySelection::default();
};
let Some(candidate) = candidates
.iter()
.find(|candidate| candidate.account.id() == &account_id)
else {
self.clear_session_affinity(key).await;
return AffinitySelection::escaped(account_id, AffinityEscapeReason::HardUnavailable);
};
if candidate
.account
.status_projection(now, candidate.signals.rate_limited_until)
.status
== AccountStatus::Normal
{
return AffinitySelection::preferred(account_id);
}
let reason = affinity_unavailable_reason(candidate, now);
self.clear_session_affinity(key).await;
AffinitySelection::escaped(account_id, reason)
affinity_selection_for_bound_account(account_id, candidates, now)
}
async fn lookup_session_affinity(
@@ -649,22 +668,38 @@ impl CodexCredentialSelector {
}
}
async fn clear_session_affinity(&self, key: &ProviderSessionAffinityKey) {
async fn claim_initial_session_affinity(
&self,
key: &ProviderSessionAffinityKey,
selected_account_id: &ProviderAccountId,
) -> Option<ProviderAccountId> {
match tokio::time::timeout(
SESSION_AFFINITY_TIMEOUT,
self.session_affinity.clear(&self.provider_kind, key),
self.session_affinity.claim_or_load(
&self.provider_kind,
key,
selected_account_id,
SESSION_AFFINITY_TTL,
),
)
.await
{
Ok(Ok(_)) => {}
Ok(Ok(account_id)) => Some(account_id),
Ok(Err(error)) => {
tracing::warn!(error = %error, "OpenAI stale session affinity clear failed open");
tracing::warn!(
account_id = %selected_account_id,
error = %error,
"OpenAI initial session affinity claim failed open"
);
None
}
Err(_) => {
tracing::warn!(
account_id = %selected_account_id,
timeout_ms = SESSION_AFFINITY_TIMEOUT.as_millis(),
"OpenAI stale session affinity clear timed out"
"OpenAI initial session affinity claim timed out"
);
None
}
}
}
@@ -901,6 +936,7 @@ impl CodexCredentialSelector {
&self,
account: &ProviderAccount,
session_affinity_key: Option<&ProviderSessionAffinityKey>,
expected_affinity_account_id: &ProviderAccountId,
) {
self.restore_recoverable_account_state(account).await;
self.risk_recovery
@@ -910,24 +946,26 @@ impl CodexCredentialSelector {
let Some(key) = session_affinity_key else {
return;
};
if matches!(
self.lookup_session_affinity(key).await,
SessionAffinityLookup::Unavailable
) {
return;
}
match tokio::time::timeout(
SESSION_AFFINITY_TIMEOUT,
self.session_affinity.bind(
self.session_affinity.compare_and_bind(
&self.provider_kind,
key,
expected_affinity_account_id,
account.id(),
SESSION_AFFINITY_TTL,
),
)
.await
{
Ok(Ok(())) => {}
Ok(Ok(effective_account)) if &effective_account == account.id() => {}
Ok(Ok(effective_account)) => {
tracing::debug!(
account_id = %account.id(),
effective_account_id = %effective_account,
"OpenAI late success left the newer session affinity binding unchanged"
);
}
Ok(Err(error)) => {
tracing::warn!(
account_id = %account.id(),
@@ -1104,6 +1142,32 @@ impl CodexCredentialSelector {
}
}
fn affinity_selection_for_bound_account(
account_id: ProviderAccountId,
candidates: &[AccountCandidate],
now: SystemTime,
) -> AffinitySelection {
let Some(candidate) = candidates
.iter()
.find(|candidate| candidate.account.id() == &account_id)
else {
return AffinitySelection::escaped(account_id, AffinityEscapeReason::HardUnavailable);
};
match candidate
.account
.status_projection(now, candidate.signals.rate_limited_until)
.status
{
AccountStatus::Normal => AffinitySelection::preferred(account_id),
AccountStatus::QuotaExhausted
| AccountStatus::RateLimited
| AccountStatus::Disabled
| AccountStatus::Error => {
AffinitySelection::escaped(account_id, affinity_unavailable_reason(candidate, now))
}
}
}
fn affinity_unavailable_reason(
candidate: &AccountCandidate,
now: SystemTime,
@@ -1159,6 +1223,7 @@ pub struct CodexCredentialLease {
cyber_policy_scope: Option<CodexCyberPolicyScope>,
allows_account_state_mutation: bool,
affinity_telemetry: AffinityTelemetry,
affinity_expected_account_id: ProviderAccountId,
_guard: Box<dyn ProviderLeaseGuard>,
}
@@ -1216,6 +1281,12 @@ impl CodexCredentialLease {
pub const fn account_switch(&self) -> bool {
self.affinity_telemetry.account_switch
}
/// 成功反馈只能从选择时观察到的绑定迁移,避免迟到请求覆盖较新 winner。
#[must_use]
pub(crate) const fn affinity_expected_account_id(&self) -> &ProviderAccountId {
&self.affinity_expected_account_id
}
}
impl fmt::Debug for CodexCredentialLease {
+39 -11
View File
@@ -71,6 +71,9 @@ use crate::transport::profile::{
use crate::transport::protocol::responses::{
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
};
use crate::transport::protocol::websocket::{
PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
};
use crate::transport::request::{
CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account,
};
@@ -318,8 +321,14 @@ impl Provider for CodexProvider {
.is_some_and(|state| state.account_id != lease.account_id().as_str());
let has_explicit_state_owner =
context.account_state_owner().is_some() || previous_session.is_some();
let cross_account =
state_owner_cross_account || (!has_explicit_state_owner && lease.account_switch());
let account_scope =
if state_owner_cross_account || (!has_explicit_state_owner && lease.account_switch()) {
RequestAccountScope::Different
} else if has_explicit_state_owner || lease.affinity_hit() {
RequestAccountScope::Same
} else {
RequestAccountScope::Unknown
};
if context.continuation_attempt() != ContinuationAttempt::None
&& let Some(continuation) = context.continuation()
{
@@ -366,13 +375,11 @@ impl Provider for CodexProvider {
}
}
}
let account_scope = if !cross_account {
RequestAccountScope::SameAccount
} else if upstream_request.previous_response_id().is_some() {
RequestAccountScope::CrossAccountContinuationProbe
} else {
RequestAccountScope::CrossAccountFullRequest
};
if upstream_request.previous_response_id().is_some()
&& !account_scope.can_reuse_account_state()
{
return Err(continuation_replay_required_error());
}
scope_request_to_account(
&mut upstream_request,
lease.installation_id(),
@@ -1178,7 +1185,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
// 完成事件一旦交给下游,Core 可以立刻停止轮询 Provider stream;
// 在此之前持久化亲和关系,保证成功请求不会因流被提前 drop 而丢失绑定。
selector
.record_success(&active_account, session_affinity_key.as_ref())
.record_success(
&active_account,
session_affinity_key.as_ref(),
lease.affinity_expected_account_id(),
)
.await;
selector
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
@@ -1282,7 +1293,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
if allows_account_state_mutation && completed && terminal_failure.is_none() {
// 同上:尾部 finish() 也可能产出 completed,亲和记录必须先于任何下游 yield。
selector
.record_success(&active_account, session_affinity_key.as_ref())
.record_success(
&active_account,
session_affinity_key.as_ref(),
lease.affinity_expected_account_id(),
)
.await;
selector
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
@@ -2364,6 +2379,19 @@ fn map_handshake_error(error: CodexClientError) -> MappedProviderFailure {
map_client_error(error, UpstreamSendState::Ambiguous, true)
}
fn continuation_replay_required_error() -> ProviderError {
ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
Some(PREVIOUS_RESPONSE_NOT_FOUND_CODE.to_owned()),
Some("invalid_request_error".to_owned()),
))
}
fn map_stream_error(error: CodexClientError) -> MappedProviderFailure {
let allows_pre_delivery_retry = stream_transport_allows_pre_delivery_retry(&error);
let mut failure = map_client_error(error, UpstreamSendState::Sent, false);
@@ -1,9 +1,9 @@
/// `/codex/responses`
pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
/// `/images/generations`
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/images/generations";
/// `/images/edits`
pub const CODEX_IMAGE_EDITS_PATH: &str = "/images/edits";
/// `/codex/images/generations`
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/codex/images/generations";
/// `/codex/images/edits`
pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
/// `/api/codex/usage`
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
/// `/wham/usage`
@@ -525,11 +525,6 @@ impl CodexResponsesRequest {
.map_or(&[], Vec::as_slice)
}
/// 替换输入条目(仅用于代理持有完整历史时的换号重放)。
pub fn set_input(&mut self, input: Vec<Value>) {
self.body.insert("input".to_string(), Value::Array(input));
}
/// 是否流式返回。
pub fn stream(&self) -> bool {
self.body
@@ -617,11 +612,12 @@ impl CodexResponsesRequest {
)
}
/// 返回会话亲和所用的子代理区分值。
/// 返回请求语义与 child transport 隔离所用的子代理区分值。
///
/// Codex 原生请求在 turn metadata 中声明 `subagent_kind`;网关的
/// `/responses/review` 等入口则通过 `client_metadata.x-openai-subagent`
/// 传递给上游。该可选值仅参与会话亲和键派生。
/// 传递给上游。它不拆分根会话的账号首选项;`thread_spawn` 仍用它派生独立
/// WebSocket/continuation transport identity。
pub fn subagent_kind(&self) -> Option<String> {
self.semantics()
.subagent_kind
@@ -5,7 +5,9 @@ use serde_json::{Map, Value};
use crate::transport::protocol::responses::{CodexResponsesRequest, transport_requirement};
const REDACTED_PAYLOAD_VALUE: &str = "<redacted>";
const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
"Previous response was not found. Retrying the full request.";
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
/// WebSocket 握手审计快照。
@@ -63,18 +63,14 @@ const INSTALLATION_ID_KEYS: &[&str] = &[
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RequestAccountScope {
SameAccount,
CrossAccountFullRequest,
CrossAccountContinuationProbe,
Same,
Different,
Unknown,
}
impl RequestAccountScope {
const fn is_cross_account(self) -> bool {
!matches!(self, Self::SameAccount)
}
const fn preserves_continuation(self) -> bool {
matches!(self, Self::CrossAccountContinuationProbe)
pub(crate) const fn can_reuse_account_state(self) -> bool {
matches!(self, Self::Same)
}
}
@@ -205,8 +201,9 @@ pub(crate) fn derive_conversation_anchor(
})
}
/// A `thread_spawn` shares its parent session but is a separate child execution.
/// Its child thread must therefore own the affinity and WebSocket pool identity.
/// `thread_spawn` 与父任务共享根会话,但它本身是独立的子任务执行。
/// 因此子线程必须拥有独立的 continuation/WebSocket 传输身份;账号亲和则从共享的
/// 根会话单独派生。
fn thread_spawn_conversation_anchor(
request: &CodexResponsesRequest,
) -> Option<(&'static str, String)> {
@@ -306,21 +303,17 @@ fn normalize_conversation_anchor_text(text: &str) -> String {
///
/// 真实 account ID 与 installation ID 由随后构造的 `CodexRequestContext` 注入
/// 请求头;正文只替换客户端原本声明过的 installation 字段,绝不接受客户端
/// 提供的 token、cookie 或账号身份。Continuation probe 保留 canonical
/// previous-response 和原始增量 input,不能把它们误当成完整请求清理。
/// 提供的 token、cookie 或账号身份。`input` 是 Responses 的可回放会话正文,
/// item ID、encrypted content 和 compaction 都必须原样保留。
pub(crate) fn scope_request_to_account(
request: &mut CodexResponsesRequest,
installation_id: &str,
account_scope: RequestAccountScope,
) {
let cross_account = account_scope.is_cross_account();
let continuation = account_scope
.preserves_continuation()
.then(|| request.previous_response_id().map(ToOwned::to_owned))
.flatten();
let reset_account_state = !account_scope.can_reuse_account_state();
let client_metadata_turn_state = metadata_string(request, "x-codex-turn-state");
let preserve_turn_state =
!cross_account && (request.turn_state.is_some() || client_metadata_turn_state.is_some());
let preserve_turn_state = !reset_account_state
&& (request.turn_state.is_some() || client_metadata_turn_state.is_some());
let turn_state = preserve_turn_state
.then(|| request.turn_state.clone())
.flatten();
@@ -329,38 +322,22 @@ pub(crate) fn scope_request_to_account(
} else {
None
};
let turn_metadata = (!account_scope.preserves_continuation())
.then(|| {
request
.turn_metadata
.as_deref()
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, cross_account))
})
.flatten();
let client_metadata_turn_metadata = (!account_scope.preserves_continuation())
.then(|| {
metadata_string(request, "x-codex-turn-metadata")
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, cross_account))
})
.flatten();
let turn_metadata = request
.turn_metadata
.as_deref()
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, reset_account_state));
let client_metadata_turn_metadata = metadata_string(request, "x-codex-turn-metadata")
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, reset_account_state));
if cross_account {
if reset_account_state {
request.passthrough_headers.remove("x-codex-turn-state");
request.passthrough_headers.remove("x-codex-turn-metadata");
if !account_scope.preserves_continuation() {
sanitize_cross_account_input(request);
}
for key in CROSS_ACCOUNT_IDENTITY_KEYS
.iter()
.chain(ACCOUNT_BOUND_STATE_KEYS)
{
request.body_mut().remove(*key);
}
if account_scope.preserves_continuation() {
for key in TURN_METADATA_KEYS {
request.body_mut().remove(*key);
}
}
}
for key in INSTALLATION_ID_KEYS {
@@ -371,7 +348,7 @@ pub(crate) fn scope_request_to_account(
.body()
.get(*key)
.and_then(Value::as_str)
.and_then(|value| scope_turn_metadata(value, installation_id, cross_account));
.and_then(|value| scope_turn_metadata(value, installation_id, reset_account_state));
replace_existing_body_string(request, key, scoped.as_deref());
}
@@ -383,22 +360,17 @@ pub(crate) fn scope_request_to_account(
(
key,
metadata.get(key).and_then(Value::as_str).and_then(|value| {
scope_turn_metadata(value, installation_id, cross_account)
scope_turn_metadata(value, installation_id, reset_account_state)
}),
)
});
if cross_account {
if reset_account_state {
for key in CROSS_ACCOUNT_IDENTITY_KEYS
.iter()
.chain(ACCOUNT_BOUND_STATE_KEYS)
{
metadata.remove(*key);
}
if account_scope.preserves_continuation() {
for key in TURN_METADATA_KEYS {
metadata.remove(*key);
}
}
}
metadata.insert(
"x-codex-installation-id".to_owned(),
@@ -436,36 +408,6 @@ pub(crate) fn scope_request_to_account(
request.turn_state = turn_state;
request.turn_metadata = turn_metadata;
if let Some(continuation) = continuation {
request.set_previous_response_id(Some(continuation));
}
}
fn sanitize_cross_account_input(request: &mut CodexResponsesRequest) {
if request.input().is_empty() {
return;
}
let input = request
.input()
.iter()
.cloned()
.filter_map(sanitize_cross_account_item)
.collect();
request.set_input(input);
}
pub(crate) fn sanitize_cross_account_item(mut item: Value) -> Option<Value> {
if let Value::Object(object) = &mut item {
if matches!(
object.get("type").and_then(Value::as_str),
Some("compaction" | "compaction_summary" | "context_compaction")
) {
return None;
}
object.remove("id");
object.remove("encrypted_content");
}
Some(item)
}
fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option<String> {
@@ -10,13 +10,13 @@ use std::{
time::Duration,
};
use tokio::{sync::Semaphore, time::Instant};
use tokio::sync::Semaphore;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use uuid::Uuid;
use self::state::{
WebSocketPoolConnecting, WebSocketPoolSlot, WebSocketPoolState, account_slot_count,
close_pooled_connection, close_pooled_connections, take_lru_idle_connection,
WebSocketPoolConnecting, WebSocketPoolSlot, WebSocketPoolState, close_pooled_connection,
close_pooled_connections,
};
use super::pump::PumpKeepalive;
@@ -31,8 +31,6 @@ pub(crate) use self::{
},
};
const DEFAULT_MAX_PER_ACCOUNT: usize = 8;
const DEFAULT_MAX_TOTAL: usize = 64;
const DEFAULT_MAX_CONNECTING: usize = 16;
const DEFAULT_MAX_AGE: Duration = Duration::from_mins(55);
const DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(25);
@@ -64,10 +62,6 @@ pub struct CodexWebSocketPoolConfig {
pub enabled: bool,
/// 单个 socket 的最大生命周期。
pub max_age: Duration,
/// 单个账号允许占用的最大池 slot 数。
pub max_per_account: usize,
/// 所有账号合计允许占用的最大池 slot 数。
pub max_total: usize,
/// 所有账号合计允许并发执行的 opening 数。
pub max_connecting: usize,
/// 后台维护间隔;`None` 表示不启动后台任务。
@@ -87,8 +81,6 @@ impl Default for CodexWebSocketPoolConfig {
Self {
enabled: true,
max_age: DEFAULT_MAX_AGE,
max_per_account: DEFAULT_MAX_PER_ACCOUNT,
max_total: DEFAULT_MAX_TOTAL,
max_connecting: DEFAULT_MAX_CONNECTING,
maintenance_interval: Some(DEFAULT_MAINTENANCE_INTERVAL),
ping_interval: Some(DEFAULT_PING_INTERVAL),
@@ -113,10 +105,9 @@ impl CodexWebSocketPoolConfig {
}
impl CodexWebSocketPool {
/// 构造连接池策略和状态。
pub fn new(max_per_account: usize, max_age: Duration) -> Self {
/// 构造不限制累计 slot 数的连接池策略和状态。
pub fn new(max_age: Duration) -> Self {
Self::with_config(CodexWebSocketPoolConfig {
max_per_account: max_per_account.max(1),
max_age,
maintenance_interval: None,
ping_interval: None,
@@ -218,43 +209,25 @@ impl CodexWebSocketPool {
let acquire = if required_response_id.is_some() {
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::ContinuationNotFound)
} else if self.config.max_per_account == 0
|| account_slot_count(&state.slots, key.account_id()) >= self.config.max_per_account
{
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
} else {
let connect_permit = self.connect_semaphore.clone().try_acquire_owned().ok();
match connect_permit {
Some(connect_permit) => {
let has_total_capacity = if self.config.max_total == 0 {
false
} else if state.slots.len() < self.config.max_total {
true
} else if let Some(connection) = take_lru_idle_connection(&mut state) {
connections_to_close.push(connection);
true
} else {
false
};
if has_total_capacity {
let lease = WebSocketPoolConnectLease::reserve(
self.clone(),
key.clone(),
connect_permit,
);
state.slots.insert(
key,
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
id: lease.id,
started_at: lease.started_at,
outcome: lease.outcome.clone(),
cancellation: lease.cancellation.clone(),
}),
);
WebSocketPoolAcquire::Connect(lease)
} else {
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
}
let lease = WebSocketPoolConnectLease::reserve(
self.clone(),
key.clone(),
connect_permit,
);
state.slots.insert(
key,
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
id: lease.id,
started_at: lease.started_at,
outcome: lease.outcome.clone(),
cancellation: lease.cancellation.clone(),
}),
);
WebSocketPoolAcquire::Connect(lease)
}
None => WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap),
}
@@ -292,7 +265,6 @@ impl CodexWebSocketPool {
key.clone(),
WebSocketPoolSlot::Idle {
connection: Box::new(connection),
last_used_at: Instant::now(),
},
);
}
@@ -108,7 +108,6 @@ impl WebSocketContinuationState {
pub(super) enum WebSocketPoolSlot {
Idle {
connection: Box<PooledWebSocketConnection>,
last_used_at: Instant,
},
Busy(WebSocketPoolReservation),
Connecting(WebSocketPoolConnecting),
@@ -138,34 +137,6 @@ pub(super) struct WebSocketPoolReservation {
pub(super) latest_response_id: Option<String>,
}
pub(super) fn account_slot_count(
slots: &HashMap<CodexWebSocketPoolKey, WebSocketPoolSlot>,
account_id: &str,
) -> usize {
slots
.keys()
.filter(|key| key.account_id() == account_id)
.count()
}
pub(super) fn take_lru_idle_connection(
state: &mut WebSocketPoolState,
) -> Option<PooledWebSocketConnection> {
let key = state
.slots
.iter()
.filter_map(|(key, slot)| match slot {
WebSocketPoolSlot::Idle { last_used_at, .. } => Some((key, *last_used_at)),
WebSocketPoolSlot::Busy(_) | WebSocketPoolSlot::Connecting(_) => None,
})
.min_by_key(|(_, last_used_at)| *last_used_at)
.map(|(key, _)| key.clone())?;
let Some(WebSocketPoolSlot::Idle { connection, .. }) = state.slots.remove(&key) else {
return None;
};
Some(*connection)
}
pub(super) async fn close_pooled_connection(connection: PooledWebSocketConnection) {
connection.websocket.close().await;
}
+153 -4
View File
@@ -1,11 +1,11 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use futures::{StreamExt, future::BoxFuture};
use gateway_admin::model::accounts::AccountRecord;
use gateway_admin::model::observability::{
CurrencyCost, DesktopReleaseStatus, ProviderBillingInput,
@@ -23,7 +23,12 @@ use gateway_core::engine::credential::{
CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
ProviderAccountStore, QuotaAccessChange, QuotaEvidence, QuotaObservation, QuotaState,
};
use gateway_core::engine::provider::ProviderRequest;
use gateway_core::engine::{
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext,
};
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
@@ -32,7 +37,11 @@ use gateway_core::provider_ports::{
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
ProviderModel, PublicModelId, RoutingContext, RuntimeAccount, RuntimeAccountDirectory,
RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential};
@@ -46,9 +55,14 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{
MemoryAccountStore, MemorySessionAffinity, MemorySessionExclusions, TestLeaseCoordinator,
profile, secret,
account_policy, profile, secret,
};
const COMPLETED_SESSION_SSE: &str = concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_initialized_session\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
);
#[tokio::test]
async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions_once() {
let config = valid_config();
@@ -97,6 +111,84 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
assert!(bundle.take_worker_contributions().is_empty());
}
#[tokio::test]
async fn initialized_provider_keeps_thread_spawn_transport_conversations_distinct() {
let account_id = "acct_initialized_thread_spawn";
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
name: account_id.to_owned(),
secret: secret("at-initialized-thread-spawn"),
verified_account: profile("chatgpt-initialized-thread-spawn"),
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_SESSION_SSE),
)
.expect(2)
.mount(&server)
.await;
let mut config = valid_config();
config.config.api.base_url = server.uri();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
)
.await
.expect("initialized OpenAI provider");
let provider = bundle.core_provider();
let thread_spawn = r#"{"subagent_kind":"thread_spawn"}"#;
let mut conversation_ids = Vec::new();
for (request_id, thread_id) in [
("req_initialized_thread_spawn_first", "child-one"),
("req_initialized_thread_spawn_second", "child-two"),
] {
let payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("child task")),
("session_id".to_owned(), json!("parent-session")),
("thread_id".to_owned(), json!(thread_id)),
("turnMetadata".to_owned(), json!(thread_spawn)),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
let mut stream = provider
.execute(
initialized_provider_request(operation, account_id),
initialized_attempt_context(request_id, account_id),
)
.await
.expect("prepare child provider stream");
let mut conversation_id = None;
while let Some(event) = stream.next().await {
let event = event.expect("child provider response");
if let Some(update) = event.session_update() {
conversation_id = update
.payload()
.get("conversation_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
}
conversation_ids.push(conversation_id.expect("child transport conversation id"));
}
assert_ne!(conversation_ids[0], conversation_ids[1]);
}
#[tokio::test]
async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() {
let config = valid_config();
@@ -921,6 +1013,63 @@ fn reset_credit_command(account_id: ProviderAccountId) -> ConsumeProviderResetCr
}
}
fn initialized_provider_request(operation: Operation, account_id: &str) -> ProviderRequest {
let provider = ProviderKind::new("openai").expect("provider");
let upstream_model = UpstreamModelId::new("gpt-5.4").expect("upstream model");
let public_model = PublicModelId::new(upstream_model.as_str()).expect("public model");
let account_scope = initialized_account_scope(account_id);
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
account_policy(),
vec![provider.clone()],
vec![ProviderModel::new(
provider,
upstream_model,
ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000))
.with_upstream_feature_validation(),
)],
Vec::new(),
)
.expect("runtime snapshot");
let plan = snapshot
.plan(
&public_model,
&operation,
account_scope,
&RoutingContext::default(),
)
.expect("routing plan");
ProviderRequest::new(operation, plan.candidates()[0].clone())
}
fn initialized_attempt_context(request_id: &str, account_id: &str) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).expect("request id"),
ClientApiKeyId::new("key_openai_initialized").expect("client key id"),
),
NonZeroU32::new(1).expect("attempt"),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(initialized_account_scope(account_id)),
None,
CancellationToken::new(),
)
}
fn initialized_account_scope(account_id: &str) -> Arc<FrozenAccountScope> {
let provider = ProviderKind::new("openai").expect("provider");
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
ProviderAccountId::new(account_id).expect("account id"),
RuntimeAccount::new(provider, BTreeSet::new()),
)]))),
ClientRoutingScope::all_accounts(),
))
}
fn provider_ports() -> ProviderStorePorts {
provider_ports_with(
Arc::new(MemoryAccountStore::default()),
@@ -101,8 +101,6 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
config.api.base_url.as_str(),
config.ws_pool.enabled,
config.ws_pool.max_age_ms,
config.ws_pool.max_per_account,
config.ws_pool.max_total,
config.ws_pool.max_connecting,
config.ws_pool.initial_event_timeout_ms,
config.quota.refresh_interval_minutes,
@@ -115,8 +113,6 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
true,
3_300_000,
8,
64,
8,
20_000,
15,
true,
@@ -59,6 +59,48 @@ async fn direct_import_persists_opaque_tokens_without_profile_or_token_validatio
assert!(account.account.next_refresh_at().is_none());
}
#[tokio::test]
async fn direct_import_accepts_snake_case_oauth_token_aliases() {
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let prepared = service
.prepare_import_document(serde_json::json!({
"accounts": [{
"platform": "openai",
"type": "oauth",
"credentials": {
"access_token": "snake-access-token",
"refresh_token": "snake-refresh-token",
"id_token": "snake-id-token"
}
}]
}))
.await
.expect("snake_case OAuth token aliases must be accepted");
let account = prepared.accounts().first().expect("one prepared account");
let runtime = CodexCredentialCodec::decode(&account.credential).expect("stored credential");
let oauth = runtime.authentication.oauth().expect("OAuth credential");
assert_eq!(
(
oauth.access_token.expose_secret(),
oauth
.refresh_token
.as_ref()
.map(|token| token.expose_secret()),
oauth.id_token.as_ref().map(|token| token.expose_secret()),
),
(
"snake-access-token",
Some("snake-refresh-token"),
Some("snake-id-token"),
)
);
}
#[tokio::test]
async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refresh_margin() {
let expires_at = Utc
@@ -445,6 +445,74 @@ fn selector_round_robin_cursor_advances_across_requests() {
);
}
#[tokio::test]
async fn selector_should_claim_the_initial_session_account_before_upstream_send() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_first", "at-first");
create_account(&store, "acct_second", "at-second");
let affinity = Arc::new(MemorySessionAffinity::default());
let selector = selector_with_affinity(
&store,
Arc::new(TestLeaseCoordinator::default()),
Arc::clone(&affinity),
);
let provider = ProviderKind::new("openai").expect("provider");
let key = ProviderSessionAffinityKey::try_new("initial-claim").expect("affinity key");
let request_attempt = attempt(BTreeSet::new());
let selected = selector
.select(&SelectCodexCredential {
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
attempt: &request_attempt,
session_affinity_key: Some(&key),
})
.await
.expect("select initial account");
assert_eq!(
affinity
.load(&provider, &key)
.await
.expect("load claimed affinity"),
Some(selected.account_id().clone())
);
}
#[tokio::test]
async fn record_success_should_not_overwrite_a_newer_session_winner() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_first", "at-first");
create_account(&store, "acct_second", "at-second");
let affinity = Arc::new(MemorySessionAffinity::default());
let selector = selector_with_affinity(
&store,
Arc::new(TestLeaseCoordinator::default()),
Arc::clone(&affinity),
);
let provider = ProviderKind::new("openai").expect("provider");
let key = ProviderSessionAffinityKey::try_new("late-success").expect("affinity key");
let first = store.account("acct_first").expect("first account");
let second = ProviderAccountId::new("acct_second").expect("second account");
affinity
.bind(&provider, &key, &second, Duration::from_secs(60))
.await
.expect("seed newer affinity");
selector
.record_success(&first, Some(&key), first.id())
.await;
assert_eq!(
affinity
.load(&provider, &key)
.await
.expect("load preserved affinity"),
Some(second)
);
}
#[tokio::test]
async fn selector_should_reuse_the_account_bound_to_the_same_session() {
let store = Arc::new(MemoryAccountStore::default());
@@ -469,7 +537,9 @@ async fn selector_should_reuse_the_account_bound_to_the_same_session() {
})
.await
.expect("select first account");
selector.record_success(first.account(), Some(&key)).await;
selector
.record_success(first.account(), Some(&key), first.account_id())
.await;
let first_account = first.account_id().clone();
let second_attempt = attempt(BTreeSet::new());
@@ -535,7 +605,7 @@ async fn selector_should_replace_a_busy_affinity_binding_after_the_fallback_succ
.await
.expect("select fallback account");
selector
.record_success(selected.account(), Some(&key))
.record_success(selected.account(), Some(&key), &bound)
.await;
assert_eq!(
@@ -1005,7 +1075,7 @@ fn successful_upstream_response_recovers_non_quota_terminal_states() {
let current = store.account("acct_primary").expect("stale account");
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
block_on(selector.record_success(&current, None));
block_on(selector.record_success(&current, None, current.id()));
assert_eq!(
store
@@ -1289,7 +1359,7 @@ fn cloudflare_challenge_does_not_change_persisted_account_facts() {
.credential_state(),
CredentialState::Ready
);
block_on(selector.record_success(lease.account(), None));
block_on(selector.record_success(lease.account(), None, lease.account_id()));
}
#[test]
@@ -344,6 +344,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_local_affinity",
"acct_metadata_new",
"acct_metadata_old",
"acct_unknown_continuation",
"acct_unknown_turn_state",
"acct_prefetch_limit",
"acct_presentation",
"acct_provider_contract",
@@ -352,7 +354,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_scope_same",
"acct_semantic_failure",
"acct_session_affinity",
"acct_subagent",
"acct_subagent_a",
"acct_subagent_b",
"acct_success_exhausted",
"acct_thread_spawn_affinity",
"acct_truncated_stream",
@@ -456,6 +459,24 @@ fn replay_any_context(request_id: &str, owner_account_id: &str) -> AttemptContex
.with_continuation_attempt(ContinuationAttempt::ReplayAny)
}
fn external_continuation_context(request_id: &str) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).expect("request id"),
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
),
NonZeroU32::new(1).expect("attempt"),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
Some(ContinuationBinding::External(PreviousResponseId::new(
"external-previous-response",
))),
CancellationToken::new(),
)
}
async fn capture_scoped_http_request(
request_id: &str,
selected_account_id: &str,
@@ -820,13 +841,13 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
let cases = [
(
ImageRequestKind::Generation,
"/images/generations",
"/codex/images/generations",
br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "background":"transparent", "future_option":{"schema":2}, "future_integer":9007199254740993 }"#.as_slice(),
br#"{ "created": 1787212800, "data": [{"b64_json":"AAEC"}], "future": 9007199254740993 }"#.as_slice(),
),
(
ImageRequestKind::Edit,
"/images/edits",
"/codex/images/edits",
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate remains opaque"}"#.as_slice(),
br#"{"created":1787212801,"data":[{"b64_json":"AwQF"}],"quality":"high"}"#.as_slice(),
),
@@ -918,7 +939,7 @@ async fn image_endpoint_returns_the_exact_upstream_error_response() {
br#"{ "model":"gpt-image-2", "images":[], "future_invalid":9007199254740993 }"#;
let response_body = br#"{ "error":{"message":"future image validation","type":"image_error","code":"future_code"}, "future":9007199254740993 }"#;
Mock::given(method("POST"))
.and(path("/images/edits"))
.and(path("/codex/images/edits"))
.and(body_bytes(request_body.to_vec()))
.respond_with(
ResponseTemplate::new(422)
@@ -1319,6 +1340,53 @@ async fn cross_account_scope_removes_only_account_bound_body_fields() {
);
}
#[tokio::test]
async fn cross_account_full_replay_should_preserve_the_complete_transcript() {
let transcript = json!([
{
"type": "reasoning",
"id": "reasoning-item-id",
"encrypted_content": "reasoning-ciphertext",
"summary": []
},
{
"type": "compaction",
"id": "compaction-item-id",
"encrypted_content": "compaction-ciphertext"
},
{
"type": "agent_message",
"id": "agent-message-id",
"content": [
{"type": "output_text", "text": "visible"},
{"type": "encrypted_content", "encrypted_content": "nested-ciphertext"}
]
},
{
"type": "function_call",
"id": "function-call-id",
"call_id": "call-id",
"name": "tool",
"arguments": "{}",
"encrypted_function_args": "function-args-ciphertext"
}
]);
let request = capture_scoped_http_request(
"req_scope_transcript",
"acct_scope_new",
"acct_scope_old",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), transcript.clone()),
]),
Map::new(),
)
.await;
let body: Value = captured_request_body(&request);
assert_eq!(body.get("input"), Some(&transcript));
}
#[tokio::test]
async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
let request = capture_scoped_http_request(
@@ -1359,40 +1427,19 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
}
#[tokio::test]
async fn cross_account_continuation_probe_should_preserve_delta_input_and_previous_response() {
async fn cross_account_continuation_should_require_client_replay_without_an_upstream_probe() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_scope_new").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
let delta_input = json!([
{
"type": "message",
"id": "delta-message-id",
"encrypted_content": "opaque-delta-ciphertext",
"role": "user",
"content": "current turn only"
},
{
"type": "compaction",
"id": "delta-compaction-id",
"encrypted_content": "opaque-compaction-ciphertext"
}
]);
let generation = GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), delta_input.clone()),
(
"input".to_owned(),
json!([{"role": "user", "content": "delta"}]),
),
(
"previous_response_id".to_owned(),
json!("client-previous-response"),
@@ -1432,41 +1479,137 @@ async fn cross_account_continuation_probe_should_preserve_delta_input_and_previo
)
.expect("provider session state"),
);
let mut stream = provider_with_base_url(&store, server.uri())
let result = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", Operation::Generate(generation)),
replay_any_context("req_cross_account_probe", "acct_scope_old"),
)
.await;
let Err(error) = result else {
panic!("cross-account continuation must require a full client replay");
};
let detail = error
.client_visible_upstream_error()
.expect("client replay error detail");
let requests = server
.received_requests()
.await
.expect("prepare continuation probe stream");
.expect("captured upstream requests");
assert_eq!(
(
error.kind(),
error.send_state(),
error.continuation_failure(),
detail.code(),
detail.error_type(),
),
(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
Some(ContinuationFailure::HistoryUnavailable),
Some("previous_response_not_found"),
Some("invalid_request_error"),
)
);
assert!(requests.is_empty());
}
#[tokio::test]
async fn missing_affinity_continuation_should_require_client_replay_without_an_upstream_probe() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_unknown_continuation").await;
let server = MockServer::start().await;
let generation = GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
(
"input".to_owned(),
json!([{"role": "user", "content": "delta"}]),
),
(
"previous_response_id".to_owned(),
json!("external-previous-response"),
),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([
("use_websocket".to_owned(), json!(false)),
("session_id".to_owned(), json!("expired-affinity-session")),
])),
);
let result = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", Operation::Generate(generation)),
external_continuation_context("req_unknown_continuation"),
)
.await;
let Err(error) = result else {
panic!("unowned continuation must require a full client replay");
};
let requests = server
.received_requests()
.await
.expect("captured upstream requests");
assert_eq!(
error
.client_visible_upstream_error()
.and_then(|detail| detail.code()),
Some("previous_response_not_found")
);
assert!(requests.is_empty());
}
#[tokio::test]
async fn missing_affinity_full_request_should_clear_unowned_turn_state() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_unknown_turn_state").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
let generation = GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("full transcript")),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([
("use_websocket".to_owned(), json!(false)),
("session_id".to_owned(), json!("expired-turn-state-session")),
("turn_id".to_owned(), json!("turn-after-affinity-expiry")),
("turn_state".to_owned(), json!("unowned-turn-state")),
])),
);
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", Operation::Generate(generation)),
context("req_unknown_turn_state", CancellationToken::new()),
)
.await
.expect("prepare full replay stream");
while let Some(event) = stream.next().await {
event.expect("continuation probe response");
event.expect("full replay response");
}
let requests = server
.received_requests()
.await
.expect("captured continuation probe request");
let body: Value = captured_request_body(&requests[0]);
.expect("captured full replay request");
assert_eq!(body.get("input"), Some(&delta_input));
assert_eq!(
body.get("previous_response_id"),
Some(&json!("upstream-previous-response"))
);
assert!(body.get("authorization").is_none());
assert!(body.get("turnMetadata").is_none());
assert!(
body.pointer("/client_metadata/x-codex-turn-state")
.is_none()
);
assert!(
body.pointer("/client_metadata/x-codex-turn-metadata")
.is_none()
);
assert_eq!(
body.pointer("/client_metadata/future"),
Some(&json!("keep"))
);
assert!(captured_header_values(&requests[0], "x-codex-turn-state").is_empty());
}
@@ -1637,9 +1780,10 @@ async fn prompt_cache_key_should_become_an_opaque_session_affinity_lookup_key()
}
#[tokio::test]
async fn subagent_requests_should_use_an_affinity_key_isolated_from_the_root_session() {
async fn subagent_requests_should_share_the_root_session_account_affinity_key() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_subagent").await;
create_account(&store, "acct_subagent_a").await;
create_account(&store, "acct_subagent_b").await;
let affinity = Arc::new(MemorySessionAffinity::default());
let server = MockServer::start().await;
Mock::given(method("POST"))
@@ -1689,10 +1833,25 @@ async fn subagent_requests_should_use_an_affinity_key_isolated_from_the_root_ses
let keys = affinity.lookup_keys();
assert_eq!(keys.len(), 3);
assert_ne!(keys[0], keys[1], "subagent must not inherit the root key");
assert_eq!(
keys[1], keys[2],
"the same subagent should retain its own key"
assert!(
keys.iter().all(|key| key == &keys[0]),
"root and derived subagent requests must prefer the same account"
);
let requests = server
.received_requests()
.await
.expect("captured root and subagent requests");
let selected_accounts = requests
.iter()
.filter_map(|request| request.headers.get("chatgpt-account-id"))
.filter_map(|value| value.to_str().ok())
.collect::<Vec<_>>();
assert_eq!(selected_accounts.len(), 3);
assert!(
selected_accounts
.iter()
.all(|account| account == &selected_accounts[0]),
"root and subagents should route to the same preferred account"
);
}
@@ -1867,7 +2026,7 @@ async fn affinity_quota_switch_should_clear_old_turn_state_without_a_provider_st
}
#[tokio::test]
async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
async fn thread_spawn_children_should_share_the_parent_account_affinity_key() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_thread_spawn_affinity").await;
let affinity = Arc::new(MemorySessionAffinity::default());
@@ -1912,10 +2071,10 @@ async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
let keys = affinity.lookup_keys();
assert_eq!(keys.len(), 5);
assert_ne!(keys[0], keys[1]);
assert_eq!(keys[0], keys[2]);
assert_ne!(keys[3], keys[4]);
assert_ne!(keys[0], keys[3]);
assert!(
keys.iter().all(|key| key == &keys[0]),
"child thread transport identities must not split parent account affinity"
);
}
#[tokio::test]
@@ -2539,7 +2698,7 @@ async fn bare_response_failed_should_remain_an_atomic_replay_safe_failure() {
}
#[tokio::test]
async fn previous_response_not_found_should_remain_client_visible_without_internal_replay_proof() {
async fn same_account_previous_response_not_found_should_remain_client_visible() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_client_history").await;
let server = MockServer::start().await;
@@ -2578,7 +2737,7 @@ async fn previous_response_not_found_should_remain_client_visible_without_intern
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
),
context("req_history_missing", CancellationToken::new()),
context_with_state_owner("req_history_missing", "acct_client_history"),
)
.await
.expect("prepare missing-history stream");
@@ -678,6 +678,55 @@ impl ProviderSessionAffinityPort for MemorySessionAffinity {
})
}
fn claim_or_load<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
candidate_account_id: &'a ProviderAccountId,
_ttl: Duration,
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move {
let mut bindings = self.bindings.lock().expect("session affinity lock");
Ok(bindings
.entry((
provider_kind.as_str().to_owned(),
key.expose_to_store().to_owned(),
))
.or_insert_with(|| candidate_account_id.clone())
.clone())
})
}
fn compare_and_bind<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
expected_account_id: &'a ProviderAccountId,
replacement_account_id: &'a ProviderAccountId,
_ttl: Duration,
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move {
let binding_key = (
provider_kind.as_str().to_owned(),
key.expose_to_store().to_owned(),
);
let mut bindings = self.bindings.lock().expect("session affinity lock");
if bindings
.get(&binding_key)
.is_none_or(|current| current == expected_account_id)
{
bindings.insert(binding_key, replacement_account_id.clone());
return Ok(replacement_account_id.clone());
}
bindings.get(&binding_key).cloned().ok_or_else(|| {
ProviderStoreError::new(
gateway_core::provider_ports::ProviderStoreErrorKind::Unavailable,
"resolve in-memory provider session affinity",
)
})
})
}
fn clear<'a>(
&'a self,
provider_kind: &'a ProviderKind,
@@ -195,7 +195,7 @@ fn bare_http_429_remains_a_temporary_rate_limit() {
}
#[test]
fn sub2api_openai_account_failure_matrix_is_preserved() {
fn openai_account_failure_matrix_is_preserved() {
let cases = [
(
400,
@@ -265,7 +265,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
format!("http://{address}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let response = backend
.create_response(
@@ -480,7 +480,7 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
format!("http://{address}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
client
.create_response(
@@ -932,7 +932,7 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la
});
let profile = test_wire_profile();
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let client = CodexBackendClient::new(
reqwest::Client::builder()
.no_proxy()
@@ -300,7 +300,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -354,7 +354,7 @@ async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() {
.unwrap();
assert_eq!(read, 0);
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -733,7 +733,7 @@ async fn concurrent_multi_conversation_continuations_should_select_each_exact_so
tokio::join!(first_continuation, second_continuation);
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1190,7 +1190,7 @@ async fn cancelled_half_open_opening_should_allow_another_probe() {
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1325,7 +1325,7 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
write_completed_sse_response(&mut second_http).await;
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -495,8 +495,6 @@ fn websocket_pool_config_for_tests(
CodexWebSocketPoolConfig {
enabled: true,
max_age: Duration::from_mins(1),
max_per_account: 8,
max_total: 64,
max_connecting: 16,
maintenance_interval,
ping_interval,
@@ -126,7 +126,7 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_
websocket.send(Message::Text(payload.into())).await.unwrap();
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -932,7 +932,7 @@ async fn codex_backend_client_should_not_reuse_websocket_rate_limit_headers() {
.unwrap();
websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1914,7 +1914,7 @@ async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
request.set_previous_response_id(Some("resp_previous".to_string()));
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -35,7 +35,7 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
}
websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -97,7 +97,7 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
format!("http://{addr}"),
profile.clone(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(1, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let first_request = pooled_websocket_request("wire-profile-continuation");
let first = backend
.create_response(
@@ -401,7 +401,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_
}
#[tokio::test]
async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
async fn codex_backend_client_stream_should_use_http_when_connecting_limit_is_exhausted() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
@@ -414,7 +414,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
write_completed_sse_response(&mut first_stream).await;
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: 0,
max_connecting: 0,
initial_event_timeout: Some(Duration::from_millis(30)),
..websocket_pool_config_for_tests(None, None, None)
}));
@@ -432,7 +432,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
request_context("req_structural_bypass", Some("chatgpt-account")),
)
.await
.expect("pool cap should select HTTP before sending payload");
.expect("exhausted connection-opening limit should select HTTP before sending payload");
assert_eq!(response.transport, CodexBackendTransport::HttpSse);
assert_eq!(
response.transport_metrics.decision,
@@ -472,7 +472,7 @@ async fn codex_backend_client_should_not_reuse_pooled_websocket_across_local_acc
websocket.close(None).await.unwrap();
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -841,140 +841,32 @@ async fn websocket_pool_shutdown_should_cancel_and_join_active_stream() {
}
#[tokio::test]
async fn websocket_pool_should_use_http_for_new_keys_after_account_cap() {
async fn websocket_pool_should_keep_sixty_fifth_conversation_on_same_account_on_websocket() {
const REQUEST_COUNT: usize = 65;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
let accepted_connections_for_server = Arc::clone(&accepted_connections);
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.unwrap();
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
let mut first_websocket = accept_codex_test_websocket(first_stream).await;
let _first_message = first_websocket.next().await.unwrap().unwrap();
first_websocket
.send(Message::Text(
completed_websocket_response("resp_cap_first", 2, 1).into(),
))
.await
.unwrap();
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let mut websockets = Vec::with_capacity(REQUEST_COUNT);
for index in 0..REQUEST_COUNT {
let (stream, _) = listener.accept().await.unwrap();
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
let request = read_http_request(&mut stream).await;
assert!(request.starts_with("POST /codex/responses HTTP/1.1"));
write_completed_sse_response(&mut stream).await;
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
completed_websocket_response(&format!("resp_same_account_{index}"), 2, 1)
.into(),
))
.await
.unwrap();
websockets.push(websocket);
}
drop(first_websocket);
});
let pool = Arc::new(CodexWebSocketPool::new(1, Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(pool);
let first_request = pooled_websocket_request("conversation-cap-one");
let second_request = pooled_websocket_request("conversation-cap-two");
let first = backend
.create_response(
&first_request,
request_context("req_cap_first", Some("chatgpt-account")),
)
.await
.expect("first capped websocket response should succeed");
let second = backend
.create_response(
&second_request,
request_context("req_cap_second", Some("chatgpt-account")),
)
.await
.expect("new key over account cap should use HTTP");
let third = backend
.create_response(
&second_request,
request_context("req_cap_third", Some("chatgpt-account")),
)
.await
.expect("capped key should keep using HTTP instead of opening one-shot sockets");
server.await.unwrap();
assert!(first.body.contains("resp_cap_first"));
assert!(second.body.contains("response.completed"));
assert!(third.body.contains("response.completed"));
assert_eq!(first.websocket_pool_decision.unwrap().kind(), "new");
assert_eq!(
second.transport_metrics.decision,
Some(CodexTransportDecision::Http2PoolUnavailable)
);
assert_eq!(
third.transport_metrics.decision,
Some(CodexTransportDecision::Http2PoolUnavailable)
);
assert_eq!(accepted_connections.load(Ordering::SeqCst), 3);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_concurrent_openings_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(8, 2).await;
assert_eq!((websockets, http), (2, 6));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_total_slots_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(3, 8).await;
assert_eq!((websockets, http), (3, 5));
}
#[tokio::test]
async fn websocket_pool_should_evict_lru_idle_connection_at_total_cap() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.unwrap();
let mut first = accept_codex_test_websocket(first_stream).await;
let _ = first.next().await.unwrap().unwrap();
first
.send(Message::Text(
completed_websocket_response("resp_global_lru_first", 2, 1).into(),
))
.await
.unwrap();
let (second_stream, _) = listener.accept().await.unwrap();
let mut second = accept_codex_test_websocket(second_stream).await;
let _ = second.next().await.unwrap().unwrap();
second
.send(Message::Text(
completed_websocket_response("resp_global_lru_second", 2, 1).into(),
))
.await
.unwrap();
let close = timeout(Duration::from_secs(2), first.next())
.await
.expect("global LRU eviction should close the oldest idle socket")
.expect("oldest idle socket should emit a close frame")
.expect("oldest idle socket close should be valid");
std::assert_matches!(close, Message::Close(_));
let (third_stream, _) = listener.accept().await.unwrap();
let mut third = accept_codex_test_websocket(third_stream).await;
let _ = third.next().await.unwrap().unwrap();
third
.send(Message::Text(
completed_websocket_response("resp_global_lru_third", 2, 1).into(),
))
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: 8,
max_total: 2,
max_connecting: 2,
max_connecting: 1,
..websocket_pool_config_for_tests(None, None, None)
}));
let backend = CodexBackendClient::new(
@@ -983,36 +875,52 @@ async fn websocket_pool_should_evict_lru_idle_connection_at_total_cap() {
test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut responses = Vec::new();
for index in 0..3 {
let request = pooled_websocket_request(&format!("conversation-global-lru-{index}"));
let account_id = format!("account-global-lru-{index}");
let request_id = format!("req_global_lru_{index}");
let mut responses = Vec::with_capacity(REQUEST_COUNT);
for index in 0..REQUEST_COUNT {
let request = pooled_websocket_request(&format!("conversation-same-account-{index}"));
responses.push(
backend
.create_response(&request, request_context(&request_id, Some(&account_id)))
.create_response(
&request,
request_context(
&format!("req_same_account_{index}"),
Some("chatgpt-account"),
),
)
.await
.expect("global LRU request should keep using websocket"),
.expect("same-account conversation should use websocket"),
);
}
server.await.unwrap();
assert!(responses[0].body.contains("resp_global_lru_first"));
assert!(responses[1].body.contains("resp_global_lru_second"));
assert!(responses[2].body.contains("resp_global_lru_third"));
assert!(
responses
.iter()
.all(|response| response.transport == CodexBackendTransport::WebSocket)
assert_eq!(
(
responses
.iter()
.filter(|response| response.transport == CodexBackendTransport::WebSocket)
.count(),
accepted_connections.load(Ordering::SeqCst),
),
(REQUEST_COUNT, REQUEST_COUNT)
);
pool.shutdown().await;
}
async fn concurrent_pool_transport_counts(
max_total: usize,
max_connecting: usize,
) -> (usize, usize) {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_concurrent_openings_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(2).await;
assert_eq!((websockets, http), (2, 6));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_allow_all_openings_when_connect_permits_are_available() {
let (websockets, http) = concurrent_pool_transport_counts(8).await;
assert_eq!((websockets, http), (8, 0));
}
async fn concurrent_pool_transport_counts(max_connecting: usize) -> (usize, usize) {
const REQUEST_COUNT: usize = 8;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -1051,7 +959,7 @@ async fn concurrent_pool_transport_counts(
websocket
.send(Message::Text(
completed_websocket_response(
&format!("resp_global_cap_{max_total}_{max_connecting}_{index}"),
&format!("resp_connecting_cap_{max_connecting}_{index}"),
2,
1,
)
@@ -1063,8 +971,6 @@ async fn concurrent_pool_transport_counts(
(websocket_count, http_count)
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: REQUEST_COUNT,
max_total,
max_connecting,
..websocket_pool_config_for_tests(None, None, None)
}));
@@ -1096,7 +1002,7 @@ async fn concurrent_pool_transport_counts(
let response = request
.await
.unwrap()
.expect("globally capped request should complete");
.expect("concurrent request should complete");
if response.transport == CodexBackendTransport::WebSocket {
websocket_responses += 1;
}
@@ -1910,7 +1816,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_error_termin
.unwrap();
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1987,7 +1893,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_unknown_resp
.unwrap();
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -237,19 +237,19 @@ impl GrokCredentialAdmin {
let mut secret = decode_secret(&loaded.credential)?;
let mut credentials = serde_json::Map::new();
credentials.insert(
"access_token".to_owned(),
"accessToken".to_owned(),
Value::String(std::mem::take(&mut secret.access_token)),
);
credentials.insert(
"refresh_token".to_owned(),
"refreshToken".to_owned(),
Value::String(std::mem::take(&mut secret.refresh_token)),
);
if let Some(id_token) = secret.id_token.take() {
credentials.insert("id_token".to_owned(), Value::String(id_token));
credentials.insert("idToken".to_owned(), Value::String(id_token));
}
credentials.insert("token_type".to_owned(), Value::String("Bearer".to_owned()));
credentials.insert("tokenType".to_owned(), Value::String("Bearer".to_owned()));
credentials.insert(
"expires_at".to_owned(),
"expiresAt".to_owned(),
Value::String(
loaded
.account
@@ -260,11 +260,11 @@ impl GrokCredentialAdmin {
),
);
credentials.insert(
"base_url".to_owned(),
"baseUrl".to_owned(),
Value::String(GROK_CLI_BASE_URL.to_owned()),
);
credentials.insert(
"client_id".to_owned(),
"clientId".to_owned(),
Value::String(OFFICIAL_CLIENT_ID.to_owned()),
);
credentials.insert(
@@ -290,7 +290,7 @@ impl GrokCredentialAdmin {
Ok(GrokAccountExport(serde_json::json!({
"version": 1,
"type": "oauth-account-bundle",
"exported_at": exported_at.to_rfc3339(),
"exportedAt": exported_at.to_rfc3339(),
"accounts": exported_accounts,
"proxies": [],
})))
+1 -1
View File
@@ -2076,7 +2076,7 @@ const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
const QUOTA_CATALOG_INTERVAL: Duration = Duration::from_secs(5 * 60);
// rolling 24h 描述的是上游用量窗口,不代表从本次观测起封禁 24 小时;
// 缺少可信 reset 时间时按 sub2api 的短周期探测策略恢复检查。
// 缺少可信 reset 时间时按短周期探测策略恢复检查。
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(10 * 60);
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = QUOTA_CATALOG_INTERVAL;
const CLI_RELEASE_WORKER_OWNER: &str = "xai-cli-release";
@@ -108,7 +108,7 @@ impl GrokCompactionRequest {
self.reasoning_replay_session_id.as_deref()
}
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
/// 返回归一化后的 xAI wire 模型。
pub(crate) fn upstream_model(&self) -> Option<&str> {
self.body.get("model").and_then(Value::as_str)
}
@@ -286,8 +286,7 @@ pub(crate) fn compaction_wire_events(
Value::Null,
);
let item = compaction_item(summary, encrypted_content);
// sub2api 的 compact contract 以真实密文为成功依据,不向客户端延续
// 摘要生成阶段的 incomplete 状态。
// compact 成功以真实密文为依据,不向客户端延续摘要生成阶段的 incomplete 状态。
let terminal_response = compaction_response(
completed,
terminal_source.or(created_source),
@@ -91,7 +91,7 @@ impl GrokResponsesRequest {
self.reasoning_replay_session_id.as_deref()
}
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
/// 返回归一化后的 xAI wire 模型。
pub(crate) fn upstream_model(&self) -> Option<&str> {
self.body.get("model").and_then(Value::as_str)
}
@@ -1928,8 +1928,8 @@ impl ToolNormalizer {
"apply_patch" => self.normalize_apply_patch_tool(tool),
"x_search" | "collections_search" | "file_search" | "code_execution"
| "code_interpreter" => Ok(vec![Value::Object(without_defer_loading(tool))]),
// sub2api 的 xAI Responses 适配层对未获 xAI 接受的工具做静默过滤,
// 并在过滤后同步收敛 tool_choice;不要把客户端可选扩展升级成整单 400。
// 对未获 xAI 接受的工具做静默过滤,并在过滤后同步收敛 tool_choice;
// 不要把客户端可选扩展升级成整单 400。
"" | "computer_use_preview" | "image_generation" => Ok(Vec::new()),
_ => Ok(Vec::new()),
}
@@ -3517,15 +3517,15 @@ fn normalize_compaction_input(
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let has_sub2api_shape = item.contains_key("summary")
let has_structured_compaction_shape = item.contains_key("summary")
|| item.contains_key("id")
|| item.contains_key("status")
|| string_field(item, "type").trim() == "compaction_summary";
// 旧版 adapter 曾将明文摘要直接写入 encrypted_content,且不带
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格按
// sub2api 回放为 xAI reasoning 密文 + 可见 conversation summary。
if !has_sub2api_shape {
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格回放为
// xAI reasoning 密文 + 可见 conversation summary。
if !has_structured_compaction_shape {
let Some(summary) = encrypted else {
return Ok(Vec::new());
};
@@ -363,20 +363,48 @@ async fn oauth_bundle_export_is_provider_owned_canonical_and_debug_redacted() {
let value = export.into_value();
assert_eq!(value["version"], 1);
assert_eq!(value["type"], "oauth-account-bundle");
assert!(value.get("exportedAt").is_some());
assert!(value.get("exported_at").is_none());
assert_eq!(value["accounts"][0]["platform"], "grok");
assert_eq!(value["accounts"][0]["type"], "oauth");
assert_eq!(
value["accounts"][0]["credentials"]["base_url"],
value["accounts"][0]["credentials"]["baseUrl"],
provider_xai::GROK_CLI_BASE_URL
);
assert_eq!(
value["accounts"][0]["credentials"]["access_token"],
value["accounts"][0]["credentials"]["accessToken"],
"access-export"
);
assert_eq!(
value["accounts"][0]["credentials"]["refreshToken"],
"refresh-export"
);
assert_eq!(value["accounts"][0]["credentials"]["idToken"], "id-export");
assert_eq!(value["accounts"][0]["credentials"]["tokenType"], "Bearer");
assert!(
value["accounts"][0]["credentials"]["expiresAt"]
.as_str()
.is_some()
);
assert_eq!(
value["accounts"][0]["credentials"]["clientId"],
provider_xai::OFFICIAL_CLIENT_ID
);
assert_eq!(
value["accounts"][0]["credentials"]["scope"],
"openid profile email offline_access grok-cli:access api:access"
);
for field in [
"access_token",
"refresh_token",
"id_token",
"token_type",
"expires_at",
"base_url",
"client_id",
] {
assert!(value["accounts"][0]["credentials"].get(field).is_none());
}
assert_eq!(value["proxies"], serde_json::json!([]));
}
@@ -225,7 +225,7 @@ fn oauth_account_document_should_reject_api_key_credential_field() {
fn oauth_account_document_should_ignore_source_wrapper_and_non_auth_metadata() {
let mut document: serde_json::Value =
serde_json::from_slice(&oauth_account_document()).expect("fixture JSON");
document["type"] = serde_json::Value::String("sub2api-data".to_owned());
document["type"] = serde_json::Value::String("external-oauth-data".to_owned());
document["version"] = serde_json::Value::from(99);
document["proxies"] = serde_json::json!([{"url": "http://127.0.0.1:8080"}]);
document["source_extension"] = serde_json::json!({"opaque": true});
@@ -1534,7 +1534,7 @@ async fn compaction_wire_should_exclude_reasoning_from_summary_content() {
}
#[tokio::test]
async fn compaction_stream_should_accept_short_summary_like_sub2api() {
async fn compaction_stream_should_accept_short_summary() {
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse(
"<summary>too short</summary>",
None,
@@ -713,6 +713,27 @@ impl ProviderSessionAffinityPort for TestSessionAffinity {
Box::pin(async { Ok(()) })
}
fn claim_or_load<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
_key: &'a ProviderSessionAffinityKey,
candidate_account_id: &'a ProviderAccountId,
_ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move { Ok(candidate_account_id.clone()) })
}
fn compare_and_bind<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
_key: &'a ProviderSessionAffinityKey,
_expected_account_id: &'a ProviderAccountId,
replacement_account_id: &'a ProviderAccountId,
_ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move { Ok(replacement_account_id.clone()) })
}
fn clear<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
@@ -76,7 +76,7 @@ fn encoder_should_preserve_history_order_and_append_summary_prompt() {
}
#[test]
fn encoder_should_preserve_sub2api_compaction_constraints() {
fn encoder_should_preserve_compaction_constraints() {
let request = encode(json!({
"model": "client-model",
"input": [
@@ -190,7 +190,7 @@ fn encoder_should_consume_only_the_terminal_compaction_trigger() {
}
#[test]
fn decoder_should_preserve_sub2api_summary_text_after_trimming_edges() {
fn decoder_should_preserve_summary_text_after_trimming_edges() {
let raw = valid_summary("checkpoint-only-secret");
let summary = decode_summary(&format!(" {raw} \n")).expect("summary");
@@ -234,7 +234,7 @@ fn decoder_should_preserve_unclosed_leading_analysis() {
}
#[test]
fn decoder_should_preserve_compaction_control_tokens_like_sub2api() {
fn decoder_should_preserve_compaction_control_tokens() {
let raw = valid_summary(
"Quoted <summary> and </summary>, <analysis> and </analysis>, \
plus <summary_request> and </summary_request>.",
@@ -278,7 +278,7 @@ fn decoder_should_preserve_numbered_sections_that_quote_analysis_close() {
}
#[test]
fn decoder_should_accept_short_summary_like_sub2api() {
fn decoder_should_accept_short_summary() {
let summary = decode_summary("<summary>too short</summary>").expect("short summary");
assert_eq!(summary, "<summary>too short</summary>");
@@ -567,7 +567,7 @@ async fn inference_transport_should_classify_free_usage_429_as_account_free_quot
}
#[tokio::test]
async fn inference_transport_should_apply_sub2api_body_aware_400_failures() {
async fn inference_transport_should_apply_body_aware_400_failures() {
let cases = [
(
json!({"error": {"code": "subscription:free-usage-exhausted", "message": "free usage exhausted"}}),
@@ -198,7 +198,7 @@ fn encoder_should_apply_build_defaults_and_normalize_reasoning_effort() {
}
#[test]
fn encoder_should_strip_sub2api_grok_unsupported_fields() {
fn encoder_should_strip_grok_unsupported_fields() {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
@@ -943,7 +943,7 @@ fn custom_apply_patch_declaration_should_use_patch_parameter() {
}
#[test]
fn hosted_tool_choice_should_preserve_the_sub2api_specific_choice() {
fn hosted_tool_choice_should_preserve_normalized_web_search_choice() {
let request = raw_request(json!({
"model": "client",
"input": "search",
@@ -1244,7 +1244,7 @@ fn compaction_history_should_become_plaintext_user_continuation_in_place() {
}
#[test]
fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
fn structured_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
for item_type in ["compaction", "compaction_summary"] {
let request = raw_request(json!({
"model": "client",
@@ -1264,7 +1264,7 @@ fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_su
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("sub2api compaction continuation");
.expect("structured compaction continuation");
let body = Value::Object(encoded.body().clone());
assert_eq!(
@@ -1290,7 +1290,7 @@ fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_su
}
#[test]
fn malformed_compaction_history_should_be_dropped_like_sub2api() {
fn malformed_compaction_history_should_be_dropped() {
for encrypted_content in [
None,
Some(json!(null)),
+1 -1
View File
@@ -13,7 +13,7 @@ RUN npm install --global pnpm@11.7.0 \
COPY frontend ./
RUN pnpm build
FROM rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa AS backend-builder
FROM rust:1.98-bookworm@sha256:e70e2eec3d495fd5c8e0be74adda86507dfac7f51a724fbf9813ff59b2b247c7 AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
+360
View File
@@ -0,0 +1,360 @@
<!-- prettier-ignore -->
<div align="center">
<img src="../frontend/public/favicon.svg" alt="Codex Proxy RS" width="72" height="72" />
# 管理端主题系统
**基于 Seed、Map、Alias 与 Component Token 的运行时主题架构**
[设计原则](#设计原则) · [Token 模型](#token-模型) · [运行时架构](#运行时架构) · [主题编辑器](#主题编辑器) · [扩展指南](#扩展指南)
</div>
主题系统为 Codex Proxy RS 管理端提供浅色、深色、预置配色与自定义配色能力。实现借鉴 Ant Design 的
Token 分层与十阶色板算法,但不引入 `antd`、React、`ConfigProvider` 或 CSS-in-JS;Vue 组件继续通过
CSS Variables 与 Tailwind CSS 4 utilities 消费主题。
> [!IMPORTANT]
> 主题的目标是调整颜色、密度、圆角与层级,不改变现有页面结构和产品语义。默认主题必须保持既有视觉基线,
> 成功、警告、错误和业务数据色不会因为品牌色变化而失去原有含义。
## 设计原则
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;只有 Hover、Active、弱背景和文字色使用派生值。
- **主色与表面分离**:品牌色负责交互和强调,页面、容器、浮层与文字由独立的背景和文本 Seed 派生。
- **语义独立**:`success`、`warning`、`error`、`info` 与 `link` 拥有各自 Seed,不随品牌色隐式改变。
- **无边设计**:默认依靠表面色差、间距和轻阴影表达层级;边框只用于焦点、错误和必要分隔。
- **运行时可定制**:用户输入在浏览器中实时派生,因此使用 CSS Variables,不使用构建时 SCSS 变量。
- **单一事实源**:Store 只保存最小配置,所有 Map、Alias 与未覆盖的 Component Token 均由纯函数生成。
- **可访问性优先**:文字、焦点和实心控件在 Alias 层执行对比度保护,并尊重 `prefers-reduced-motion`。
## 架构概览
```mermaid
flowchart LR
A[Pinia 持久化配置] --> B[resolveTheme]
B --> C[Seed Token]
C --> D[Map Token]
D --> E[Alias Token]
E --> F[Component Token]
F --> G[#cp-runtime-theme]
G --> H[Vue / Tailwind / ECharts]
B --> I[Theme Editor 草稿]
I --> J[Shadow DOM 预览]
J --> K[首页画板 / 组件概览]
```
核心职责保持分离:
| 层 | 文件 | 职责 |
| --- | --- | --- |
| 领域模型 | [`theme/index.ts`](../frontend/src/theme/index.ts) | 类型、规范化、纯派生算法与最终 Token 输出 |
| 设计基线 | [`theme/constants.ts`](../frontend/src/theme/constants.ts) | 默认值、预置主题、中性色面、阴影与可编辑白名单 |
| 浏览器适配 | [`theme/browser.ts`](../frontend/src/theme/browser.ts) | 将解析结果提交为根作用域运行时样式表 |
| 状态 | [`stores/modules/theme.ts`](../frontend/src/stores/modules/theme.ts) | 持久化配置、系统明暗偏好、切换动作与动画 |
| 编辑器状态 | [`useThemeEditor.ts`](../frontend/src/views/theme/composables/useThemeEditor.ts) | 草稿、修改计数、恢复与保存 |
| 样式桥接 | [`styles/index.css`](../frontend/src/styles/index.css) | 将 CSS Token 暴露为 Tailwind CSS 4 utilities |
| 稳定语义 | [`styles/tokens.css`](../frontend/src/styles/tokens.css) | 不参与主题派生的业务常量 |
`theme/index.ts` 不访问 DOM,`theme/browser.ts` 不包含派生规则,Theme Store 不复制算法。类型与算法集中在
`theme/index.ts`,不额外拆分 `types.ts`。
## Token 模型
主题采用四层模型:
```text
Seed Token → Map Token → Alias Token → Component Token
```
### Seed Token
Seed 是用户配置与持久化的最小事实:
```ts
type ThemeMode = 'system' | 'light' | 'dark'
type ThemeColorId = 'relay-blue' | 'deep-teal' | 'signal-violet' | 'graphite' | 'custom'
interface ThemeCustomization {
seed?: Partial<ThemeSeedOverrides>
component?: Partial<ThemeComponentOverrides>
tokenOverrides?: Partial<ThemeTokens>
}
```
| 类别 | Seed |
| --- | --- |
| 品牌 | `colorPrimary`,由预置 ID 或自定义 HEX 提供 |
| 功能色 | `colorSuccess`、`colorWarning`、`colorError`、`colorInfo`、`colorLink` |
| 中性色 | `colorTextBase`、`colorBgBase` |
| 尺寸 | `fontSize`、`sizeUnit`、`sizeStep`、`controlHeight` |
| 风格 | `borderRadius`、`shadowStrength` |
| 组件尺寸 | `tableRowHeight`、`cardBorderRadius` |
持久化键为 `codex-proxy-rs-theme`。Store 不保存完整色板或 CSS Variables,损坏的模式、颜色与自定义值会在
初始化时规范化并回退到默认配置。
### Map Token
品牌色和功能色使用 `@ant-design/colors` 从一个 Seed 生成十阶色板。主色派生规则为:
| 角色 | 规则 |
| --- | --- |
| `colorPrimary` | 始终使用 Seed 原色 |
| Hover | 浅色取 P5,深色取暗色算法 P8 |
| Active | 浅色取 P7,深色取暗色算法 P6 |
| 实心 Hover | Seed 向白混合 6% |
| 实心 Active | Seed 向黑混合 8% |
| 弱背景 | 从当前容器表面与主色混合,避免低亮度 Seed 产生脏灰色块 |
| 文字与描边 | 从色板取值后,对相邻 Surface 执行对比度校正 |
背景与文本 Seed 进入独立的 Surface Map,生成:
- `colorBgLayout / Container / Elevated / Spotlight / Mask`
- `colorFillSecondary / Tertiary / Quaternary`
- `colorText / Heading / Secondary / Tertiary / Quaternary / Disabled`
- `colorBorder / BorderSecondary / Split / Shadow`
默认中继蓝使用固定的石墨中性色基线。其他预置通过各自的浅色、深色 `colorBgBase` 与 `colorTextBase`
建立完整气质。自定义主色只向中性表面注入少量色温:浅色混入 2.8%,深色从默认暗面与暗色色板背景混合;
用户显式设置背景或文字 Seed 后,自动配套值立即让位。
### Alias Token
Alias 按视觉角色命名,使用 `--cp-` 命名空间,并优先对齐 Ant Design 的公开 Token 词汇:
| 角色 | CSS Token | 主要消费者 |
| --- | --- | --- |
| 品牌交互 | `--cp-color-primary*` | 主操作、选中态、焦点与强调 |
| 链接 | `--cp-color-link*` | 普通文本链接 |
| 表面 | `--cp-color-bg-*` | 页面、容器、浮层和遮罩 |
| 填充 | `--cp-color-fill-*` | 轨道、弱背景和静态层级 |
| 文字 | `--cp-color-text*` | 标题、正文和辅助信息 |
| 选择 | `--cp-control-item-bg-active*` | Segmented、Select 和选择控件 |
| 焦点 | `--cp-control-outline` | 键盘焦点和输入反馈 |
| 功能色 | `--cp-color-info / success / warning / error-*` | 系统反馈与状态 |
Alias 层执行以下保护:
- 普通文字与背景的目标对比度为 4.5:1。
- 控件边界与相邻 Surface 的目标对比度为 3:1。
- 极亮的实心按钮背景自动切换为深色文字,避免白底白字。
- 校正只修改最终 Alias,不反写用户保存的 Seed。
### Component Token
当组件需要独立演进时,使用 `component[-part][-variant][-state]-property` 命名,不创造含义重复的全局别名。
| 组件 | 代表 Token |
| --- | --- |
| Button | `--cp-button-primary-color / bg / hover-bg / active-bg` |
| Input | `--cp-input-bg / hover-bg / active-bg / error-active-bg` 与对应 Shadow |
| Menu | `--cp-menu-item-selected-bg` |
| Table | `--cp-table-row-bg / stripe-bg / hover-bg / selected-bg / height` |
| Card | `--cp-card-bg / border-radius / shadow` |
| Layout | `--cp-layout-sider-bg / shadow` |
| Scrollbar | `--cp-scrollbar-thumb-bg / hover-bg` |
| BrandMark | `--cp-brand-mark-bg` |
应用内品牌图标使用 [`AppBrandMark.vue`](../frontend/src/components/AppBrandMark.vue):浅色模式取
`colorBgSpotlight`,深色模式取 `colorBgElevated`,保持中性暗面与白色图形;浏览器 favicon 继续使用固定黑白
图标,不随主题改变。
### 稳定业务语义
下列颜色不属于通用主题皮肤,保留在 `styles/tokens.css`:
- 账号“正常”状态 `colorStatusNormal*`
- 推理标识色 `colorReasoning`
- 账号套餐标识色 `accountPlan*`
它们可以分别拥有浅色和深色值,但不会被品牌 Seed 重写。图表数据系列同样按数据语义管理,不把所有曲线染成
主色。
## 预置主题
| ID | 名称 | 主色 | 表面方向 |
| --- | --- | --- | --- |
| `relay-blue` | 中继蓝 | `#5983F4` | 默认石墨控制面,清晰可信 |
| `deep-teal` | 深海青 | `#0E7C72` | 氧化铜与深水玻璃,冷静低饱和 |
| `signal-violet` | 古风色 | `#A0583D` | 赭石、宣纸与松烟墨,温厚克制 |
| `graphite` | 石墨 | `#525B66` | 工作室灰纸与铅笔粉末,近单色层级 |
预置主题只改变颜色画像,不改变字号、密度、圆角或布局。所有预置与自定义主题走同一条派生链路,不存在页面级
特判。
## 运行时架构
### 启动顺序
[`main.ts`](../frontend/src/main.ts) 在 Vue 挂载前初始化主题:
```text
createApp
→ app.use(pinia)
→ persistedstate 同步水合 Theme Store
→ themeStore.initializeTheme()
→ app.use(router / auth)
→ app.mount('#app')
```
这样首个 Vue 组件渲染时已经具备正确主题,不需要在 `index.html` 增加 bootstrap 脚本,也不维护第二套本地存储
解析逻辑。
### CSS Variables 提交
全局主题由 `<head>` 中唯一的运行时样式节点承载:
```html
<style id="cp-runtime-theme">
:root[data-theme='dark'][data-theme-color='relay-blue'] {
color-scheme: dark;
--cp-color-bg-layout: #0B111C;
/* 其余 Map、Alias 与 Component Token */
}
</style>
```
根元素只保留 `data-theme` 与 `data-theme-color` 状态,不写整套内联 Token。运行时样式表比逐项调用
`style.setProperty()` 更集中、可检查,也避免在 DOM 属性中形成超长变量串。全局弹窗通过 Teleport 挂到 `body`
后仍继承根变量。
> [!NOTE]
> Theme Editor 预览是例外:草稿 Token 以内联变量写在影子环境的局部根节点上,仅影响预览,不污染已保存主题。
### 切换与图表
- 用户主动切换时,以点击位置为圆心运行 View Transition;不支持时使用 180ms 颜色过渡。
- 初始化、系统主题变化或减少动态效果时直接提交,不播放扩散动画。
- 每次有效提交只增加一次 `themeRevision`,相同签名不会重复刷新。
- `useThemeColor()` 在正式页面读取根 CSS Variables,在影子预览中优先读取注入的局部 Token。
- 图表 Option 在主题 revision 或预览 Token 改变后重算,`BaseChart` 通过 `setOption` 更新现有 Canvas。
## 主题编辑器
主题编辑器位于一级路由 `/theme`,采用一屏工作台:左侧编辑,右侧预览,顶部保留全局操作。桌面端高度固定为
`100dvh - 3rem`,切换编辑层级或预览类型时不改变外框高度。窄屏头部操作允许自然换行;手机端隐藏实时预览,
只保留 Token 编辑,避免在有限宽度内渲染不可操作的缩放画板。
### 编辑能力
| 区域 | 能力 |
| --- | --- |
| 全局 / 颜色 | 模式、预置、自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
| 全局 / 尺寸 | 字号、基础间距、尺寸步长、控件高度 |
| 全局 / 风格 | 通用圆角、阴影强度 |
| 组件 | Action、Form、Selection、Surface、Data Display、Feedback、Navigation、Layout |
| 工作流 | 搜索、单项恢复、撤销草稿、恢复默认、保存并应用 |
Component Token 只开放白名单字段。未覆盖项始终继续使用全局 Seed 与 Alias 算法,避免主题配置逐渐退化成一份
无法维护的完整 CSS 快照。
### 草稿与保存
编辑器维护 `saved` 与 `draft` 两份状态:
1. 输入只更新草稿和局部预览。
2. 修改计数按模式、主题色、Seed、组件值和 Token override 分项统计。
3. “撤销草稿”恢复到最近一次已保存配置。
4. “保存并应用”先规范化草稿,再原子更新 Theme Store。
### 隔离预览
[`ThemePreviewScope.vue`](../frontend/src/views/theme/components/ThemePreviewScope.vue) 创建开放 Shadow Root,复制应用
样式,并把预览内容 Teleport 到影子根中。预览可独立切换浅色和深色,不受外层主题影响。
- **首页画板**复用真实 `DashboardContent` 和固定 fixture,不请求接口,也不启动自动刷新。
- **组件概览**展示基础组件、表格、空状态、骨架、浮层和菜单等关键状态。
- 画板固定为 `1600 × 1808`,使用 CSS `zoom` 重排,不使用 `transform: scale()` 长期缩放文字。
- 空白区域可拖拽,滚轮以指针为锚点缩放,并提供缩小、100%、放大和适应画板操作。
- 编辑面板和组件概览统一使用 `BaseScrollbar`,滚动条空闲时自动隐藏。
## 样式与命名约定
### CSS Token
- 全局 Token:`--cp-color-bg-container`、`--cp-font-size`。
- Component Token:`--cp-table-row-hover-bg`、`--cp-input-active-shadow`。
- 业务 Token:命名中明确业务域,例如 `--cp-account-plan-pro-bg`。
- 禁止继续引入 `accent`、`soft`、`current`、`subtle` 等与现有角色重叠的平行词汇。
### Vue 与 Tailwind CSS 4
组件优先使用 `bg-cp-*`、`text-cp-*`、`shadow-cp-*`、`rounded-cp-*` 等由 `@theme inline` 暴露的 utility:
```vue
<section class="rounded-cp-card bg-cp-bg-container text-cp-text shadow-cp-card">
...
</section>
```
仅当值需要在 CSS 函数、SVG 或局部派生中参与计算时,直接读取 `var(--cp-*)`。不在页面组件中重新实现色阶、
对比度或明暗算法。
### 视觉状态
- 静态填充、Hover、Active 与 Selected 必须使用不同角色,不能复用一个变量制造所有层级。
- Input 的 Hover 与 Active 只改变内部填充;外圈反馈保持同色同宽,错误状态使用独立 Error Token。
- 表格斑马纹使用 `table-row-stripe-bg`,不借用 `row-hover-bg`。
- Card 和选项默认不增加装饰性边框;键盘焦点必须保留可见反馈。
- 阴影保持中性,`shadowStrength` 只调节层级强弱,不给阴影染品牌色。
## 扩展指南
### 增加预置主题
1. 在 `ThemeColorPresetId` 增加稳定 ID。
2. 在 `THEME_COLOR_PRESETS` 声明名称、描述、主色 Seed 与可选浅暗表面画像。
3. 不新增页面特判,确认预置可通过同一 `resolveTheme()` 派生。
4. 在浅色、深色和系统模式下检查文本、浮层、Input、表格与图表。
### 增加全局 Seed 或 Alias
1. 在 `theme/index.ts` 的领域类型中声明字段。
2. 在规范化函数中定义合法输入边界。
3. 在 Map 或 Alias 层集中派生,并加入 `ThemeTokens` 完整输出。
4. 如需 Tailwind utility,在 `styles/index.css` 的 `@theme inline` 中映射。
5. 只在确实需要用户控制时加入 Theme Editor;派生细节默认只读。
### 增加 Component Token
1. 先确认全局 Alias 无法准确表达组件职责。
2. 使用 `component[-part][-variant][-state]-property` 命名。
3. 在 `ThemeComponentMap` 和 `toThemeTokens()` 中提供默认派生值。
4. 需要开放编辑时,加入对应组件目录和可编辑白名单。
5. 基础组件消费 Token,页面不得再写第二套局部常量。
> [!WARNING]
> 不要把任意 HEX、阴影或尺寸加入 `styles/tokens.css` 作为“临时修复”。可换肤值必须进入派生链;只有稳定的
> 业务语义与首帧安全 fallback 可以留在静态样式中。
## 验证
前端改动至少执行:
```bash
cd frontend
pnpm run lint
pnpm run typecheck
pnpm run build
git diff --check
```
视觉验收覆盖:
- 四个预置、自定义 HEX、浅色、深色和跟随系统模式。
- 页面、容器、浮层、输入框、表格、分页、主按钮、选中态、焦点和品牌图标。
- 草稿隔离、保存刷新恢复与 Teleport 弹窗。
- 首页画板缩放清晰度、组件概览滚动、ECharts 网格线和 Skeleton 动效。
- 键盘操作、颜色之外的选中反馈和 `prefers-reduced-motion`。
## 上游参考
- [Ant Design 色彩规范](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/spec/colors.zh-CN.md)
- [Ant Design 暗黑模式](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/spec/dark.zh-CN.md)
- [Ant Design 主题定制](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/react/customize-theme.zh-CN.md)
- [Seed 到 Map](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/components/theme/themes/shared/genColorMapToken.ts)
- [Map 到 Alias](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/components/theme/util/alias.ts)
- [`@ant-design/colors` 生成器](https://github.com/ant-design/ant-design-colors/blob/89b4a5b7e989b792610087abe855bf4a2fb1d322/src/generate.ts)
+9 -8
View File
@@ -14,18 +14,19 @@
"format:check": "pnpm run lint"
},
"dependencies": {
"@ant-design/colors": "^8.0.1",
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.31.0",
"@lucide/vue": "^1.33.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.19.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.13",
"dayjs": "^1.11.23",
"dompurify": "^3.4.14",
"echarts": "^6.1.0",
"es-toolkit": "^1.50.0",
"es-toolkit": "^1.51.0",
"gsap": "^3.15.0",
"marked": "^18.0.9",
"marked": "^18.0.10",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.41",
@@ -37,12 +38,12 @@
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1",
"eslint": "^10.9.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vue-tsc": "^3.3.10"
"vite": "^8.2.2",
"vue-tsc": "^3.3.11"
}
}
+274 -248
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -2,9 +2,6 @@
import { RouterView } from 'vue-router'
import { BaseToast } from '@/components/base/BaseToast'
import { useUiStore } from '@/stores/modules/ui'
useUiStore().initializeTheme()
</script>
<template>
@@ -32,7 +32,7 @@ function updateGroup(groupId: string, selected: boolean) {
<div
v-for="group in groups"
:key="group.id"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp bg-cp-fill-quaternary px-3.5 py-2.5"
>
<span class="flex min-w-0 items-center gap-2.5">
<span
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
</span>
<span
v-if="!group.enabled"
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
class="shrink-0 text-cp-xs font-emphasis text-cp-text-quaternary"
>
已禁用
</span>
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
</div>
<p
v-else
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
class="m-0 rounded-cp bg-cp-fill-quaternary px-3.5 py-3 text-cp-sm font-emphasis text-cp-text-secondary"
>
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
</p>
@@ -27,5 +27,5 @@ const fullNames = computed(() => props.groups
aria-hidden="true"
/>
</div>
<span v-else class="text-xs text-cp-muted-text">—</span>
<span v-else class="text-xs text-cp-text-quaternary">—</span>
</template>
@@ -90,7 +90,7 @@ function providerSummary(group: AccountGroup) {
:disabled="disabled || loading"
>
<template #prefix>
<Search class="size-4 text-cp-tertiary" />
<Search class="size-4 text-cp-text-tertiary" />
</template>
</BaseInput>
@@ -102,10 +102,10 @@ function providerSummary(group: AccountGroup) {
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="flex w-full items-start gap-3 rounded-cp border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:class="selectedIds.includes(group.id)
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover'"
? 'bg-cp-control-item-bg-active'
: 'bg-cp-fill-quaternary hover:bg-cp-bg-text-hover'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
@@ -115,7 +115,7 @@ function providerSummary(group: AccountGroup) {
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
<strong class="min-w-0 truncate text-cp text-cp-text">
{{ group.name }}
</strong>
<span
@@ -125,7 +125,7 @@ function providerSummary(group: AccountGroup) {
已禁用
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
<span class="mt-1 block text-cp-xs font-emphasis text-cp-text-secondary">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
@@ -148,15 +148,15 @@ function providerSummary(group: AccountGroup) {
<div
v-if="selectedIds.length === 0"
class="rounded-cp-control px-3.5 py-3"
class="rounded-cp px-3.5 py-3"
:class="emptyCopy.tone === 'warning'
? 'bg-cp-warning-bg text-cp-warning-text'
: 'bg-cp-subtle text-cp-secondary'"
: 'bg-cp-fill-quaternary text-cp-text-secondary'"
>
<p class="m-0 text-[12px] font-bold">
<p class="m-0 text-cp-sm font-bold">
{{ emptyCopy.title }}
</p>
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis">
{{ emptyCopy.description }}
</p>
</div>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useId } from 'vue'
const id = useId().replaceAll(':', '')
const noiseFilterId = `${id}-brand-noise`
const wornFilterId = `${id}-brand-worn`
const mistGradientId = `${id}-brand-mist`
</script>
<template>
<svg
class="app-brand-mark"
xmlns="http://www.w3.org/2000/svg"
viewBox="48 48 416 416"
aria-hidden="true"
focusable="false"
>
<defs>
<filter :id="noiseFilterId" x="0" y="0" width="100%" height="100%">
<feTurbulence
type="fractalNoise"
baseFrequency="1.7"
numOctaves="2"
seed="31"
result="noise"
/>
<feColorMatrix
in="noise"
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 2.56 2.56 2.56 0 -4.76"
result="speckle"
/>
<feComposite operator="in" in="SourceGraphic" in2="speckle" />
</filter>
<filter :id="wornFilterId" x="-12%" y="-12%" width="124%" height="124%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.12"
numOctaves="2"
seed="41"
result="noise"
/>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.2" />
</filter>
<radialGradient :id="mistGradientId" cx="0.5" cy="0.45" r="0.75">
<stop offset="0" stop-color="currentColor" stop-opacity="0.1" />
<stop offset="0.55" stop-color="currentColor" stop-opacity="0.045" />
<stop offset="1" stop-color="currentColor" stop-opacity="0.012" />
</radialGradient>
</defs>
<rect class="app-brand-mark__surface" x="48" y="48" width="416" height="416" rx="88" />
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="36">
<path d="M156 307A112 112 0 0 1 307 156" />
<path d="M356 205A112 112 0 0 1 205 356" />
</g>
<path d="m407 105-119 153-183 149 124-157Z" fill="currentColor" />
<g
:filter="`url(#${wornFilterId})`"
fill="none"
stroke="currentColor"
stroke-linecap="square"
>
<path d="M92 170 136 148" stroke-width="1" opacity="0.38" />
<path d="M98 178 142 156" stroke-width="0.5" opacity="0.3" />
<path d="M308 398 346 378" stroke-width="0.9" opacity="0.36" />
<path d="M314 406 352 386" stroke-width="0.5" opacity="0.3" />
<path d="M364 132 398 118" stroke-width="0.9" opacity="0.38" />
<path d="M402 118 410 126" stroke-width="0.5" opacity="0.28" />
<path d="M108 350 142 334" stroke-width="1" opacity="0.4" />
<path d="M158 238 190 226" stroke-width="0.8" opacity="0.34" />
<path d="M296 292 320 280" stroke-width="0.7" opacity="0.3" />
<path d="M126 118 136 110" stroke-width="0.6" opacity="0.26" />
<path d="M400 96 410 88" stroke-width="0.6" opacity="0.24" />
</g>
<g :filter="`url(#${wornFilterId})`" fill="currentColor">
<path
d="M98 176 104 173 108 175 114 170 118 173 124 167 130 170 136 164 142 167 148 161 154 164 160 158Z"
opacity="0.34"
/>
<path
d="M314 395 320 392 324 394 330 389 336 392 342 386 348 389 354 383Z"
opacity="0.3"
/>
</g>
<rect
x="48"
y="48"
width="416"
height="416"
rx="88"
fill="currentColor"
:filter="`url(#${noiseFilterId})`"
opacity="0.08"
/>
<rect
x="48"
y="48"
width="416"
height="416"
rx="88"
:fill="`url(#${mistGradientId})`"
/>
</svg>
</template>
<style scoped>
.app-brand-mark {
color: var(--cp-color-text-light-solid);
}
.app-brand-mark__surface {
fill: var(--cp-brand-mark-bg);
}
</style>
@@ -39,18 +39,18 @@ const authenticationLabel = computed(() => {
<template>
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
<span
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
class="inline-flex shrink-0 items-center justify-center bg-cp-fill-tertiary text-cp-text"
:class="iconContainerClass"
:title="providerLabel"
>
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
<span
v-if="showAuthenticationKind"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
class="inline-flex shrink-0 items-center justify-center bg-cp-fill-tertiary text-cp-text"
:class="iconContainerClass"
:title="authenticationLabel"
>
@@ -64,7 +64,7 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
<span class="sr-only">
+9 -9
View File
@@ -30,19 +30,19 @@ defineSlots<{
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
secondary:
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
'bg-cp-fill-tertiary text-cp-text shadow-cp-tertiary hover:bg-cp-bg-text-active active:bg-cp-bg-text-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
destructive:
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
'bg-cp-error-bg text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
}
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
md: 'h-cp-control gap-2 px-4 text-cp',
lg: 'h-cp-control-lg gap-2.5 px-5 text-cp-lg',
}
const spinnerSizes: Record<ButtonSize, number> = {
@@ -52,9 +52,9 @@ const spinnerSizes: Record<ButtonSize, number> = {
}
const classes = computed(() => [
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
+3 -3
View File
@@ -45,7 +45,7 @@ const contentClasses = computed(() => [
<template>
<component
:is="props.as"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
class="overflow-hidden rounded-cp-card bg-(--cp-card-bg) shadow-cp-card"
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
>
<template v-if="$slots.header || hasManagedHeader || $slots.body">
@@ -55,7 +55,7 @@ const contentClasses = computed(() => [
<div class="min-w-0 pt-0.5">
<h2
v-if="props.title || $slots.title"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-text text-balance"
>
<slot name="title">
{{ props.title }}
@@ -63,7 +63,7 @@ const contentClasses = computed(() => [
</h2>
<p
v-if="props.description || $slots.description"
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
class="mt-1.75 mb-0 text-cp leading-[1.3] font-emphasis text-cp-text-secondary text-pretty"
>
<slot name="description">
{{ props.description }}
@@ -26,12 +26,12 @@ watchEffect(() => {
})
const boxClasses = computed(() => [
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-cp-disabled text-cp-disabled-icon'
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
: checked.value
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
? 'bg-cp-primary text-(--cp-button-primary-color)'
: 'bg-cp-bg-container text-transparent shadow-[inset_0_0_0_1px_var(--cp-color-border)]',
])
</script>
@@ -39,7 +39,7 @@ const boxClasses = computed(() => [
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-cp leading-none font-emphasis"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
>
<input
@@ -52,7 +52,7 @@ const boxClasses = computed(() => [
>
<span
:class="boxClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
aria-hidden="true"
>
<Minus
@@ -64,6 +64,6 @@ const boxClasses = computed(() => [
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
/>
</span>
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
<span v-if="showLabel" class="text-cp leading-none font-emphasis">{{ label }}</span>
</label>
</template>
@@ -1,9 +1,9 @@
import { normalizeRgbaHexColor } from '@/utils/color'
import { normalizeHexColor, normalizeRgbaHexColor } from '@/utils/color'
const RGBA_COLOR_PATTERN
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
interface HsvColor {
export interface HsvaColor {
h: number
s: number
v: number
@@ -17,8 +17,23 @@ interface RgbaColor {
a: number
}
export function hexToHsv(value: string): HsvColor | null {
const normalized = normalizeRgbaHexColor(value)
export function normalizePickerHexColor(value: string, allowAlpha: boolean) {
if (allowAlpha) {
const rgba = normalizeRgbaHexColor(value)
if (rgba)
return rgba
const rgb = normalizeHexColor(value)
return rgb ? `${rgb}FF` : null
}
const rgb = normalizeHexColor(value)
if (rgb)
return rgb
return normalizeRgbaHexColor(value)?.slice(0, 7) ?? null
}
export function hexToHsva(value: string): HsvaColor | null {
const normalized = normalizePickerHexColor(value, true)
if (!normalized)
return null
@@ -48,7 +63,7 @@ export function hexToHsv(value: string): HsvColor | null {
}
}
export function hsvToHex(color: HsvColor) {
export function hsvaToHex(color: HsvaColor, allowAlpha: boolean) {
const hue = ((color.h % 360) + 360) % 360
const saturation = clamp(color.s, 0, 100) / 100
const brightness = clamp(color.v, 0, 100) / 100
@@ -71,7 +86,9 @@ export function hsvToHex(color: HsvColor) {
const rgb = [red, green, blue]
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
.join('')
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
const alpha = allowAlpha
? Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
: ''
return `#${rgb}${alpha}`.toUpperCase()
}
@@ -97,7 +114,7 @@ export function rgbaToHexColor(color: RgbaColor) {
}
export function formatRgbaColor(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, true)
if (!normalized)
return null
@@ -3,14 +3,15 @@ import { Check, ChevronDown } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { normalizeRgbaHexColor } from '@/utils/color'
import { normalizeHexColor } from '@/utils/color'
import BaseButton from '../BaseButton.vue'
import BaseInput from '../BaseInput.vue'
import BasePopover from '../BasePopover.vue'
import {
formatRgbaColor,
hexToHsv,
hsvToHex,
hexToHsva,
hsvaToHex,
normalizePickerHexColor,
parseRgbaColor,
rgbaToHexColor,
} from './color'
@@ -20,11 +21,13 @@ const props = withDefaults(
presets?: readonly string[]
disabled?: boolean
label?: string
allowAlpha?: boolean
}>(),
{
presets: () => [],
disabled: false,
label: '选择颜色',
allowAlpha: true,
},
)
@@ -39,13 +42,22 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
const draftColor = computed(() => hsvToHex({
const normalizedModel = computed(() =>
normalizePickerHexColor(model.value, props.allowAlpha)
?? (props.allowAlpha ? '#60A5FA80' : '#2563EB'),
)
const triggerSwatchStyle = computed(() => props.allowAlpha
? {
backgroundImage: `linear-gradient(${normalizedModel.value}, ${normalizedModel.value}), conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0)`,
backgroundSize: '100% 100%, 8px 8px',
}
: { backgroundColor: normalizedModel.value })
const draftColor = computed(() => hsvaToHex({
h: hue.value,
s: saturation.value,
v: brightness.value,
a: alpha.value,
}))
}, props.allowAlpha))
const hueColor = computed(() => `hsl(${hue.value} 100% 50%)`)
const svCursorStyle = computed(() => ({
left: `${saturation.value}%`,
@@ -63,8 +75,11 @@ const alphaTrackStyle = computed(() => {
backgroundSize: '100% 100%, 8px 8px',
}
})
const inputError = computed(() => customInput.value && !parseRgbaColor(customInput.value)
? '请输入 rgba(r, g, b, a)'
const inputValid = computed(() => props.allowAlpha
? Boolean(parseRgbaColor(customInput.value))
: Boolean(normalizeHexColor(customInput.value)))
const inputError = computed(() => customInput.value && !inputValid.value
? props.allowAlpha ? '请输入 rgba(r, g, b, a)' : '请输入六位 HEX 颜色,例如 #2563EB'
: undefined)
watch(open, (isOpen) => {
@@ -78,16 +93,19 @@ watch(model, (value) => {
})
function resetDraft(value: string) {
const color = hexToHsv(value) ?? { h: 213, s: 62, v: 98, a: 0.5 }
const color = hexToHsva(normalizePickerHexColor(value, props.allowAlpha) ?? '')
?? { h: 213, s: 62, v: 98, a: props.allowAlpha ? 0.5 : 1 }
hue.value = color.h
saturation.value = color.s
brightness.value = color.v
alpha.value = color.a
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
alpha.value = props.allowAlpha ? color.a : 1
syncInput()
}
function syncInput() {
customInput.value = formatRgbaColor(draftColor.value) ?? ''
customInput.value = props.allowAlpha
? formatRgbaColor(draftColor.value) ?? ''
: normalizeHexColor(draftColor.value) ?? ''
}
function updateSaturationBrightness(event: PointerEvent) {
@@ -189,31 +207,31 @@ function handleAlphaKeydown(event: KeyboardEvent) {
}
function updateFromInput() {
const rgba = parseRgbaColor(customInput.value)
const color = rgba ? hexToHsv(rgbaToHexColor(rgba)) : null
const inputColor = normalizedInputColor()
const color = inputColor ? hexToHsva(inputColor) : null
if (!color)
return
hue.value = color.h
saturation.value = color.s
brightness.value = color.v
alpha.value = color.a
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
alpha.value = props.allowAlpha ? color.a : 1
syncInput()
}
function choosePreset(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
if (normalized)
resetDraft(normalized)
}
function isPresetSelected(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
}
function presetCheckClass(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
if (!normalized)
return 'text-white'
@@ -227,13 +245,21 @@ function presetCheckClass(value: string) {
function confirm() {
updateFromInput()
const rgba = parseRgbaColor(customInput.value)
if (!rgba)
const normalized = normalizedInputColor()
if (!normalized)
return
model.value = rgbaToHexColor(rgba)
model.value = normalized
open.value = false
}
function normalizedInputColor() {
if (!props.allowAlpha)
return normalizeHexColor(customInput.value)
const rgba = parseRgbaColor(customInput.value)
return rgba ? rgbaToHexColor(rgba) : null
}
</script>
<template>
@@ -246,14 +272,17 @@ function confirm() {
<template #trigger>
<button
type="button"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:opacity-60"
:disabled="props.disabled"
:aria-label="props.label"
aria-haspopup="dialog"
:aria-expanded="open"
>
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
<span
class="relative flex size-full items-center justify-center overflow-hidden rounded-sm"
:style="triggerSwatchStyle"
aria-hidden="true"
>
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
</span>
</button>
@@ -263,7 +292,7 @@ function confirm() {
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
<div
ref="svPanel"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:style="{ backgroundColor: hueColor }"
role="slider"
tabindex="0"
@@ -285,7 +314,7 @@ function confirm() {
<div
ref="hueSlider"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
role="slider"
tabindex="0"
aria-label="色相"
@@ -305,10 +334,10 @@ function confirm() {
</div>
</div>
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
<div v-if="props.allowAlpha" class="grid grid-cols-[1fr_36px] items-center gap-2">
<div
ref="alphaSlider"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:style="alphaTrackStyle"
role="slider"
tabindex="0"
@@ -328,7 +357,7 @@ function confirm() {
aria-hidden="true"
/>
</div>
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
<span class="text-right font-mono text-cp-xs tabular-nums text-cp-text-secondary">
{{ Math.round(alpha * 100) }}%
</span>
</div>
@@ -343,7 +372,7 @@ function confirm() {
v-for="color in props.presets"
:key="color"
type="button"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:style="{ backgroundColor: color }"
role="radio"
:aria-label="color"
@@ -364,8 +393,9 @@ function confirm() {
<div class="min-w-0 flex-1">
<BaseInput
v-model="customInput"
aria-label="RGBA 颜色"
placeholder="rgba(96, 165, 250, 1)"
class="rounded-cp bg-cp-fill-tertiary!"
:aria-label="props.allowAlpha ? 'RGBA 颜色' : 'HEX 颜色'"
:placeholder="props.allowAlpha ? 'rgba(96, 165, 250, 1)' : '#2563EB'"
autocomplete="off"
:disabled="props.disabled"
:aria-invalid="Boolean(inputError) || undefined"
@@ -376,13 +406,13 @@ function confirm() {
<p
v-if="inputError"
id="color-input-error"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-error-text"
aria-live="polite"
>
{{ inputError }}
</p>
</div>
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
<BaseButton size="md" variant="secondary" :disabled="!inputValid" @click="confirm">
确定
</BaseButton>
</div>
@@ -391,16 +421,6 @@ function confirm() {
</template>
<style scoped>
.base-color-picker__checker,
.base-color-picker__hue,
.base-color-picker__sv {
--cp-color-picker-checker: conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0) 0 / 8px 8px;
}
.base-color-picker__checker {
background: var(--cp-color-picker-checker);
}
.base-color-picker__sv::before,
.base-color-picker__sv::after {
position: absolute;
@@ -52,7 +52,7 @@ function handleConfirm() {
>
<div
v-if="$slots.default"
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
class="text-cp-lg leading-[1.55] font-emphasis text-cp-text-secondary"
>
<slot />
</div>
+4 -4
View File
@@ -28,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<div
class="grid justify-items-center text-center"
:class="[
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
surface === 'none' ? 'bg-transparent' : 'rounded-cp-lg bg-cp-bg-elevated',
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
]"
>
<span
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
class="inline-flex items-center justify-center rounded-cp bg-cp-fill-tertiary text-cp-text-quaternary"
:class="size === 'sm' ? 'size-8' : 'size-10'"
>
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
</span>
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
<p class="m-0 text-cp leading-[1.15] font-heavy text-cp-text">
{{ resolvedTitle }}
</p>
<p
v-if="description"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-text-secondary"
>
{{ description }}
</p>
@@ -43,10 +43,10 @@ provide(formFieldKey, {
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
:for="resolvedControlId"
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
class="flex min-w-0 items-center gap-1.5 text-cp leading-none font-medium text-cp-text-secondary"
>
<slot name="label">{{ label }}</slot>
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
<span v-if="required" class="font-bold text-cp-error" aria-hidden="true">*</span>
<span v-if="required" class="sr-only">必填</span>
</label>
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
@@ -56,7 +56,7 @@ provide(formFieldKey, {
<p
v-if="description"
:id="descriptionId"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-text-quaternary"
>
{{ description }}
</p>
@@ -66,7 +66,7 @@ provide(formFieldKey, {
<p
v-if="error"
:id="errorId"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-error-text"
aria-live="polite"
>
{{ error }}
@@ -32,20 +32,20 @@ defineSlots<{
const variantClasses: Record<IconButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
secondary:
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
'bg-cp-bg-container text-cp-text-secondary shadow-cp-tertiary hover:bg-cp-bg-text-hover hover:text-cp-text active:bg-cp-bg-text-active',
success:
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
destructive:
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
'bg-transparent text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
}
const sizeClasses: Record<IconButtonSize, string> = {
sm: 'size-cp-control-sm [&>svg]:size-3.5',
md: 'size-cp-control-md [&>svg]:size-4',
md: 'size-cp-control [&>svg]:size-4',
lg: 'size-cp-control-lg [&>svg]:size-4.5',
}
@@ -56,12 +56,12 @@ const spinnerSizes: Record<IconButtonSize, number> = {
}
const classes = computed(() => [
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
props.pressed ? 'bg-cp-bg-text-active text-cp-text' : undefined,
])
</script>
+28 -17
View File
@@ -48,31 +48,31 @@ const controlAttrs = computed(() => Object.fromEntries(
const sizeClasses: Record<InputSize, string> = {
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
md: 'h-cp-control gap-2.5 px-3.5 text-cp',
lg: 'h-cp-control-lg gap-3 px-4 text-cp-lg',
}
const containerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp border-0 text-cp-text shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
sizeClasses[props.size],
props.disabled
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
: invalid.value
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
? 'bg-(--cp-input-error-active-bg) shadow-cp-input-error-active'
: [
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
'bg-[var(--cp-input-bg)]',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-active-bg) focus-within:shadow-cp-input-active',
],
])
const iconClasses = computed(() => [
'inline-flex shrink-0',
props.disabled
? 'text-cp-disabled-icon'
? 'text-cp-text-disabled'
: invalid.value
? 'text-cp-danger'
: 'text-cp-muted-text',
? 'text-cp-error'
: 'text-cp-text-quaternary',
])
function updateModel(event: Event) {
@@ -90,7 +90,7 @@ function updateModel(event: Event) {
v-bind="controlAttrs"
:id="controlId"
:value="model"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-text outline-0 placeholder:text-cp-text-quaternary disabled:cursor-not-allowed disabled:text-cp-text-disabled"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
@@ -109,24 +109,35 @@ function updateModel(event: Event) {
<style scoped>
.base-input__control {
--base-input-autofill-bg: var(--cp-input-current-bg, var(--cp-input-context-bg));
--base-input-autofill-bg: var(--cp-input-bg);
}
.base-input__control:hover {
--base-input-autofill-bg: var(--cp-input-current-bg-hover, var(--cp-input-context-bg-hover));
--base-input-autofill-bg: var(--cp-input-hover-bg);
}
.base-input__control:focus-within {
--base-input-autofill-bg: var(--cp-input-soft-bg-focus);
--base-input-autofill-bg: var(--cp-input-active-bg);
}
.base-input__field:-webkit-autofill,
.base-input__field:-webkit-autofill:hover,
.base-input__field:-webkit-autofill:focus,
.base-input__field:autofill {
caret-color: var(--cp-text-primary);
-webkit-text-fill-color: var(--cp-text-primary) !important;
caret-color: var(--cp-color-text);
-webkit-text-fill-color: var(--cp-color-text) !important;
-webkit-box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
}
/* 普通数字表单保留键盘步进,但不混入浏览器原生微调器外观。 */
.base-input__field[type='number'] {
appearance: textfield;
}
.base-input__field[type='number']::-webkit-inner-spin-button,
.base-input__field[type='number']::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
</style>
@@ -22,12 +22,12 @@ defineSlots<{
}>()
const classes = computed(() => [
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 bg-transparent px-3 text-left text-cp leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-control-outline',
'disabled:cursor-not-allowed disabled:text-cp-text-disabled',
props.tone === 'destructive'
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
: 'text-cp-primary hover:bg-cp-default-hover',
? 'text-cp-error-text hover:bg-cp-error-bg-hover'
: 'text-cp-text hover:bg-cp-bg-text-hover',
])
</script>
@@ -72,8 +72,8 @@ const toneClasses: Record<ModalTone, { iconBg: string, icon: string }> = {
icon: 'text-cp-warning',
},
danger: {
iconBg: 'bg-cp-danger-bg',
icon: 'text-cp-danger',
iconBg: 'bg-cp-error-bg',
icon: 'text-cp-error',
},
success: {
iconBg: 'bg-cp-success-bg',
@@ -186,12 +186,12 @@ onBeforeUnmount(() => {
type="button"
tabindex="-1"
aria-label="关闭弹窗"
class="absolute inset-0 cursor-default border-0 bg-cp-overlay p-0"
class="absolute inset-0 cursor-default border-0 bg-cp-bg-mask p-0"
@click="closeModal"
/>
<section
ref="panel"
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover sm:max-h-[calc(100dvh-3rem)]"
class="cp-modal-panel relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-card bg-cp-bg-container shadow-cp sm:max-h-[calc(100dvh-3rem)]"
:class="[
sizeClasses[size],
$slots.default ? 'grid-rows-[auto_minmax(0,1fr)_auto]' : 'grid-rows-[auto_auto]',
@@ -203,28 +203,31 @@ onBeforeUnmount(() => {
tabindex="-1"
>
<header
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
:class="tone === 'neutral'
? 'grid-cols-[minmax(0,1fr)_28px]'
: 'grid-cols-[auto_minmax(0,1fr)_28px]'"
class="grid shrink-0 gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
:class="[
tone === 'neutral'
? 'grid-cols-[minmax(0,1fr)_28px]'
: 'grid-cols-[auto_minmax(0,1fr)_28px]',
description ? 'items-start' : 'items-center',
]"
>
<span
v-if="tone !== 'neutral'"
class="inline-flex size-11 items-center justify-center rounded-cp-control"
:class="$slots.icon ? 'bg-cp-subtle' : toneClasses[tone].iconBg"
class="inline-flex size-11 items-center justify-center rounded-cp"
:class="$slots.icon ? 'bg-cp-fill-quaternary' : toneClasses[tone].iconBg"
>
<slot name="icon">
<component :is="iconMap[tone]" :size="18" :class="toneClasses[tone].icon" />
</slot>
</span>
<div class="min-w-0">
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-primary">
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-text">
{{ title }}
</h2>
<p
v-if="description"
:id="descriptionId"
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-cp-secondary"
class="mt-2 mb-0 text-cp leading-[1.45] font-semibold text-cp-text-secondary"
>
{{ description }}
</p>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { Minus, Plus } from '@lucide/vue'
import { computed, useAttrs } from 'vue'
import BaseIconButton from './BaseIconButton.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
min?: number
max?: number
step?: number
unit?: string
disabled?: boolean
}>(),
{
min: undefined,
max: undefined,
step: 1,
unit: undefined,
disabled: false,
},
)
const model = defineModel<number>({ required: true })
const attrs = useAttrs()
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
const controlAttrs = computed(() => Object.fromEntries(
Object.entries(attrs).filter(([key]) => !['class', 'style'].includes(key)),
))
const canDecrease = computed(() => !props.disabled && (props.min === undefined || model.value > props.min))
const canIncrease = computed(() => !props.disabled && (props.max === undefined || model.value < props.max))
function clampValue(value: number) {
return Math.min(props.max ?? Number.POSITIVE_INFINITY, Math.max(props.min ?? Number.NEGATIVE_INFINITY, value))
}
function stepPrecision() {
const decimal = String(props.step).split('.')[1]
return decimal?.length ?? 0
}
function updateModel(event: Event) {
const value = (event.target as HTMLInputElement).valueAsNumber
if (Number.isFinite(value))
model.value = clampValue(value)
}
function restoreValue(event: Event) {
const input = event.target as HTMLInputElement
input.value = String(model.value)
}
function stepBy(direction: -1 | 1) {
const next = clampValue(model.value + props.step * direction)
model.value = Number(next.toFixed(stepPrecision()))
}
</script>
<template>
<div
v-bind="rootAttrs"
class="inline-flex h-8 items-center rounded-cp bg-cp-fill-tertiary p-0.5 text-cp-text transition-[background-color,box-shadow] duration-150 focus-within:bg-cp-bg-container focus-within:shadow-cp-input-active motion-reduce:transition-none"
>
<BaseIconButton
:label="`减少${label}`"
size="sm"
variant="ghost"
class="size-7!"
:disabled="!canDecrease"
@click="stepBy(-1)"
>
<Minus class="size-3" />
</BaseIconButton>
<span class="inline-flex min-w-0 items-baseline justify-center gap-1 px-1">
<input
v-bind="controlAttrs"
:value="model"
type="number"
inputmode="decimal"
:aria-label="label"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
class="base-number-input__field w-9 min-w-0 border-0 bg-transparent p-0 text-right font-mono text-cp-xs font-bold tabular-nums text-cp-text outline-none disabled:text-cp-text-disabled"
@input="updateModel"
@blur="restoreValue"
>
<span
v-if="unit"
class="shrink-0 text-[9px] font-emphasis text-cp-text-quaternary"
aria-hidden="true"
>
{{ unit }}
</span>
</span>
<BaseIconButton
:label="`增加${label}`"
size="sm"
variant="ghost"
class="size-7!"
:disabled="!canIncrease"
@click="stepBy(1)"
>
<Plus class="size-3" />
</BaseIconButton>
</div>
</template>
<style scoped>
/* 隐藏浏览器原生微调器,统一使用两侧的减号和加号。 */
.base-number-input__field {
appearance: textfield;
}
.base-number-input__field::-webkit-inner-spin-button,
.base-number-input__field::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
</style>
@@ -10,12 +10,12 @@ defineProps<{
class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-x-4 gap-y-3"
>
<div class="min-w-0 flex-1 basis-64">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-primary">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-text">
{{ title }}
</h1>
<p
v-if="description || $slots.description"
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-cp-secondary"
class="mt-2.5 mb-0 flex items-center gap-2 text-cp-xl leading-[1.15] font-semibold text-cp-text-secondary"
>
<slot name="description">
{{ description }}
+1 -1
View File
@@ -56,7 +56,7 @@ const hoverOpenTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-cp-overlay border-0 bg-cp-surface text-left text-cp-primary shadow-cp-popover',
'fixed z-50 overflow-visible rounded-cp-lg border-0 bg-cp-bg-container text-left text-cp-text shadow-cp',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
value: string
name: string
label: string
disabled?: boolean
showLabel?: boolean
}>(),
{
disabled: false,
showLabel: false,
},
)
defineSlots<{
default?: () => unknown
}>()
const model = defineModel<string>({ required: true })
const checked = computed(() => model.value === props.value)
const indicatorClasses = computed(() => [
'relative inline-grid size-4 shrink-0 place-items-center rounded-full transition-colors duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
: checked.value
? 'bg-cp-primary text-(--cp-button-primary-color)'
: 'bg-cp-fill-tertiary text-transparent group-hover:bg-cp-bg-text-active',
])
</script>
<template>
<!-- The native radio is nested so the full label remains one accessible click target. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
>
<input
v-model="model"
type="radio"
class="peer sr-only"
:name="name"
:value="value"
:aria-label="label"
:disabled="disabled"
>
<span
:class="indicatorClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
aria-hidden="true"
>
<span class="size-1.5 rounded-full bg-current" />
</span>
<span v-if="$slots.default" class="min-w-0 flex-1">
<slot />
</span>
<span v-else-if="showLabel" class="text-cp leading-none font-emphasis">
{{ label }}
</span>
</label>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
label: string
min?: number
max?: number
step?: number
unit?: string
disabled?: boolean
}>(),
{
min: 0,
max: 100,
step: 1,
unit: undefined,
disabled: false,
},
)
const model = defineModel<number>({ required: true })
const rangeStyle = computed<CSSProperties>(() => {
const span = props.max - props.min
const progress = span > 0 ? ((model.value - props.min) / span) * 100 : 0
return {
'--base-range-progress': `${Math.min(100, Math.max(0, progress))}%`,
}
})
const valueText = computed(() => `${model.value}${props.unit ?? ''}`)
function updateModel(event: Event) {
const value = (event.target as HTMLInputElement).valueAsNumber
if (Number.isFinite(value))
model.value = value
}
</script>
<template>
<input
:value="model"
type="range"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
:aria-label="label"
:aria-valuetext="valueText"
class="base-range h-5 w-full"
:style="rangeStyle"
@input="updateModel"
>
</template>
<style scoped>
.base-range {
appearance: none;
cursor: pointer;
background: transparent;
outline: none;
}
.base-range::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--cp-color-primary) 0 var(--base-range-progress),
var(--cp-color-fill-secondary) var(--base-range-progress) 100%
);
}
.base-range::-webkit-slider-thumb {
width: 14px;
height: 14px;
margin-top: -5px;
appearance: none;
border: 2px solid var(--cp-color-bg-container);
border-radius: 999px;
background: var(--cp-color-primary);
transition:
box-shadow 150ms ease,
transform 150ms ease;
}
.base-range::-moz-range-track {
height: 4px;
border-radius: 999px;
background: var(--cp-color-fill-secondary);
}
.base-range::-moz-range-progress {
height: 4px;
border-radius: 999px;
background: var(--cp-color-primary);
}
.base-range::-moz-range-thumb {
width: 10px;
height: 10px;
border: 2px solid var(--cp-color-bg-container);
border-radius: 999px;
background: var(--cp-color-primary);
transition:
box-shadow 150ms ease,
transform 150ms ease;
}
.base-range:hover::-webkit-slider-thumb {
transform: scale(1.08);
}
.base-range:hover::-moz-range-thumb {
transform: scale(1.08);
}
.base-range:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 3px var(--cp-color-primary-bg-hover);
}
.base-range:focus-visible::-moz-range-thumb {
box-shadow: 0 0 0 3px var(--cp-color-primary-bg-hover);
}
.base-range:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@media (prefers-reduced-motion: reduce) {
.base-range::-webkit-slider-thumb {
transition: none;
}
.base-range::-moz-range-thumb {
transition: none;
}
}
</style>
@@ -9,12 +9,14 @@ const props = withDefaults(
height?: string
horizontal?: boolean
vertical?: boolean
alwaysVisible?: boolean
}>(),
{
maxHeight: undefined,
height: undefined,
horizontal: false,
vertical: true,
alwaysVisible: false,
},
)
@@ -64,10 +66,10 @@ const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollba
const canScrollY = computed(() => thumbHeight.value > 0)
const canScrollX = computed(() => horizontalThumbWidth.value > 0)
const verticalScrollbarVisible = computed(
() => dragging.value || verticalTrackHovering.value || visible.value,
() => props.alwaysVisible || dragging.value || verticalTrackHovering.value || visible.value,
)
const horizontalScrollbarVisible = computed(
() => horizontalDragging.value || horizontalTrackHovering.value || visible.value,
() => props.alwaysVisible || horizontalDragging.value || horizontalTrackHovering.value || visible.value,
)
const thumbStyle = computed(() => ({
height: `${thumbHeight.value}px`,
@@ -510,8 +512,8 @@ defineExpose({
]"
>
<div
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb-bg) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover-bg)"
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover-bg)' : ''"
:style="thumbStyle"
@pointerdown="handleThumbPointerDown"
/>
@@ -528,8 +530,8 @@ defineExpose({
]"
>
<div
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb-bg) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover-bg)"
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover-bg)' : ''"
:style="horizontalThumbStyle"
@pointerdown="handleHorizontalThumbPointerDown"
/>
@@ -549,6 +551,6 @@ defineExpose({
}
.base-scrollbar-track-y {
top: var(--cp-scrollbar-track-top, 0.25rem);
top: var(--cp-scrollbar-track-inset-block-start, 0.25rem);
}
</style>
@@ -45,13 +45,13 @@ const indicatorStyle = computed(() => ({
const rootClasses: Record<SegmentedSize, string> = {
sm: 'h-cp-control-sm',
md: 'h-cp-control-md',
md: 'h-cp-control',
lg: 'h-cp-control-lg',
}
const optionClasses: Record<SegmentedSize, string> = {
sm: 'h-6.5 px-2 text-[11px]',
sm: 'h-6.5 px-2 text-cp-xs',
md: 'h-8 px-3 text-xs',
lg: 'h-9.5 px-4 text-[13px]',
lg: 'h-9.5 px-4 text-cp',
}
function enabledIndexes() {
@@ -97,7 +97,7 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
<template>
<div
class="relative inline-grid items-center rounded-cp-control bg-cp-muted p-0.75"
class="relative inline-grid items-center rounded-cp bg-cp-fill-tertiary p-0.75"
:class="rootClasses[size]"
:style="gridStyle"
role="radiogroup"
@@ -106,20 +106,20 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
>
<span
v-if="options.length > 0"
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp-control bg-cp-surface shadow-cp-control transition-transform duration-200 ease-out motion-reduce:transition-none"
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp bg-cp-bg-container shadow-cp-tertiary transition-transform duration-200 ease-out motion-reduce:transition-none"
:style="indicatorStyle"
aria-hidden="true"
/>
<button
v-for="(option, index) in options"
:key="option.value"
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp-control border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-accent-border motion-reduce:transition-none"
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
:class="[
optionClasses[size],
model === option.value
? 'text-cp-primary'
: 'text-cp-secondary hover:text-cp-primary',
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-secondary' : undefined,
? 'text-cp-text'
: 'text-cp-text-secondary hover:text-cp-text',
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-text-secondary' : undefined,
display === 'icon' ? 'px-0' : undefined,
]"
type="button"
+22 -22
View File
@@ -76,18 +76,18 @@ const sizeConfig: Record<
> = {
md: {
trigger:
'h-cp-control-md px-3.5 pr-9 text-[13px] rounded-cp-control',
option: 'h-8.5 px-3 text-[13px]',
'h-cp-control px-3.5 pr-9 text-cp rounded-cp',
option: 'h-8.5 px-3 text-cp',
icon: 16,
},
sm: {
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp-control',
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp',
option: 'h-8 px-2.5 text-xs',
icon: 14,
},
lg: {
trigger: 'h-cp-control-lg px-4 pr-10 text-[14px] rounded-cp-control',
option: 'h-10 px-3.5 text-[14px]',
trigger: 'h-cp-control-lg px-4 pr-10 text-cp-lg rounded-cp',
option: 'h-10 px-3.5 text-cp-lg',
icon: 17,
},
}
@@ -98,20 +98,20 @@ const triggerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
sizeConfig[props.size].trigger,
props.disabled
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
: invalid.value
? 'cursor-pointer bg-(--cp-input-error-soft-bg) text-cp-danger-text shadow-cp-input-error'
? 'cursor-pointer bg-(--cp-input-error-active-bg) text-cp-error-text shadow-cp-input-error-active'
: open.value
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-cp-primary shadow-cp-input-focus'
? 'cursor-pointer bg-(--cp-input-active-bg) text-cp-text shadow-cp-input-active'
: [
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-cp-input-focus',
'cursor-pointer bg-[var(--cp-input-bg)] text-cp-text',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover',
'focus-visible:bg-(--cp-input-active-bg) focus-visible:shadow-cp-input-active',
],
])
const popoverClasses = computed(() => [
'fixed z-50 flex flex-col gap-1 rounded-cp-overlay border-0 bg-cp-surface p-1 shadow-cp-popover',
'fixed z-50 flex flex-col gap-1 rounded-cp-lg border-0 bg-cp-bg-container p-1 shadow-cp',
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
])
@@ -255,15 +255,15 @@ function handleTriggerKeydown(event: KeyboardEvent) {
function optionClasses(option: SelectOption, index: number) {
return [
'flex w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
'flex w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
sizeConfig[props.size].option,
option.disabled
? 'cursor-not-allowed bg-transparent text-cp-disabled-text'
? 'cursor-not-allowed bg-transparent text-cp-text-disabled'
: option.value === model.value
? 'cursor-pointer bg-cp-info-bg text-cp-info-text'
? 'cursor-pointer bg-cp-control-item-bg-active text-cp-primary-text'
: activeIndex.value === index
? 'cursor-pointer bg-cp-default-hover text-cp-primary'
: 'cursor-pointer bg-transparent text-cp-primary hover:bg-cp-default-hover',
? 'cursor-pointer bg-cp-bg-text-hover text-cp-text'
: 'cursor-pointer bg-transparent text-cp-text hover:bg-cp-bg-text-hover',
]
}
@@ -316,10 +316,10 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
:class="
disabled
? 'text-cp-disabled-icon'
? 'text-cp-text-disabled'
: open
? 'rotate-180 text-cp-info'
: 'text-cp-muted-text'
? 'rotate-180 text-cp-primary-text'
: 'text-cp-text-quaternary'
"
:size="sizeConfig[size].icon"
aria-hidden="true"
@@ -346,7 +346,7 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
>
<div
v-if="options.length === 0"
class="flex h-8.5 items-center rounded-cp-control-sm px-3 text-[13px] leading-none font-emphasis text-cp-muted-text"
class="flex h-8.5 items-center rounded-cp-sm px-3 text-cp leading-none font-emphasis text-cp-text-quaternary"
>
{{ emptyText }}
</div>
@@ -369,7 +369,7 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
<Check
v-if="option.value === model"
class="shrink-0 text-cp-info"
class="shrink-0 text-cp-primary-text"
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
aria-hidden="true"
/>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
type SkeletonShape = 'text' | 'block' | 'circle'
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
shape?: SkeletonShape
animated?: boolean
}>(),
{
as: 'span',
shape: 'block',
animated: true,
},
)
const shapeClasses: Record<SkeletonShape, string> = {
text: 'h-3 rounded-full',
block: 'rounded-cp',
circle: 'rounded-full',
}
const classes = computed(() => [
'block bg-cp-fill-secondary',
shapeClasses[props.shape],
props.animated ? 'motion-safe:animate-cp-skeleton motion-reduce:animate-none' : undefined,
])
</script>
<template>
<component
:is="as"
aria-hidden="true"
:class="classes"
/>
</template>
+6 -6
View File
@@ -18,10 +18,10 @@ const model = defineModel<boolean>({ default: false })
const trackClasses = computed(() => [
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180 motion-reduce:transition-none',
props.disabled
? 'bg-cp-disabled shadow-none'
? 'bg-cp-bg-container-disabled shadow-none'
: model.value
? 'bg-cp-accent shadow-cp-control group-hover:bg-cp-accent-hover'
: 'bg-cp-muted shadow-cp-control group-hover:bg-cp-default-active',
? 'bg-cp-primary shadow-cp-tertiary group-hover:bg-cp-primary-hover'
: 'bg-cp-fill-tertiary shadow-cp-tertiary group-hover:bg-cp-bg-text-active',
])
</script>
@@ -42,14 +42,14 @@ const trackClasses = computed(() => [
>
<span
:class="trackClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
aria-hidden="true"
>
<span
class="size-5 rounded-full bg-cp-surface shadow-cp-control transition-transform duration-180 ease-out motion-reduce:transition-none"
class="size-5 rounded-full bg-cp-bg-container shadow-cp-tertiary transition-transform duration-180 ease-out motion-reduce:transition-none"
:class="model ? 'translate-x-5' : 'translate-x-0'"
/>
</span>
<span v-if="showLabel" class="text-[13px] font-emphasis text-cp-primary">{{ label }}</span>
<span v-if="showLabel" class="text-cp font-emphasis text-cp-text">{{ label }}</span>
</label>
</template>
@@ -51,10 +51,10 @@ function goToPage(page: number) {
function paginationPageClass(page: number) {
return [
'inline-flex size-8 items-center justify-center rounded-cp-control border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
'inline-flex size-8 items-center justify-center rounded-cp border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
page === currentPage.value
? 'cursor-default bg-cp-accent text-cp-accent-on'
: 'cursor-pointer bg-cp-subtle text-cp-primary hover:bg-cp-default-hover focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
? 'cursor-default bg-(--cp-button-primary-bg) text-(--cp-button-primary-color)'
: 'cursor-pointer bg-cp-fill-quaternary text-cp-text hover:bg-cp-bg-text-hover focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
]
}
</script>
@@ -64,7 +64,7 @@ function paginationPageClass(page: number) {
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
>
<div
class="flex min-w-0 items-center gap-2.5 text-[12px] font-emphasis text-cp-secondary"
class="flex min-w-0 items-center gap-2.5 text-cp-sm font-emphasis text-cp-text-secondary"
>
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
</div>
@@ -93,7 +93,7 @@ function paginationPageClass(page: number) {
<template v-for="(item, index) in pagerItems" :key="`${item}-${index}`">
<span
v-if="item === 'ellipsis'"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-muted-text"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-text-quaternary"
>
…
</span>
@@ -83,7 +83,7 @@ const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
size: 'lg',
align: 'left',
truncate: true,
contentClass: 'text-cp-secondary',
contentClass: 'text-cp-text-secondary',
},
status: {
size: 'md',
@@ -94,25 +94,25 @@ const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
size: 'md',
align: 'right',
truncate: false,
contentClass: 'font-mono tabular-nums text-cp-secondary',
contentClass: 'font-mono tabular-nums text-cp-text-secondary',
},
datetime: {
size: 'xl',
align: 'left',
truncate: false,
contentClass: 'whitespace-nowrap font-mono text-[12px] tabular-nums text-cp-secondary',
contentClass: 'whitespace-nowrap font-mono text-cp-sm tabular-nums text-cp-text-secondary',
},
mono: {
size: 'xl',
align: 'left',
truncate: true,
contentClass: 'font-mono text-[12px] font-emphasis',
contentClass: 'font-mono text-cp-sm font-emphasis',
},
index: {
size: 'xs',
align: 'center',
truncate: false,
contentClass: 'font-mono tabular-nums text-cp-secondary',
contentClass: 'font-mono tabular-nums text-cp-text-secondary',
},
selection: {
size: 'xs',
@@ -85,14 +85,14 @@ watch([() => displayRows.value.length, () => props.columns], async () => {
})
const headerRowClass = computed(() => [
props.density === 'compact' ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
'font-bold text-cp-secondary',
props.density === 'compact' ? 'h-8 text-cp-xs' : 'h-10 text-cp-sm',
'font-bold text-cp-text-secondary',
])
const bodyRowClass = computed(() =>
props.density === 'compact' ? 'h-cp-table-row-min-compact' : 'h-cp-table-row-min',
props.density === 'compact' ? 'h-cp-table-row-sm' : 'h-cp-table-row',
)
const cellPaddingClass = computed(() => props.density === 'compact' ? 'px-3' : 'px-4')
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-[12px]' : 'text-[13px]')
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-cp-sm' : 'text-cp')
const bodyCellFrameClass = computed(() => props.density === 'compact'
? 'border-y-2 border-transparent bg-clip-padding'
: 'border-y-[3px] border-transparent bg-clip-padding')
@@ -100,8 +100,8 @@ const firstRowTopGapClass = computed(() =>
props.density === 'compact' ? 'border-t-4' : 'border-t-[6px]',
)
const bodyCellContentClass = computed(() => props.density === 'compact'
? 'min-h-[calc(var(--cp-table-row-min-height-compact)-4px)]'
: 'min-h-[calc(var(--cp-table-row-min-height)-6px)]')
? 'min-h-[calc(var(--cp-table-row-height-sm)-4px)]'
: 'min-h-[calc(var(--cp-table-row-height)-6px)]')
function getRowKey(row: Row, index: number) {
if (typeof props.rowKey === 'function')
@@ -120,16 +120,16 @@ function isRowExpanded(row: Row, index: number) {
function rowBackgroundClass(row: Row, index: number) {
if (isRowSelected(row, index))
return 'bg-cp-tertiary-bg'
return 'bg-(--cp-table-row-selected-bg)'
if (index % 2 === 1)
return 'bg-cp-subtle'
return 'bg-cp-surface'
return 'bg-(--cp-table-row-stripe-bg)'
return 'bg-(--cp-table-row-bg)'
}
function rowClass() {
return [
bodyRowClass.value,
'hover:[&>td]:bg-cp-default-hover',
'hover:[&>td]:bg-(--cp-table-row-hover-bg)',
]
}
@@ -142,8 +142,8 @@ function stickyClass(column: ResolvedTableColumn<Row>, header = false) {
header ? 'z-40' : 'z-20',
showShadow
? column.sticky === 'left'
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
? 'shadow-[8px_0_14px_-14px_var(--cp-color-shadow)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-color-shadow)]'
: undefined,
]
}
@@ -194,8 +194,8 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
ref="scrollbar"
class="min-h-0 flex-1"
:class="density === 'compact'
? '[--cp-scrollbar-track-top:2.25rem]'
: '[--cp-scrollbar-track-top:2.75rem]'"
? '[--cp-scrollbar-track-inset-block-start:2.25rem]'
: '[--cp-scrollbar-track-inset-block-start:2.75rem]'"
horizontal
@scroll="handleTableScroll"
>
@@ -216,12 +216,12 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<th
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
class="sticky top-0 z-30 whitespace-nowrap bg-cp-muted shadow-[0_10px_16px_-18px_var(--cp-shadow-sticky)]"
class="sticky top-0 z-30 whitespace-nowrap bg-(--cp-table-header-bg) shadow-[0_10px_16px_-18px_var(--cp-color-shadow)]"
:class="[
column.paddingClass ?? cellPaddingClass,
alignClass(column),
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control' : undefined,
columnIndex === 0 ? 'rounded-l-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'pr-6' : undefined,
stickyClass(column, true),
]"
@@ -233,7 +233,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<button
v-if="column.sortable"
type="button"
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-primary focus-visible:text-cp-accent motion-reduce:transition-none"
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-text focus-visible:text-cp-primary-text motion-reduce:transition-none"
:aria-label="sortButtonLabel(column)"
:title="sortButtonLabel(column)"
@click="toggleColumnSort(column)"
@@ -244,12 +244,12 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<span class="inline-flex shrink-0 -translate-y-px flex-col gap-px" aria-hidden="true">
<Triangle
class="size-1.25 fill-current"
:class="columnSortDirection(column) === 'asc' ? 'text-cp-accent' : 'text-cp-tertiary'"
:class="columnSortDirection(column) === 'asc' ? 'text-cp-primary-text' : 'text-cp-text-tertiary'"
:stroke-width="0"
/>
<Triangle
class="size-1.25 rotate-180 fill-current"
:class="columnSortDirection(column) === 'desc' ? 'text-cp-accent' : 'text-cp-tertiary'"
:class="columnSortDirection(column) === 'desc' ? 'text-cp-primary-text' : 'text-cp-text-tertiary'"
:stroke-width="0"
/>
</span>
@@ -275,8 +275,8 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
column.contentClass,
alignClass(column),
index === 0 ? firstRowTopGapClass : undefined,
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control pr-6' : undefined,
columnIndex === 0 ? 'rounded-l-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp pr-6' : undefined,
stickyClass(column),
rowBackgroundClass(row, index),
]"
@@ -300,7 +300,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<tr v-if="isRowExpanded(row, index)">
<td
:colspan="computedColumns.length"
class="rounded-cp-control border-y-transparent bg-cp-subtle bg-clip-padding p-0"
class="rounded-cp border-y-transparent bg-cp-fill-quaternary bg-clip-padding p-0"
:class="bodyCellFrameClass"
>
<slot name="expanded" :row="row" :index="index" />
@@ -47,17 +47,17 @@ const controlAttrs = computed(() => Object.fromEntries(
const sizeClasses: Record<TextareaSize, string> = {
sm: 'px-3 py-2.5 text-xs',
md: 'px-3.5 py-3 text-[13px]',
lg: 'px-4 py-3.5 text-[14px]',
md: 'px-3.5 py-3 text-cp',
lg: 'px-4 py-3.5 text-cp-lg',
}
const textareaClasses = computed(() => [
'cp-scrollbar w-full resize-none rounded-cp-control border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-muted-text motion-reduce:transition-none',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover focus:bg-(--cp-input-soft-bg-focus) focus:shadow-cp-input-focus',
'disabled:cursor-not-allowed disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'cp-scrollbar w-full resize-none rounded-cp border-0 bg-[var(--cp-input-bg)] text-cp-text shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-text-quaternary motion-reduce:transition-none',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover focus:bg-(--cp-input-active-bg) focus:shadow-cp-input-active',
'disabled:cursor-not-allowed disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
'leading-[1.55] font-emphasis',
sizeClasses[props.size],
invalid.value ? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error' : undefined,
invalid.value ? 'bg-(--cp-input-error-active-bg) shadow-cp-input-error-active' : undefined,
])
</script>
@@ -24,8 +24,8 @@ const colorClasses = {
icon: 'text-cp-success',
},
error: {
iconBg: 'bg-cp-danger-bg',
icon: 'text-cp-danger',
iconBg: 'bg-cp-error-bg',
icon: 'text-cp-error',
},
warning: {
iconBg: 'bg-cp-warning-bg',
@@ -45,12 +45,12 @@ const colorClasses = {
<article
v-for="message in toast.messages"
:key="message.id"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-surface bg-cp-surface px-3.5 py-3 shadow-cp-popover"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-card bg-cp-bg-container px-3.5 py-3 shadow-cp"
:role="message.type === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
<div
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp-control"
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp"
:class="colorClasses[message.type].iconBg"
>
<component
@@ -61,11 +61,11 @@ const colorClasses = {
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-cp-primary">
<p class="m-0 truncate text-cp leading-[1.15] font-bold text-cp-text">
{{ message.title ?? titleMap[message.type] }}
</p>
<p
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-secondary"
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-text-secondary"
>
{{ message.message }}
</p>
@@ -77,7 +77,7 @@ const colorClasses = {
:label="`关闭${message.title ?? titleMap[message.type]}通知`"
@click="toast.remove(message.id)"
>
<X :size="16" class="text-cp-muted-text" />
<X :size="16" class="text-cp-text-quaternary" />
</BaseIconButton>
</article>
</TransitionGroup>
+1 -24
View File
@@ -3,10 +3,8 @@ import type { EChartsOption } from 'echarts'
import type { EChartsType } from 'echarts/core'
import { useRafFn, useResizeObserver } from '@vueuse/core'
import { init } from 'echarts/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { useUiStore } from '@/stores/modules/ui'
import '@/plugins/echarts'
const props = withDefaults(
@@ -23,7 +21,6 @@ const chartElement = useTemplateRef<HTMLElement>('chart')
const chartOption = shallowRef<EChartsOption>(props.option)
const chart = shallowRef<EChartsType>()
const pendingInitElement = shallowRef<HTMLElement>()
const { themeRevision } = storeToRefs(useUiStore())
const style = computed(() => ({
height: `${props.height}px`,
@@ -71,18 +68,6 @@ function applyOption(option: EChartsOption) {
chart.value.setOption(option, true)
}
async function recreateChartAfterThemeChange() {
await nextTick()
requestAnimationFrame(() => {
const element = chartElement.value
if (!element)
return
dispose()
ensureChart(element)
resize()
})
}
function dispose() {
cancelPendingInit()
chart.value?.dispose()
@@ -104,14 +89,6 @@ watch(
{ flush: 'post' },
)
watch(
themeRevision,
() => {
recreateChartAfterThemeChange()
},
{ flush: 'post' },
)
watch(
chartElement,
(element) => {
+1 -1
View File
@@ -30,7 +30,7 @@ export function chartTooltipStyle(
fontFamily: options.fontFamily ?? 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: options.fontWeight ?? 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-box-shadow);',
}
if (!options.axisPointer)
+15 -15
View File
@@ -5,21 +5,21 @@ import { useThemeColor } from '@/composables/useThemeColor'
export function useChartPalette() {
const color = useThemeColor()
const palette = computed(() => ({
textPrimary: color('--cp-text-primary', '#0E1726'),
textSecondary: color('--cp-text-secondary', '#64748B'),
textMuted: color('--cp-text-muted', '#94A3B8'),
surface: color('--cp-bg-surface', '#FFFFFF'),
surfaceMuted: color('--cp-bg-muted', '#F1F5F9'),
grid: color('--cp-bg-muted', '#F1F5F9'),
divider: color('--cp-divider-subtle', '#E2E8F0'),
border: color('--cp-default-border', '#E2E8F0'),
pointer: color('--cp-default-border-hover', '#CBD5E1'),
info: color('--cp-info', '#2563EB'),
success: color('--cp-success', '#10B981'),
warning: color('--cp-warning', '#F59E0B'),
danger: color('--cp-danger', '#EF4444'),
normal: color('--cp-normal', '#0F9F9A'),
reasoning: color('--cp-reasoning', '#8B5CF6'),
textPrimary: color('--cp-color-text', '#0E1726'),
textSecondary: color('--cp-color-text-secondary', '#64748B'),
textMuted: color('--cp-color-text-quaternary', '#94A3B8'),
surface: color('--cp-color-bg-container', '#FFFFFF'),
surfaceMuted: color('--cp-color-fill-tertiary', '#F1F5F9'),
grid: color('--cp-color-fill-tertiary', '#F1F5F9'),
divider: color('--cp-color-split', '#E2E8F0'),
border: color('--cp-color-border-secondary', '#E2E8F0'),
pointer: color('--cp-color-border', '#CBD5E1'),
info: color('--cp-color-info', '#2563EB'),
success: color('--cp-color-success', '#10B981'),
warning: color('--cp-color-warning', '#F59E0B'),
danger: color('--cp-color-error', '#EF4444'),
normal: color('--cp-color-status-normal', '#0F9F9A'),
reasoning: color('--cp-color-reasoning', '#8B5CF6'),
}))
return { color, palette }
+25 -4
View File
@@ -1,12 +1,33 @@
import { storeToRefs } from 'pinia'
import type { InjectionKey, Ref } from 'vue'
import { useUiStore } from '@/stores/modules/ui'
import { storeToRefs } from 'pinia'
import { inject, provide } from 'vue'
import { useThemeStore } from '@/stores/modules/theme'
export type ThemeColorTokens = Readonly<Record<string, string | number | undefined>>
const themeColorTokensKey: InjectionKey<Readonly<Ref<ThemeColorTokens>>> = Symbol('theme-color-tokens')
export function provideThemeColorTokens(tokens: Readonly<Ref<ThemeColorTokens>>) {
provide(themeColorTokensKey, tokens)
}
// 主题感知的 CSS 变量读取:themeRevision 作为响应式依赖,主题切换后
// 引用该函数的 computed 会重算并读到新变量值。
// 引用该函数的 computed 会重算并读到新变量值;影子预览优先读取其局部 Token。
export function useThemeColor() {
const { themeRevision } = storeToRefs(useUiStore())
const scopedTokens = inject(themeColorTokensKey, null)
const { themeRevision } = storeToRefs(useThemeStore())
return (name: string, fallback: string) => {
if (scopedTokens) {
const scopedValue = scopedTokens.value[name]
if (typeof scopedValue === 'string' && scopedValue.trim())
return scopedValue.trim()
if (typeof scopedValue === 'number')
return String(scopedValue)
return fallback
}
void themeRevision.value
return readCssVariable(name, fallback)
}
+3 -3
View File
@@ -28,7 +28,7 @@ const OVERLAY_BASE_CLASS = [
'place-items-center',
'rounded-[inherit]',
].join(' ')
const OVERLAY_MASK_CLASS = 'bg-cp-surface/82 backdrop-blur-[4px]'
const OVERLAY_MASK_CLASS = 'bg-cp-bg-container/82 backdrop-blur-[4px]'
const PANEL_CLASS = [
'inline-flex',
'min-h-10',
@@ -41,14 +41,14 @@ const PANEL_CLASS = [
'text-xs',
'leading-[1.15]',
'font-heavy',
'text-cp-secondary',
'text-cp-text-secondary',
].join(' ')
const MARK_CLASS = [
'inline-block',
'size-4',
'rounded-full',
'animate-spin',
'bg-[radial-gradient(circle_at_center,var(--cp-bg-surface)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-normal)_0deg_105deg,var(--cp-info)_105deg_200deg,var(--cp-bg-muted)_200deg_360deg)]',
'bg-[radial-gradient(circle_at_center,var(--cp-color-bg-container)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-color-status-normal)_0deg_105deg,var(--cp-color-info)_105deg_200deg,var(--cp-color-fill-tertiary)_200deg_360deg)]',
].join(' ')
const LABEL_CLASS = 'whitespace-nowrap'
@@ -53,15 +53,15 @@ const linkItems = [
<div class="grid gap-5">
<section class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control bg-cp-subtle font-mono text-[15px] leading-none font-extrabold text-cp-primary"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp bg-cp-fill-quaternary font-mono text-cp-xl leading-none font-extrabold text-cp-text"
>
Z
</span>
<div class="min-w-0">
<p class="m-0 truncate text-[15px] leading-none font-heavy text-cp-primary">
<p class="m-0 truncate text-cp-xl leading-none font-heavy text-cp-text">
{{ author }}
</p>
<p class="mt-1.5 mb-0 text-[12px] leading-none font-emphasis text-cp-secondary">
<p class="mt-1.5 mb-0 text-cp-sm leading-none font-emphasis text-cp-text-secondary">
Built by Zyy · Codex Proxy RS
</p>
</div>
@@ -71,20 +71,20 @@ const linkItems = [
<div
v-for="item in linkItems"
:key="item.label"
class="group flex min-w-0 items-center justify-between gap-3 rounded-cp-control px-1 py-2.5 transition-colors hover:bg-cp-subtle"
class="group flex min-w-0 items-center justify-between gap-3 rounded-cp px-1 py-2.5 transition-colors hover:bg-cp-fill-quaternary"
>
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-8 shrink-0 items-center justify-center rounded-cp-control bg-cp-muted text-cp-primary"
class="inline-flex size-8 shrink-0 items-center justify-center rounded-cp bg-cp-fill-tertiary text-cp-text"
>
<component :is="item.icon" class="size-5" />
</span>
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 text-cp-xs leading-none font-heavy text-cp-text-quaternary">
{{ item.label }}
</p>
<p
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-bold text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-cp-sm leading-none font-bold text-cp-text"
:title="item.value"
>
{{ item.value }}
@@ -96,7 +96,7 @@ const linkItems = [
:href="item.href"
target="_blank"
rel="noreferrer"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-cp-control text-cp-muted-text transition-colors hover:bg-cp-muted hover:text-cp-info"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-cp text-cp-text-quaternary transition-colors hover:bg-cp-fill-tertiary hover:text-cp-link"
:aria-label="`打开 ${item.label}`"
>
<ExternalLink class="size-3.5" />
@@ -104,7 +104,7 @@ const linkItems = [
</div>
</section>
<p class="m-0 font-mono text-[11px] leading-none font-emphasis text-cp-muted-text">
<p class="m-0 font-mono text-cp-xs leading-none font-emphasis text-cp-text-quaternary">
{{ versionLine }}
</p>
</div>
+29 -26
View File
@@ -8,6 +8,7 @@ import {
LayoutDashboard,
LogOut,
Moon,
Palette,
PanelLeftClose,
PanelLeftOpen,
Settings,
@@ -29,11 +30,12 @@ import {
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppBrandMark from '@/components/AppBrandMark.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import { useAuthStore } from '@/stores/modules/auth'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useUiStore } from '@/stores/modules/ui'
import { useThemeStore } from '@/stores/modules/theme'
const props = withDefaults(
defineProps<{
@@ -56,10 +58,10 @@ const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const systemUpdateStore = useSystemUpdateStore()
const uiStore = useUiStore()
const themeStore = useThemeStore()
const { version, hasUpdate } = storeToRefs(systemUpdateStore)
const { effectiveTheme } = storeToRefs(uiStore)
const { toggleTheme } = uiStore
const { effectiveTheme } = storeToRefs(themeStore)
const { toggleTheme } = themeStore
const preferredMotion = usePreferredReducedMotion()
const navItems = [
@@ -68,6 +70,7 @@ const navItems = [
{ label: '分组管理', icon: FolderTree, path: '/account-groups' },
{ label: 'API 密钥', icon: KeyRound, path: '/api-keys' },
{ label: '使用统计', icon: ChartNoAxesColumn, path: '/usage' },
{ label: '主题设置', icon: Palette, path: '/theme' },
{ label: '系统设置', icon: Settings, path: '/settings' },
]
@@ -328,7 +331,7 @@ onBeforeUnmount(() => {
<template>
<aside
ref="sidebarEl"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-cp-surface px-4 shadow-cp-sidebar"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-layout-sider-bg) px-4 shadow-cp-layout-sider"
:class="[
mobile ? 'flex' : 'hidden min-[961px]:flex',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
@@ -341,35 +344,30 @@ onBeforeUnmount(() => {
<BaseMotionIcon
aria-hidden="true"
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp-control"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp"
>
<img
src="/favicon.svg"
alt=""
class="block size-11 select-none"
draggable="false"
>
<AppBrandMark class="block size-11 select-none" />
</BaseMotionIcon>
<span
v-show="brandLabelVisible"
ref="brandLabelEl"
class="grid min-w-33 content-center overflow-hidden"
>
<strong class="text-base leading-[1.1] font-heavy text-cp-primary">
<strong class="text-base leading-[1.1] font-heavy text-cp-text">
Codex Proxy
</strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-cp-secondary">
<span class="shrink-0 text-xs leading-none font-emphasis text-cp-text-secondary">
Rust build
</span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-cp-control-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-cp-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:class="[
hasUpdate
? 'bg-cp-success-bg text-cp-success-text hover:bg-cp-success-bg-hover'
: 'bg-cp-subtle text-cp-muted-text hover:bg-cp-muted hover:text-cp-secondary',
: 'bg-cp-fill-quaternary text-cp-text-quaternary hover:bg-cp-fill-tertiary hover:text-cp-text-secondary',
]"
:title="updateButtonLabel"
@click="openSystemUpdate"
@@ -388,7 +386,7 @@ onBeforeUnmount(() => {
>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp-control bg-cp-nav-active transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp bg-cp-menu-item-selected-bg transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span ref="navSignal" class="sidebar-active-signal absolute inset-y-0 left-0 w-2/3" />
@@ -397,16 +395,16 @@ onBeforeUnmount(() => {
v-for="item in navItems"
:key="item.label"
type="button"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp-control border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'bg-transparent font-bold text-cp-primary transition-none'
: 'bg-transparent font-bold text-cp-primary transition-colors duration-200'
? 'bg-transparent font-bold text-cp-text transition-none'
: 'bg-transparent font-bold text-cp-text transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-cp-secondary transition-none'
: 'bg-transparent font-semibold text-cp-secondary transition-colors duration-200 hover:bg-cp-subtle hover:text-cp-primary',
? 'bg-transparent font-semibold text-cp-text-secondary transition-none'
: 'bg-transparent font-semibold text-cp-text-secondary transition-colors duration-200 hover:bg-cp-fill-quaternary hover:text-cp-text',
]"
@click="navigate(item.path)"
>
@@ -422,11 +420,11 @@ onBeforeUnmount(() => {
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
<div
class="bg-cp-subtle"
class="bg-cp-fill-quaternary"
:class="
isCollapsed
? 'grid gap-1 rounded-cp-control p-1'
: 'flex h-11 items-center justify-between rounded-cp-overlay px-2'
? 'grid gap-1 rounded-cp p-1'
: 'flex h-11 items-center justify-between rounded-cp-lg px-2'
"
>
<span
@@ -505,6 +503,11 @@ onBeforeUnmount(() => {
<style scoped>
.sidebar-active-signal {
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--cp-info) 9%, transparent), transparent);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--cp-color-info) 9%, transparent),
transparent
);
}
</style>
@@ -220,10 +220,10 @@ onScopeDispose(() => {
>
<button
type="button"
class="inline-flex size-11.5 items-center justify-center rounded-cp-control border-0 bg-cp-surface text-cp-primary shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-cp-default-hover hover:text-cp-normal active:scale-95 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-page"
class="inline-flex size-11.5 items-center justify-center rounded-cp border-0 bg-cp-bg-container text-cp-text shadow-[0_18px_34px_-18px_var(--cp-color-shadow)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-cp-bg-text-hover hover:text-cp-status-normal active:scale-95 focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-layout"
:class="
isDragging
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-color-shadow)]'
: 'cursor-grab'
"
aria-label="打开侧边栏"
@@ -198,18 +198,18 @@ watch(
:dismissible="!updating && !restarting"
>
<div class="grid gap-3.5">
<section class="grid gap-4 rounded-cp-surface bg-cp-subtle px-4 py-4">
<section class="grid gap-4 rounded-cp-card bg-cp-fill-quaternary px-4 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 text-cp-xs leading-none font-heavy text-cp-text-quaternary">
Codex Proxy RS
</p>
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-cp-primary">
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-cp-text">
应用包更新
</p>
</div>
<span
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-heavy"
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-cp-sm font-heavy"
:class="presentation.status.badge"
>
<component
@@ -225,10 +225,10 @@ watch(
<div
v-for="item in presentation.summaryItems"
:key="item.key"
class="min-w-0 rounded-cp-control bg-cp-surface px-3 py-2.5"
class="min-w-0 rounded-cp bg-cp-bg-container px-3 py-2.5"
>
<div class="flex min-w-0 items-center justify-between gap-2">
<p class="m-0 truncate text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 truncate text-cp-xs leading-none font-heavy text-cp-text-quaternary">
{{ item.label }}
</p>
<a
@@ -236,14 +236,14 @@ watch(
:href="item.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-bold text-cp-info-text transition-colors hover:text-cp-info"
class="inline-flex shrink-0 items-center gap-1 text-cp-xs leading-none font-bold text-cp-link transition-colors hover:text-cp-link-hover"
>
发布页
<ExternalLink class="size-3" />
</a>
</div>
<p
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-bold text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-cp leading-none font-bold text-cp-text"
:title="item.title || item.value"
>
{{ item.value }}
@@ -253,7 +253,7 @@ watch(
<p
v-if="updateError || updateInfo?.warning"
class="m-0 rounded-cp-control bg-cp-danger-bg px-3 py-2 text-[12px] leading-normal font-bold text-cp-danger-text"
class="m-0 rounded-cp bg-cp-error-bg px-3 py-2 text-cp-sm leading-normal font-bold text-cp-error-text"
>
{{ updateError || updateInfo?.warning }}
</p>
@@ -261,13 +261,13 @@ watch(
<section
v-if="renderedReleaseNotes"
class="grid gap-2 rounded-cp-surface bg-cp-subtle px-4 py-3.5"
class="grid gap-2 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
<p class="m-0 text-[13px] font-heavy text-cp-primary">
<p class="m-0 text-cp font-heavy text-cp-text">
发布说明
</p>
<span class="font-mono text-[11px] font-emphasis text-cp-muted-text">
<span class="font-mono text-cp-xs font-emphasis text-cp-text-quaternary">
{{ presentation.releaseVersion }}
</span>
</div>
@@ -280,22 +280,22 @@ watch(
<section
v-if="showUpdateProgress"
class="overflow-hidden rounded-cp-surface bg-cp-subtle"
class="overflow-hidden rounded-cp-card bg-cp-fill-quaternary"
>
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="flex min-w-0 items-center gap-2">
<Terminal class="size-4 shrink-0 text-cp-success" />
<p class="m-0 text-[13px] leading-none font-heavy text-cp-primary">
<p class="m-0 text-cp leading-none font-heavy text-cp-text">
更新进度
</p>
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-subtle px-2 text-[11px] leading-none font-bold text-cp-secondary"
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-fill-quaternary px-2 text-cp-xs leading-none font-bold text-cp-text-secondary"
:title="updateStreamError || presentation.streamStatusLabel"
>
<i
class="size-1.5 rounded-full"
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-muted-text'"
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-text-quaternary'"
/>
{{ presentation.streamStatusLabel }}
</span>
@@ -310,16 +310,16 @@ watch(
<div
v-for="log in updateLogRows"
:key="log.id"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-cp-control bg-cp-surface px-3 py-2 font-mono text-[11px] leading-[1.55]"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-cp bg-cp-bg-container px-3 py-2 font-mono text-cp-xs leading-[1.55]"
>
<span class="tabular-nums text-cp-muted-text">{{ log.time }}</span>
<span class="tabular-nums text-cp-text-quaternary">{{ log.time }}</span>
<Circle
class="mt-1 size-2.5"
:class="log.classes.marker"
fill="currentColor"
/>
<p class="m-0 min-w-0 wrap-break-word" :class="log.classes.text">
<span v-if="log.step" class="mr-1 text-cp-muted-text">[{{ log.step }}]</span>
<span v-if="log.step" class="mr-1 text-cp-text-quaternary">[{{ log.step }}]</span>
{{ log.message }}
</p>
</div>
@@ -383,23 +383,23 @@ watch(
@confirm="handleConfirmUpdate"
>
<div class="grid gap-3">
<div class="grid gap-2 rounded-cp-control bg-cp-subtle p-3">
<div class="grid gap-2 rounded-cp bg-cp-fill-quaternary p-3">
<div
v-for="item in presentation.confirmRows"
:key="item.key"
class="flex min-w-0 items-center justify-between gap-3"
>
<span class="text-[12px] leading-none font-bold text-cp-muted-text">
<span class="text-cp-sm leading-none font-bold text-cp-text-quaternary">
{{ item.label }}
</span>
<span
class="truncate font-mono text-[13px] leading-none font-heavy text-cp-primary"
class="truncate font-mono text-cp leading-none font-heavy text-cp-text"
>
{{ item.value }}
</span>
</div>
</div>
<p class="m-0 text-[12px] leading-relaxed font-emphasis text-cp-muted-text">
<p class="m-0 text-cp-sm leading-relaxed font-emphasis text-cp-text-quaternary">
点击确认后弹窗会关闭,并按远端最新目标版本开始更新
</p>
</div>
@@ -408,7 +408,7 @@ watch(
<style scoped>
.release-notes {
color: var(--cp-text-primary);
color: var(--cp-color-text);
font-size: 12px;
font-weight: var(--font-weight-emphasis);
line-height: 1.65;
@@ -432,7 +432,7 @@ watch(
.release-notes :deep(h3),
.release-notes :deep(h4) {
margin: 12px 0 6px;
color: var(--cp-text-primary);
color: var(--cp-color-text);
font-size: 12px;
font-weight: var(--font-weight-heavy);
line-height: 1.4;
@@ -453,19 +453,19 @@ watch(
}
.release-notes :deep(a) {
color: var(--cp-info-text);
color: var(--cp-color-link);
font-weight: 700;
text-decoration: none;
}
.release-notes :deep(a:hover) {
color: var(--cp-info);
color: var(--cp-color-link-hover);
}
.release-notes :deep(code) {
border-radius: 5px;
background: var(--cp-bg-muted);
color: var(--cp-text-primary);
background: var(--cp-color-fill-tertiary);
color: var(--cp-color-text);
font-family: var(--font-mono);
font-size: 11px;
padding: 1px 5px;
@@ -474,8 +474,8 @@ watch(
.release-notes :deep(pre) {
margin: 8px 0;
overflow-x: auto;
border-radius: var(--cp-radius-control);
background: var(--cp-bg-surface);
border-radius: var(--cp-border-radius);
background: var(--cp-color-bg-container);
padding: 8px 10px;
}
@@ -486,8 +486,8 @@ watch(
.release-notes :deep(blockquote) {
margin: 8px 0;
border-left: 3px solid var(--cp-divider-subtle);
color: var(--cp-text-secondary);
border-left: 3px solid var(--cp-color-split);
color: var(--cp-color-text-secondary);
padding-left: 10px;
}
</style>
@@ -36,9 +36,9 @@ export function resolveSystemUpdateLogClasses(level: string) {
case 'warning':
return { marker: 'text-cp-warning', text: 'text-cp-warning' }
case 'error':
return { marker: 'text-cp-danger', text: 'text-cp-danger' }
return { marker: 'text-cp-error', text: 'text-cp-error' }
default:
return { marker: 'text-cp-info', text: 'text-cp-primary' }
return { marker: 'text-cp-info', text: 'text-cp-text' }
}
}
@@ -55,8 +55,8 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-cp-danger-bg text-cp-danger-text',
iconClass: 'text-cp-danger',
badge: 'bg-cp-error-bg text-cp-error-text',
iconClass: 'text-cp-error',
}
}
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
@@ -70,8 +70,8 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
return {
label: '未检查',
icon: CheckCircle2,
badge: 'bg-cp-muted text-cp-secondary',
iconClass: 'text-cp-muted-text',
badge: 'bg-cp-fill-tertiary text-cp-text-secondary',
iconClass: 'text-cp-text-quaternary',
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ watch(
</script>
<template>
<div class="relative flex h-dvh overflow-hidden bg-cp-page">
<div class="relative flex h-dvh overflow-hidden bg-cp-bg-layout">
<AppSidebar
:collapsed="sidebarCollapsed"
@toggle="toggleSidebar"
+11 -6
View File
@@ -5,14 +5,19 @@ import { loading } from './directives/loading'
import { authPlugin } from './plugins/auth'
import { router } from './router'
import { pinia } from './stores'
import { useThemeStore } from './stores/modules/theme'
import '@fontsource-variable/inter'
import '@fontsource-variable/jetbrains-mono'
import './styles/index.css'
createApp(App)
.directive('loading', loading)
.use(pinia)
.use(router)
.use(authPlugin)
.mount('#app')
const app = createApp(App)
app.directive('loading', loading)
app.use(pinia)
useThemeStore(pinia).initializeTheme()
app.use(router)
app.use(authPlugin)
app.mount('#app')
+11 -1
View File
@@ -36,10 +36,20 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/views/usage/index.vue'),
},
{
path: 'settings/:section?',
path: 'theme',
name: 'theme',
component: () => import('@/views/theme/index.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/settings/index.vue'),
},
{
path: 'settings/backup',
name: 'settings-backup',
component: () => import('@/views/settings/index.vue'),
},
],
},
{
+330
View File
@@ -0,0 +1,330 @@
// @env browser
import type {
ResolvedTheme,
ThemeColorId,
ThemeCustomization,
ThemeMode,
} from '@/theme'
import { usePreferredDark, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import {
applyResolvedTheme,
DEFAULT_CUSTOM_THEME_COLOR,
DEFAULT_THEME_COLOR,
DEFAULT_THEME_MODE,
isThemeColorId,
isThemeMode,
normalizeThemeCustomization,
resolveTheme,
resolveThemeName,
} from '@/theme'
import { normalizeHexColor } from '@/utils/color'
interface ThemeTransitionOrigin {
x: number
y: number
}
interface ThemeConfigurationInput {
mode: ThemeMode
color: ThemeColorId
customColor: string
customization: ThemeCustomization
}
interface ViewTransition {
ready: Promise<void>
finished: Promise<void>
}
type ViewTransitionDocument = Document & {
startViewTransition?: (callback: () => void) => ViewTransition
}
export const useThemeStore = defineStore(
'theme',
() => {
const themeMode = shallowRef<ThemeMode>(DEFAULT_THEME_MODE)
const themeColor = shallowRef<ThemeColorId>(DEFAULT_THEME_COLOR)
const customThemeColor = shallowRef(DEFAULT_CUSTOM_THEME_COLOR)
const themeCustomization = ref<ThemeCustomization>({})
const themeRevision = shallowRef(0)
const themeTransitioning = shallowRef(false)
const preferredDark = usePreferredDark()
const preferredMotion = usePreferredReducedMotion()
let appliedThemeSignature: string | undefined
let themeTransitionOrigin: ThemeTransitionOrigin | undefined
let themeTransitionRequested = false
const effectiveTheme = computed(() => resolveThemeName(themeMode.value, preferredDark.value))
const resolvedTheme = computed(() =>
resolveTheme(
effectiveTheme.value,
themeColor.value,
customThemeColor.value,
themeCustomization.value,
),
)
const { start: startFallbackTransitionTimer, stop: stopFallbackTransitionTimer } = useTimeoutFn(
() => {
document.documentElement.classList.remove('theme-fallback-transition')
themeTransitioning.value = false
},
180,
{ immediate: false },
)
watch(resolvedTheme, theme => applyTheme(theme))
function initializeTheme(): void {
normalizePersistedTheme()
applyTheme(resolvedTheme.value)
}
function normalizePersistedTheme(): void {
if (!isThemeMode(themeMode.value))
themeMode.value = DEFAULT_THEME_MODE
if (!isThemeColorId(themeColor.value))
themeColor.value = DEFAULT_THEME_COLOR
customThemeColor.value = normalizeHexColor(customThemeColor.value) ?? DEFAULT_CUSTOM_THEME_COLOR
themeCustomization.value = normalizeThemeCustomization(themeCustomization.value)
}
function applyTheme(theme: ResolvedTheme): void {
if (typeof document === 'undefined')
return
const signature = themeSignature(theme)
if (appliedThemeSignature === signature) {
clearThemeTransitionRequest()
return
}
if (
appliedThemeSignature === undefined
|| !themeTransitionRequested
|| preferredMotion.value === 'reduce'
|| themeTransitioning.value
) {
commitTheme(theme)
clearThemeTransitionRequest()
return
}
runThemeTransition(theme)
clearThemeTransitionRequest()
}
function commitTheme(theme: ResolvedTheme): void {
applyResolvedTheme(document.documentElement, theme)
appliedThemeSignature = themeSignature(theme)
themeRevision.value += 1
}
function runThemeTransition(theme: ResolvedTheme): void {
const transitionDocument = document as ViewTransitionDocument
const shrinkingDarkLayer
= document.documentElement.dataset.theme === 'dark' && theme.name === 'light'
if (!transitionDocument.startViewTransition) {
runFallbackThemeTransition(theme)
return
}
const origin = themeTransitionOrigin ?? {
x: window.innerWidth - 44,
y: 44,
}
const maxX = Math.max(origin.x, window.innerWidth - origin.x)
const maxY = Math.max(origin.y, window.innerHeight - origin.y)
const radius = Math.hypot(maxX, maxY)
document.documentElement.classList.toggle('theme-view-transition-shrink', shrinkingDarkLayer)
themeTransitioning.value = true
let transition: ViewTransition
try {
transition = transitionDocument.startViewTransition(() => commitTheme(theme))
}
catch {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
runFallbackThemeTransition(theme)
return
}
let cleaned = false
const cleanupTransition = (): void => {
if (cleaned)
return
cleaned = true
requestAnimationFrame(() => {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
})
}
const cleanupTimer = window.setTimeout(cleanupTransition, 1200)
void transition.finished.catch(() => undefined)
let activeAnimation: Animation | undefined
void transition.ready
.then(() => {
const animation = document.documentElement.animate(
{
clipPath: shrinkingDarkLayer
? [
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
`circle(0px at ${origin.x}px ${origin.y}px)`,
]
: [
`circle(0px at ${origin.x}px ${origin.y}px)`,
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
],
},
{
duration: 420,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both',
pseudoElement: shrinkingDarkLayer
? '::view-transition-old(root)'
: '::view-transition-new(root)',
},
)
activeAnimation = animation
return animation.finished.catch(() => undefined)
})
.catch(() => undefined)
.finally(() => {
activeAnimation?.cancel()
window.clearTimeout(cleanupTimer)
cleanupTransition()
})
}
function runFallbackThemeTransition(theme: ResolvedTheme): void {
themeTransitioning.value = true
stopFallbackTransitionTimer()
document.documentElement.classList.add('theme-fallback-transition')
commitTheme(theme)
startFallbackTransitionTimer()
}
function setThemeMode(mode: ThemeMode, event?: MouseEvent): void {
if (mode === themeMode.value) {
clearThemeTransitionRequest()
return
}
requestThemeTransition(event)
themeMode.value = mode
}
function setThemeColor(color: ThemeColorId, event?: MouseEvent): void {
if (color === themeColor.value) {
clearThemeTransitionRequest()
return
}
requestThemeTransition(event)
themeColor.value = color
}
function setCustomThemeColor(color: string, event?: MouseEvent): boolean {
const normalized = normalizeHexColor(color)
if (!normalized)
return false
if (themeColor.value === 'custom' && customThemeColor.value === normalized) {
clearThemeTransitionRequest()
return true
}
requestThemeTransition(event)
customThemeColor.value = normalized
themeColor.value = 'custom'
return true
}
function setThemeConfiguration(
configuration: ThemeConfigurationInput,
event?: MouseEvent,
): boolean {
const customColor = normalizeHexColor(configuration.customColor)
if (!isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
return false
requestThemeTransition(event)
themeMode.value = configuration.mode
themeColor.value = configuration.color
customThemeColor.value = customColor
themeCustomization.value = normalizeThemeCustomization(configuration.customization)
return true
}
function resetTheme(event?: MouseEvent): void {
const unchanged
= themeMode.value === DEFAULT_THEME_MODE
&& themeColor.value === DEFAULT_THEME_COLOR
&& customThemeColor.value === DEFAULT_CUSTOM_THEME_COLOR
&& Object.keys(themeCustomization.value).length === 0
if (unchanged) {
clearThemeTransitionRequest()
return
}
requestThemeTransition(event)
themeMode.value = DEFAULT_THEME_MODE
themeColor.value = DEFAULT_THEME_COLOR
customThemeColor.value = DEFAULT_CUSTOM_THEME_COLOR
themeCustomization.value = {}
}
function toggleTheme(event?: MouseEvent): void {
if (themeTransitioning.value)
return
requestThemeTransition(event)
themeMode.value = effectiveTheme.value === 'dark' ? 'light' : 'dark'
}
function requestThemeTransition(event?: MouseEvent): void {
themeTransitionOrigin = event
? { x: event.clientX, y: event.clientY }
: undefined
themeTransitionRequested = Boolean(event)
}
function clearThemeTransitionRequest(): void {
themeTransitionOrigin = undefined
themeTransitionRequested = false
}
return {
themeMode,
themeColor,
customThemeColor,
themeCustomization,
themeRevision,
themeTransitioning,
effectiveTheme,
initializeTheme,
setThemeMode,
setThemeColor,
setCustomThemeColor,
setThemeConfiguration,
resetTheme,
toggleTheme,
}
},
{
persist: {
key: 'codex-proxy-rs-theme',
pick: [
'themeMode',
'themeColor',
'customThemeColor',
'themeCustomization',
],
},
},
)
function themeSignature(theme: ResolvedTheme): string {
return `${theme.name}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
}
+3 -197
View File
@@ -1,218 +1,24 @@
import { useDark, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, shallowRef } from 'vue'
type ThemeMode = 'system' | 'light' | 'dark'
type ThemeName = 'light' | 'dark'
type ColorModeStorageValue = 'auto' | ThemeName
interface ThemeTransitionOrigin { x: number, y: number }
interface ViewTransition {
ready: Promise<void>
finished: Promise<void>
}
type ViewTransitionDocument = Document & {
startViewTransition?: (callback: () => void) => ViewTransition
}
import { shallowRef } from 'vue'
export const useUiStore = defineStore(
'ui',
() => {
const sidebarCollapsed = shallowRef(false)
const themeMode = shallowRef<ThemeMode>('system')
const themeRevision = shallowRef(0)
const themeTransitioning = shallowRef(false)
const preferredMotion = usePreferredReducedMotion()
let themeApplied = false
let themeTransitionOrigin: ThemeTransitionOrigin | undefined
let themeTransitionRequested = false
const { start: startFallbackTransitionTimer, stop: stopFallbackTransitionTimer } = useTimeoutFn(
() => {
document.documentElement.classList.remove('theme-fallback-transition')
themeTransitioning.value = false
},
180,
{ immediate: false },
)
const colorModeStorage = computed<ColorModeStorageValue>({
get: () => (themeMode.value === 'system' ? 'auto' : themeMode.value),
set: (mode) => {
themeMode.value = mode === 'auto' ? 'system' : mode
},
})
const isDark = useDark({
storageRef: colorModeStorage,
onChanged: (dark) => {
applyTheme(dark ? 'dark' : 'light')
},
})
const effectiveTheme = computed<ThemeName>(() => (isDark.value ? 'dark' : 'light'))
function toggleSidebar() {
function toggleSidebar(): void {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function applyTheme(theme: ThemeName) {
if (themeApplied && document.documentElement.dataset.theme === theme) {
themeTransitionRequested = false
return
}
if (
!themeApplied
|| !themeTransitionRequested
|| preferredMotion.value === 'reduce'
|| themeTransitioning.value
) {
commitTheme(theme)
themeApplied = true
themeTransitionRequested = false
return
}
runThemeTransition(theme)
themeApplied = true
themeTransitionRequested = false
}
function commitTheme(theme: ThemeName) {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
themeRevision.value += 1
}
function runThemeTransition(theme: ThemeName) {
const transitionDocument = document as ViewTransitionDocument
const shrinkingDarkLayer
= document.documentElement.dataset.theme === 'dark' && theme === 'light'
if (!transitionDocument.startViewTransition) {
runFallbackThemeTransition(theme)
return
}
const origin = themeTransitionOrigin ?? {
x: window.innerWidth - 44,
y: 44,
}
themeTransitionOrigin = undefined
const maxX = Math.max(origin.x, window.innerWidth - origin.x)
const maxY = Math.max(origin.y, window.innerHeight - origin.y)
const radius = Math.hypot(maxX, maxY)
document.documentElement.classList.toggle('theme-view-transition-shrink', shrinkingDarkLayer)
themeTransitioning.value = true
let transition: ViewTransition
try {
transition = transitionDocument.startViewTransition(() => commitTheme(theme))
}
catch {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
runFallbackThemeTransition(theme)
return
}
let cleaned = false
const cleanupTransition = () => {
if (cleaned) {
return
}
cleaned = true
requestAnimationFrame(() => {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
})
}
const cleanupTimer = window.setTimeout(cleanupTransition, 1200)
void transition.finished.catch(() => undefined)
let activeAnimation: Animation | undefined
void transition.ready
.then(() => {
const animation = document.documentElement.animate(
{
clipPath: shrinkingDarkLayer
? [
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
`circle(0px at ${origin.x}px ${origin.y}px)`,
]
: [
`circle(0px at ${origin.x}px ${origin.y}px)`,
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
],
},
{
duration: 420,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both',
pseudoElement: shrinkingDarkLayer
? '::view-transition-old(root)'
: '::view-transition-new(root)',
},
)
activeAnimation = animation
return animation.finished.catch(() => undefined)
})
.catch(() => undefined)
.finally(() => {
activeAnimation?.cancel()
window.clearTimeout(cleanupTimer)
cleanupTransition()
})
}
function runFallbackThemeTransition(theme: ThemeName) {
themeTransitioning.value = true
stopFallbackTransitionTimer()
document.documentElement.classList.add('theme-fallback-transition')
commitTheme(theme)
startFallbackTransitionTimer()
}
function initializeTheme() {
if (!themeApplied) {
applyTheme(effectiveTheme.value)
}
}
function setThemeMode(mode: ThemeMode) {
themeMode.value = mode
}
function toggleTheme(event?: MouseEvent) {
if (themeTransitioning.value) {
return
}
if (event) {
themeTransitionOrigin = {
x: event.clientX,
y: event.clientY,
}
themeTransitionRequested = true
}
themeMode.value = effectiveTheme.value === 'dark' ? 'light' : 'dark'
}
return {
sidebarCollapsed,
themeMode,
themeRevision,
themeTransitioning,
effectiveTheme,
toggleSidebar,
initializeTheme,
setThemeMode,
toggleTheme,
}
},
{
persist: {
key: 'codex-proxy-rs-ui',
pick: ['sidebarCollapsed', 'themeMode'],
pick: ['sidebarCollapsed'],
},
},
)
+16 -6
View File
@@ -1,6 +1,6 @@
html {
background: var(--cp-bg-page);
color: var(--cp-text-primary);
background: var(--cp-color-bg-layout, #f6f8fb);
color: var(--cp-color-text, #0e1726);
font-family: var(--font-sans);
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -9,7 +9,17 @@ html {
}
body {
background: var(--cp-bg-page);
background: var(--cp-color-bg-layout, #f6f8fb);
font-size: var(--cp-font-size, 13px);
}
/* 主题作用域同时驱动 Tailwind 数值间距,保证预览草稿与已保存主题使用同一密度算法。 */
[data-theme] {
--spacing: var(--cp-size-unit, 4px);
}
[data-theme]:not(html) {
font-size: var(--cp-font-size, 13px);
}
html.theme-fallback-transition,
@@ -51,7 +61,7 @@ button:disabled {
}
.cp-scrollbar {
scrollbar-color: var(--cp-scrollbar-thumb) transparent;
scrollbar-color: var(--cp-scrollbar-thumb-bg) transparent;
scrollbar-width: thin;
}
@@ -65,14 +75,14 @@ button:disabled {
}
.cp-scrollbar::-webkit-scrollbar-thumb {
background: var(--cp-scrollbar-thumb);
background: var(--cp-scrollbar-thumb-bg);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
.cp-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--cp-scrollbar-thumb-hover);
background: var(--cp-scrollbar-thumb-hover-bg);
border: 3px solid transparent;
background-clip: content-box;
}
+119 -79
View File
@@ -6,87 +6,127 @@
--font-sans:
'Inter Variable', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
/* 界面字重刻度:正文强调 650 / 加粗 700(font-bold) / 重标题 760 / 极重 800(font-extrabold)。
新代码从这四档取值,不再使用任意字重。 */
/* 界面字重刻度:正文强调 650 / 加粗 700(font-bold) / 重标题 760 / 极重 800(font-extrabold)。 */
--font-weight-emphasis: 650;
--font-weight-heavy: 760;
--color-cp-page: var(--cp-bg-page);
--color-cp-surface: var(--cp-bg-surface);
--color-cp-subtle: var(--cp-bg-subtle);
--color-cp-muted: var(--cp-bg-muted);
--color-cp-tertiary-bg: var(--cp-bg-tertiary);
--color-cp-nav-active: var(--cp-bg-nav-active);
--color-cp-primary: var(--cp-text-primary);
--color-cp-strong: var(--cp-text-strong);
--color-cp-secondary: var(--cp-text-secondary);
--color-cp-tertiary: var(--cp-text-tertiary);
--color-cp-muted-text: var(--cp-text-muted);
--color-cp-accent: var(--cp-accent);
--color-cp-accent-hover: var(--cp-accent-hover);
--color-cp-accent-pressed: var(--cp-accent-pressed);
--color-cp-accent-border: var(--cp-accent-border);
--color-cp-accent-on: var(--cp-accent-on);
--color-cp-default-hover: var(--cp-default-bg-hover);
--color-cp-default-active: var(--cp-default-bg-active);
--color-cp-default-border: var(--cp-default-border);
--color-cp-default-border-hover: var(--cp-default-border-hover);
--color-cp-border: var(--cp-border-primary);
--color-cp-divider: var(--cp-divider-subtle);
--color-cp-progress: var(--cp-progress-track);
--color-cp-overlay: var(--cp-overlay-scrim);
--color-cp-disabled: var(--cp-disabled-bg);
--color-cp-disabled-text: var(--cp-disabled-text);
--color-cp-disabled-icon: var(--cp-disabled-icon);
--color-cp-info: var(--cp-info);
--color-cp-info-hover: var(--cp-info-hover);
--color-cp-info-pressed: var(--cp-info-pressed);
--color-cp-info-bg: var(--cp-info-bg);
--color-cp-info-border: var(--cp-info-border);
--color-cp-info-text: var(--cp-info-text);
--color-cp-info-on: var(--cp-info-on);
--color-cp-normal: var(--cp-normal);
--color-cp-normal-hover: var(--cp-normal-hover);
--color-cp-normal-pressed: var(--cp-normal-pressed);
--color-cp-normal-bg: var(--cp-normal-bg);
--color-cp-normal-border: var(--cp-normal-border);
--color-cp-normal-text: var(--cp-normal-text);
--color-cp-success: var(--cp-success);
--color-cp-success-bg: var(--cp-success-bg);
--color-cp-success-bg-hover: var(--cp-success-bg-hover);
--color-cp-success-bg-active: var(--cp-success-bg-active);
--color-cp-success-border: var(--cp-success-border);
--color-cp-success-text: var(--cp-success-text);
--color-cp-warning: var(--cp-warning);
--color-cp-warning-bg: var(--cp-warning-bg);
--color-cp-warning-bg-hover: var(--cp-warning-bg-hover);
--color-cp-warning-bg-active: var(--cp-warning-bg-active);
--color-cp-warning-border: var(--cp-warning-border);
--color-cp-warning-text: var(--cp-warning-text);
--color-cp-danger: var(--cp-danger);
--color-cp-danger-bg: var(--cp-danger-bg);
--color-cp-danger-bg-hover: var(--cp-danger-bg-hover);
--color-cp-danger-bg-active: var(--cp-danger-bg-active);
--color-cp-danger-border: var(--cp-danger-border);
--color-cp-danger-text: var(--cp-danger-text);
--color-cp-plan-pro-bg: var(--cp-plan-pro-bg);
--color-cp-plan-pro-text: var(--cp-plan-pro-text);
--color-cp-plan-prolite-bg: var(--cp-plan-prolite-bg);
--color-cp-plan-prolite-text: var(--cp-plan-prolite-text);
--radius-cp-control-sm: var(--cp-radius-control-sm);
--radius-cp-control: var(--cp-radius-control);
--radius-cp-overlay: var(--cp-radius-overlay);
--radius-cp-surface: var(--cp-radius-surface);
--shadow-cp-card: var(--cp-shadow-card);
--shadow-cp-control: var(--cp-shadow-control);
--shadow-cp-popover: var(--cp-shadow-popover);
--shadow-cp-input: var(--cp-shadow-input);
--shadow-cp-input-hover: var(--cp-shadow-input-hover);
--shadow-cp-input-focus: var(--cp-shadow-input-focus);
--shadow-cp-input-error: var(--cp-shadow-input-error);
--shadow-cp-sidebar: var(--cp-shadow-sidebar);
--animate-cp-skeleton: cp-skeleton-breathe 2.8s cubic-bezier(0.37, 0, 0.63, 1) infinite;
--text-cp-xs: var(--cp-font-size-xs);
--text-cp-sm: var(--cp-font-size-sm);
--text-cp: var(--cp-font-size);
--text-cp-lg: var(--cp-font-size-lg);
--text-cp-xl: var(--cp-font-size-xl);
--color-cp-white: var(--cp-color-white);
--color-cp-transparent: var(--cp-color-transparent);
--color-cp-bg-layout: var(--cp-color-bg-layout);
--color-cp-bg-container: var(--cp-color-bg-container);
--color-cp-bg-elevated: var(--cp-color-bg-elevated);
--color-cp-bg-spotlight: var(--cp-color-bg-spotlight);
--color-cp-bg-mask: var(--cp-color-bg-mask);
--color-cp-bg-text-hover: var(--cp-color-bg-text-hover);
--color-cp-bg-text-active: var(--cp-color-bg-text-active);
--color-cp-bg-container-disabled: var(--cp-color-bg-container-disabled);
--color-cp-fill-secondary: var(--cp-color-fill-secondary);
--color-cp-fill-tertiary: var(--cp-color-fill-tertiary);
--color-cp-fill-quaternary: var(--cp-color-fill-quaternary);
--color-cp-border: var(--cp-color-border);
--color-cp-border-secondary: var(--cp-color-border-secondary);
--color-cp-split: var(--cp-color-split);
--color-cp-text: var(--cp-color-text);
--color-cp-text-heading: var(--cp-color-text-heading);
--color-cp-text-secondary: var(--cp-color-text-secondary);
--color-cp-text-tertiary: var(--cp-color-text-tertiary);
--color-cp-text-quaternary: var(--cp-color-text-quaternary);
--color-cp-text-disabled: var(--cp-color-text-disabled);
--color-cp-text-light-solid: var(--cp-color-text-light-solid);
--color-cp-primary-bg: var(--cp-color-primary-bg);
--color-cp-primary-bg-hover: var(--cp-color-primary-bg-hover);
--color-cp-primary-border: var(--cp-color-primary-border);
--color-cp-primary-border-hover: var(--cp-color-primary-border-hover);
--color-cp-primary-hover: var(--cp-color-primary-hover);
--color-cp-primary: var(--cp-color-primary);
--color-cp-primary-active: var(--cp-color-primary-active);
--color-cp-primary-text-hover: var(--cp-color-primary-text-hover);
--color-cp-primary-text: var(--cp-color-primary-text);
--color-cp-primary-text-active: var(--cp-color-primary-text-active);
--color-cp-link: var(--cp-color-link);
--color-cp-link-hover: var(--cp-color-link-hover);
--color-cp-link-active: var(--cp-color-link-active);
--color-cp-control-item-bg-active: var(--cp-control-item-bg-active);
--color-cp-control-item-bg-active-hover: var(--cp-control-item-bg-active-hover);
--color-cp-control-outline: var(--cp-control-outline);
--color-cp-menu-item-selected-bg: var(--cp-menu-item-selected-bg);
--color-cp-progress-remaining: var(--cp-progress-remaining-color);
--color-cp-info: var(--cp-color-info);
--color-cp-info-hover: var(--cp-color-info-hover);
--color-cp-info-active: var(--cp-color-info-active);
--color-cp-info-bg: var(--cp-color-info-bg);
--color-cp-info-border: var(--cp-color-info-border);
--color-cp-info-text: var(--cp-color-info-text);
--color-cp-status-normal: var(--cp-color-status-normal);
--color-cp-status-normal-hover: var(--cp-color-status-normal-hover);
--color-cp-status-normal-active: var(--cp-color-status-normal-active);
--color-cp-status-normal-bg: var(--cp-color-status-normal-bg);
--color-cp-status-normal-border: var(--cp-color-status-normal-border);
--color-cp-status-normal-text: var(--cp-color-status-normal-text);
--color-cp-success: var(--cp-color-success);
--color-cp-success-bg: var(--cp-color-success-bg);
--color-cp-success-bg-hover: var(--cp-color-success-bg-hover);
--color-cp-success-bg-active: var(--cp-color-success-bg-active);
--color-cp-success-border: var(--cp-color-success-border);
--color-cp-success-text: var(--cp-color-success-text);
--color-cp-warning: var(--cp-color-warning);
--color-cp-warning-bg: var(--cp-color-warning-bg);
--color-cp-warning-bg-hover: var(--cp-color-warning-bg-hover);
--color-cp-warning-bg-active: var(--cp-color-warning-bg-active);
--color-cp-warning-border: var(--cp-color-warning-border);
--color-cp-warning-text: var(--cp-color-warning-text);
--color-cp-error: var(--cp-color-error);
--color-cp-error-bg: var(--cp-color-error-bg);
--color-cp-error-bg-hover: var(--cp-color-error-bg-hover);
--color-cp-error-bg-active: var(--cp-color-error-bg-active);
--color-cp-error-border: var(--cp-color-error-border);
--color-cp-error-text: var(--cp-color-error-text);
--color-cp-reasoning: var(--cp-color-reasoning);
--color-cp-account-plan-pro-bg: var(--cp-account-plan-pro-bg);
--color-cp-account-plan-pro: var(--cp-account-plan-pro-color);
--color-cp-account-plan-pro-lite-bg: var(--cp-account-plan-pro-lite-bg);
--color-cp-account-plan-pro-lite: var(--cp-account-plan-pro-lite-color);
--radius-cp-sm: var(--cp-border-radius-sm);
--radius-cp: var(--cp-border-radius);
--radius-cp-lg: var(--cp-border-radius-lg);
--radius-cp-card: var(--cp-card-border-radius);
--shadow-cp: var(--cp-box-shadow);
--shadow-cp-secondary: var(--cp-box-shadow-secondary);
--shadow-cp-tertiary: var(--cp-box-shadow-tertiary);
--shadow-cp-card: var(--cp-card-shadow);
--shadow-cp-input: var(--cp-input-shadow);
--shadow-cp-input-hover: var(--cp-input-hover-shadow);
--shadow-cp-input-active: var(--cp-input-active-shadow);
--shadow-cp-input-error-active: var(--cp-input-error-active-shadow);
--shadow-cp-layout-sider: var(--cp-layout-sider-shadow);
--spacing-cp-control-sm: var(--cp-control-height-sm);
--spacing-cp-control-md: var(--cp-control-height-md);
--spacing-cp-control: var(--cp-control-height);
--spacing-cp-control-lg: var(--cp-control-height-lg);
--spacing-cp-table-row-min: var(--cp-table-row-min-height);
--spacing-cp-table-row-min-compact: var(--cp-table-row-min-height-compact);
--spacing-cp-size-step: var(--cp-size-step);
--spacing-cp-table-row: var(--cp-table-row-height);
--spacing-cp-table-row-sm: var(--cp-table-row-height-sm);
}
@keyframes cp-skeleton-breathe {
0%,
100% {
background-color: var(--cp-color-fill-tertiary);
}
50% {
background-color: var(--cp-color-fill-secondary);
}
}
+30 -197
View File
@@ -1,209 +1,42 @@
:root {
/* :root 提供首屏浅色兜底;data-theme 允许预览区域独立于宿主切换明暗。 */
:root,
[data-theme='light'] {
color-scheme: light;
--cp-bg-page: #f6f8fb;
--cp-bg-surface: #ffffff;
--cp-bg-subtle: #f8fafc;
--cp-bg-muted: #f1f5f9;
--cp-bg-tertiary: #eef6ff;
--cp-bg-nav-active: #e9eef5;
--cp-bg-dark: #111827;
--cp-brand-mark-bg: #1c2a3d;
--cp-white: #ffffff;
--cp-transparent: #ffffff00;
--cp-overlay-scrim: #0e17264d;
/* 静态常量。皮肤 Token 由主题 Store 在 Vue 挂载前生成。 */
--cp-color-white: #ffffff;
--cp-color-transparent: #ffffff00;
--cp-text-primary: #0e1726;
--cp-text-strong: #111827;
--cp-text-secondary: #64748b;
--cp-text-tertiary: #94a3b8;
--cp-text-muted: #94a3b8;
--cp-brand-accent: #2563eb;
--cp-accent: #2563eb;
--cp-accent-hover: #1d4ed8;
--cp-accent-pressed: #1e40af;
--cp-accent-border: #93c5fd;
--cp-accent-on: #ffffff;
--cp-reasoning: #8b5cf6;
--cp-plan-pro-bg: #ede9fe;
--cp-plan-pro-text: #5b21b6;
--cp-plan-prolite-bg: #f5f3ff;
--cp-plan-prolite-text: #7c3aed;
/* 稳定的业务语义色,不参与主题编辑器派生。 */
--cp-color-status-normal: #0f9f9a;
--cp-color-status-normal-hover: #0d8d88;
--cp-color-status-normal-active: #0b7370;
--cp-color-status-normal-bg: #ecfdfd;
--cp-color-status-normal-border: #a7dfdd;
--cp-color-status-normal-text: #0b7370;
--cp-default-bg-hover: #f1f5f9;
--cp-default-bg-active: #e9eef5;
--cp-default-border: #e2e8f0;
--cp-default-border-hover: #cbd5e1;
--cp-border-primary: #d8e0ea;
--cp-divider-subtle: #d8e0ea6b;
--cp-progress-track: #e2e8f0;
--cp-color-reasoning: #8b5cf6;
--cp-control-focus-ring: #dbeafe;
--cp-input-soft-bg: #eef2f7;
--cp-input-soft-bg-hover: #f2f6fa;
--cp-input-soft-bg-focus: #f8fafc;
--cp-input-context-bg: #eef2f7;
--cp-input-context-bg-hover: #f2f6fa;
--cp-input-error-soft-bg: #fff1f1;
--cp-disabled-bg: #f1f5f9;
--cp-disabled-text: #94a3b8;
--cp-disabled-icon: #cbd5e1;
--cp-info: #2563eb;
--cp-info-hover: #1d4ed8;
--cp-info-pressed: #1e40af;
--cp-info-bg: #eef6ff;
--cp-info-border: #93c5fd;
--cp-info-text: #2563eb;
--cp-info-on: #ffffff;
--cp-normal: #0f9f9a;
--cp-normal-hover: #0d8d88;
--cp-normal-pressed: #0b7370;
--cp-normal-bg: #ecfdfd;
--cp-normal-border: #a7dfdd;
--cp-normal-text: #0b7370;
--cp-success: #12b981;
--cp-success-bg: #ecfdf5;
--cp-success-bg-hover: #ddfbea;
--cp-success-bg-active: #c8f5dc;
--cp-success-border: #b7ead0;
--cp-success-text: #047857;
--cp-warning: #f59e0b;
--cp-warning-bg: #fffbeb;
--cp-warning-bg-hover: #fef3c7;
--cp-warning-bg-active: #fde68a;
--cp-warning-border: #fcd34d;
--cp-warning-text: #b45309;
--cp-danger: #ef4444;
--cp-danger-bg: #fef2f2;
--cp-danger-bg-hover: #fee2e2;
--cp-danger-bg-active: #fecaca;
--cp-danger-border: #fca5a5;
--cp-danger-text: #b91c1c;
--cp-control-height-sm: 32px;
--cp-control-height-md: 38px;
--cp-control-height-lg: 44px;
--cp-table-row-min-height: 60px;
--cp-table-row-min-height-compact: 40px;
--cp-radius-control-sm: 6px;
--cp-radius-control: 8px;
--cp-radius-overlay: 12px;
--cp-radius-surface: 18px;
--cp-shadow-card: 0 10px 22px -18px #0e172614;
--cp-shadow-control: 0 9px 18px -14px #0e172616;
--cp-shadow-popover: 0 18px 38px -18px #0e17262b;
--cp-shadow-input: inset 0 1px 0 #ffffff66, 0 9px 18px -14px #0e172616;
--cp-shadow-input-hover: inset 0 1px 0 #ffffffcc, 0 0 0 3px var(--cp-control-focus-ring), 0 12px 24px -16px #0e17261f;
--cp-shadow-input-focus: inset 0 1px 0 #ffffff, 0 0 0 3px #dbeafe;
--cp-shadow-input-error: inset 0 1px 0 #ffffffcc, 0 0 0 3px #fee2e2;
--cp-shadow-sidebar: 2px 0 12px -12px #0e172607;
--cp-shadow-sticky: #0f172a61;
--cp-scrollbar-thumb: #cbd7e6;
--cp-scrollbar-thumb-hover: #aebed0;
--cp-account-plan-pro-bg: #ede9fe;
--cp-account-plan-pro-color: #5b21b6;
--cp-account-plan-pro-lite-bg: #f5f3ff;
--cp-account-plan-pro-lite-color: #7c3aed;
}
html[data-theme='dark'] {
[data-theme='dark'] {
color-scheme: dark;
--cp-bg-page: #0b111c;
--cp-bg-surface: #111a29;
--cp-bg-subtle: #162133;
--cp-bg-muted: #1c2a3d;
--cp-bg-tertiary: #142946;
--cp-bg-nav-active: #1b2a40;
--cp-bg-dark: #050913;
--cp-white: #ffffff;
--cp-transparent: #ffffff00;
--cp-overlay-scrim: #030712a6;
--cp-color-status-normal: #2dd4bf;
--cp-color-status-normal-hover: #5eead4;
--cp-color-status-normal-active: #14b8a6;
--cp-color-status-normal-bg: #0c332f;
--cp-color-status-normal-border: #1f6f67;
--cp-color-status-normal-text: #5eead4;
--cp-text-primary: #d8e2ee;
--cp-text-strong: #f5f8fc;
--cp-text-secondary: #8fa0b7;
--cp-text-tertiary: #71839c;
--cp-text-muted: #64748b;
--cp-brand-accent: #6ea8ff;
--cp-accent: #3b82f6;
--cp-accent-hover: #4f8ee8;
--cp-accent-pressed: #2563eb;
--cp-accent-border: #2e5e92;
--cp-accent-on: var(--cp-text-primary);
--cp-reasoning: #a78bfa;
--cp-plan-pro-bg: #35136f;
--cp-plan-pro-text: #ddd6fe;
--cp-plan-prolite-bg: #24143e;
--cp-plan-prolite-text: #c4b5fd;
--cp-color-reasoning: #a78bfa;
--cp-default-bg-hover: #1b2a40;
--cp-default-bg-active: #23354f;
--cp-default-border: #26364c;
--cp-default-border-hover: #344760;
--cp-border-primary: #29384d;
--cp-divider-subtle: #26364c80;
--cp-progress-track: #24334a;
--cp-control-focus-ring: #1f3f68;
--cp-input-soft-bg: #182437;
--cp-input-soft-bg-hover: #1d2d43;
--cp-input-soft-bg-focus: #111d2d;
--cp-input-context-bg: #182437;
--cp-input-context-bg-hover: #1d2d43;
--cp-input-error-soft-bg: #311b24;
--cp-disabled-bg: #182231;
--cp-disabled-text: #53647a;
--cp-disabled-icon: #44556b;
--cp-info: #3b82f6;
--cp-info-hover: #4f8ee8;
--cp-info-pressed: #2563eb;
--cp-info-bg: #102747;
--cp-info-border: #2e5e92;
--cp-info-text: #93c5fd;
--cp-info-on: var(--cp-text-primary);
--cp-normal: #2dd4bf;
--cp-normal-hover: #5eead4;
--cp-normal-pressed: #14b8a6;
--cp-normal-bg: #0c332f;
--cp-normal-border: #1f6f67;
--cp-normal-text: #5eead4;
--cp-success: #34d399;
--cp-success-bg: #0e3427;
--cp-success-bg-hover: #13432f;
--cp-success-bg-active: #18543b;
--cp-success-border: #247554;
--cp-success-text: #86efac;
--cp-warning: #fbbf24;
--cp-warning-bg: #38280c;
--cp-warning-bg-hover: #4a3510;
--cp-warning-bg-active: #5d4314;
--cp-warning-border: #7a581a;
--cp-warning-text: #fcd34d;
--cp-danger: #fb7185;
--cp-danger-bg: #3b1620;
--cp-danger-bg-hover: #4b1c28;
--cp-danger-bg-active: #612333;
--cp-danger-border: #7f2d42;
--cp-danger-text: #fda4af;
--cp-shadow-card: 0 18px 34px -26px #000000a3, inset 0 1px 0 #ffffff08;
--cp-shadow-control: 0 12px 24px -20px #000000a8, inset 0 1px 0 #ffffff08;
--cp-shadow-popover: 0 24px 52px -26px #000000d1, inset 0 1px 0 #ffffff0a;
--cp-shadow-input: inset 0 1px 0 #ffffff08, 0 12px 24px -20px #000000a8;
--cp-shadow-input-hover: inset 0 1px 0 #ffffff10, 0 0 0 3px var(--cp-control-focus-ring), 0 14px 28px -22px #000000b8;
--cp-shadow-input-focus: inset 0 1px 0 #ffffff12, 0 0 0 3px #1f3f68;
--cp-shadow-input-error: inset 0 1px 0 #ffffff0d, 0 0 0 3px #5a1f2d;
--cp-shadow-sidebar: 2px 0 18px -14px #000000b8;
--cp-shadow-sticky: #000000bf;
--cp-scrollbar-thumb: #2b3e56;
--cp-scrollbar-thumb-hover: #405774;
--cp-account-plan-pro-bg: #35136f;
--cp-account-plan-pro-color: #ddd6fe;
--cp-account-plan-pro-lite-bg: #24143e;
--cp-account-plan-pro-lite-color: #c4b5fd;
}

Some files were not shown because too many files have changed in this diff Show More