mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 00:05:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402bc37fbb | ||
|
|
8b9aacd8af | ||
|
|
d9f9eca7e8 | ||
|
|
ff1ea92155 | ||
|
|
f772bda57b | ||
|
|
e0a3dc20dd | ||
|
|
39c8a6352d | ||
|
|
db779a4a21 | ||
|
|
750dd32200 | ||
|
|
6230d95729 | ||
|
|
11b64b3026 | ||
|
|
ca5901ba56 | ||
|
|
81de0f5331 | ||
|
|
69c1ee15bb | ||
|
|
ba87cfde6a | ||
|
|
d0a588fbc3 |
@@ -19,8 +19,8 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
|
||||
|
||||
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
|
||||
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 备版只更新源码的构建版本;通道快照是版本 Release 的 `version.yaml` 附件,验收时核对固定预发行页 `update-index` 指向的公开快照
|
||||
- 同版本失败先区分[续跑原任务与修复源码后重发](references/operations.md#恢复与验收):前者沿用原提交;后者仅在版本尚未公开时清理失败的版本提交,再生成唯一的版本提交。已发布的 tag 和产物保持不变
|
||||
- 备版只更新构建版本;发布后回读 `main:release/version.yaml` 的 `published`,核对已公开的通道候选
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
|
||||
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
|
||||
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
|
||||
发布任务应将该基线的最高公开实验版写入版本 Release 通道快照的 `published[大版本].exp[基线]`,不推进普通通道。
|
||||
发布任务应将该基线的最高公开实验版写入`main:release/version.yaml` 的 `published[大版本].exp[基线]`,不推进普通通道。
|
||||
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
|
||||
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
|
||||
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore: bump version to <version>`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
@@ -38,15 +38,29 @@ release/publish "$release_version"
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
|
||||
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
|
||||
先核对本地与远端的分支头、版本提交、tag,以及对应的工作流、GitHub Release 和版本镜像。超时或任务失败不等于版本未公开;即使 Release 还是草稿,版本镜像也可能已经推送
|
||||
|
||||
### 续跑原任务
|
||||
|
||||
源码无需修改时保留版本提交和 tag,核对两者指向后从断点继续:
|
||||
|
||||
- 尚未推送:补齐缺失的步骤,按入口原子推送分支和 tag
|
||||
- 已推送但未触发:使用入口提示的 dispatch 命令
|
||||
- 已有 run:跟踪对应提交的任务,失败时重跑失败任务。构建产物保留 7 天,过期后从原 tag 重跑完整工作流
|
||||
|
||||
### 修复源码后同版本重发
|
||||
|
||||
1. 确认旧工作流已停止、该版本没有公开 Release 或版本镜像,且待清理提交不在其他已发布 tag 的历史中。已有公开产物时保留原提交和 tag,代码修复另发新版本;发布状态不明时先查清
|
||||
2. 从失败版本提交的父提交重新接上后续功能和修复,跳过已核实的失败版本提交。保留最终说明为未提交改动,版本号和插件弃用清单交给发行入口。核对差异,确保修复未丢失、失败提交已不在待发历史中
|
||||
3. 只清理本次失败的 tag 和草稿。涉及远端改写时,先核对授权,以事先记录的分支头和 tag 对象 SHA 作显式租约,原子更新分支并删除未发行 tag。租约不匹配就停止,不合并旧线
|
||||
4. 修复验证通过后执行 `release/publish --check <version>`,再按[执行发版](#执行发版)重发。核对上一发行至新 tag 的历史中只有一个本版本的发行提交,且与工作流构建的提交一致
|
||||
|
||||
### 验收
|
||||
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 回读固定预发行页 `update-index`(不是 GitHub Latest),按正文 JSON 的 `current` 下载对应 `v<current>` 的 `version.yaml`。核对快照版本及 `published`:正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。晚完成的发行不得回退较高候选或丢失其他发行线
|
||||
- 只有指针写入失败时,在既有授权内执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务与发布共用锁,只核对已公开版本的附件和待发布快照摘要,不重新打包、改版本 tag 或写 main。完成后回读指针与附件;待发布记录不能当作成功。索引附件最多 128 KiB
|
||||
- 回读 `main:release/version.yaml` 的 `published`。正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。有通道推进时应有 `chore(release): update release channels to <version>` 提交;重复执行或较旧版本未推进任何通道时无提交
|
||||
- 通道表写入失败时,确认该版本已公开、工作流能写入 `main`,再按已有授权执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务重新校验公开附件、读取并合并当前清单,不重新打包或改 tag
|
||||
- v3.42.0 在线升级:首个包含新更新器的 3.x 正式版公开并通过校验后,从该次通道提交取出 `release/version.yaml`,将顶层 `version` 设为该正式版,作为同名附件上传到该 Release。回读附件,确认 3.x 稳定通道指向该版本且安装包可用,再手动将已有 `update-index` 正文改为 `{"current":"<该正式版>"}`。此后保留该页与附件,无需再编辑
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 基线与版本
|
||||
|
||||
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
|
||||
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分源码构建版本和 `update-index` 指向的发行附件中的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
|
||||
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分顶层构建版本与 `main` 上的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
|
||||
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
|
||||
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
读取当前 [notes.md](../../../../release/notes.md)与[项目约定](../../../../CONTRIBUTING.md#项目约定)中的发布说明条目
|
||||
|
||||
- 只在发布说明任务中修改 `release/notes.md`,替换为本次版本内容,首行为目标 tag,正文使用中文
|
||||
- 按用户可感知的最终变化归并,省略空章节,不逐条翻译提交,不写排查过程或未经验证的承诺
|
||||
- 常规版本按「新增功能」「优化改进」「问题修复」「升级须知」归类,省略空章节;单一改动可直接写一句。安装命令和文档入口由工作流附加
|
||||
- 每条用一句短语讲清一个用户可感知的结果,不加句号,例如「系统更新:确认当前平台有安装包后才提示升级」
|
||||
- 同一变化只写一次,保留会影响使用或升级的默认值和限制,不展开实现过程、逐条翻译提交或写未经验证的承诺
|
||||
- 剔除回退、重复及未进入发行范围的内容,升级事项须能追溯到实际迁移、配置或兼容变化
|
||||
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
|
||||
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
|
||||
@@ -26,6 +28,8 @@
|
||||
|
||||
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
|
||||
|
||||
在上一发行基线到 HEAD 的提交中检查是否已有目标版本的备版提交(`chore: bump version to <version>`;查历史时也匹配 `chore(release): prepare v<version>` 和 `chore(release): v<version>`),即使同名 tag 已不存在也要查。命中时先核对该提交对应的发行状态,按[失败恢复](operations.md#恢复与验收)处理,不在旧提交上再叠一个同名提交
|
||||
|
||||
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
|
||||
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ module ReleasePackage
|
||||
default_image = "${CPR_IMAGE:-#{image}:latest}"
|
||||
raise 'Expected exactly one default application image in deploy/compose.yaml' unless compose.scan(default_image).length == 1
|
||||
File.write("#{release_dir}/compose.yaml", compose.sub(default_image, "${CPR_IMAGE:-#{image}:#{ENV.fetch('DOCKER_VERSION')}}"))
|
||||
FileUtils.cp(MANIFEST, release_dir)
|
||||
|
||||
platforms.each do |platform|
|
||||
raise "Unsupported archive format: #{platform['archive']}" unless platform['archive'] == 'tar.gz'
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
require 'base64'
|
||||
require 'digest'
|
||||
require 'uri'
|
||||
require_relative 'release-metadata'
|
||||
|
||||
# 通道快照随版本封存,固定 Release 只记录已公开快照和可恢复的待发布摘要
|
||||
# 各发行线共用 main 通道表;只合并本次已核实的版本
|
||||
module PublishedChannels
|
||||
POINTER = 'update-index'
|
||||
PATH = 'release/version.yaml'
|
||||
LIMIT = 128 * 1024
|
||||
|
||||
def self.advance(document, version)
|
||||
@@ -46,40 +45,15 @@ module PublishedChannels
|
||||
end
|
||||
|
||||
def initialize(repository)
|
||||
@repository = repository
|
||||
@base = "repos/#{repository}"
|
||||
release = api("#{@base}/releases/tags/#{POINTER}", optional: true)
|
||||
if release
|
||||
raise 'Invalid update-index release' unless release.fetch('tag_name') == POINTER &&
|
||||
release.fetch('prerelease') && !release.fetch('draft')
|
||||
@id = release.fetch('id')
|
||||
@state = JSON.parse(release.fetch('body'))
|
||||
@state.fetch('current')
|
||||
else
|
||||
@state = { 'current' => nil }
|
||||
end
|
||||
end
|
||||
|
||||
def save(current:, pending: nil)
|
||||
state = { 'current' => current }
|
||||
state['pending'] = pending if pending
|
||||
body = { body: JSON.generate(state), make_latest: 'false' }
|
||||
if @id
|
||||
api("#{@base}/releases/#{@id}", method: 'PATCH', data: body)
|
||||
else
|
||||
release = api("#{@base}/releases", method: 'POST', data: body.merge(
|
||||
tag_name: POINTER, name: '更新通道索引', target_commitish: 'main', prerelease: true
|
||||
))
|
||||
@id = release.fetch('id')
|
||||
end
|
||||
@state = state
|
||||
end
|
||||
|
||||
def release(version)
|
||||
def find_release(version)
|
||||
api("#{@base}/releases/tags/v#{ReleaseMetadata.version(version)}", optional: true)
|
||||
end
|
||||
|
||||
def verify_assets(version, release, index:)
|
||||
def verify_assets(version)
|
||||
release = find_release(version)
|
||||
tag = "v#{version}"
|
||||
raise "Release is not public or tag differs: #{tag}" unless release && release.fetch('tag_name') == tag &&
|
||||
!release.fetch('draft') && release.fetch('prerelease') == ReleaseMetadata.prerelease?(version)
|
||||
@@ -87,73 +61,34 @@ module PublishedChannels
|
||||
platforms = YAML.safe_load(Base64.decode64(data.fetch('content'))).fetch('platforms')
|
||||
raise "Missing platforms for #{tag}" if platforms.empty?
|
||||
expected = platforms.map { |p| ReleaseMetadata.archive_name(version, p) }
|
||||
expected.concat(%w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json])
|
||||
expected << 'version.yaml' if index
|
||||
assets = release.fetch('assets').select { |a| a.fetch('state') == 'uploaded' && a.fetch('size').positive? }.to_h { |a| [a.fetch('name'), a] }
|
||||
missing = expected - assets.keys
|
||||
expected.concat(%w[checksums.txt compose.yaml config.example.yaml])
|
||||
uploaded = release.fetch('assets').filter_map do |asset|
|
||||
asset.fetch('name') if asset.fetch('state') == 'uploaded' && asset.fetch('size').positive?
|
||||
end
|
||||
missing = expected - uploaded
|
||||
raise "Incomplete release #{tag}: #{missing.join(', ')}" unless missing.empty?
|
||||
assets
|
||||
end
|
||||
|
||||
def download(version)
|
||||
content, error, status = Open3.capture3('gh', 'release', 'download', "v#{version}", '--repo', @repository,
|
||||
'--pattern', 'version.yaml', '--output', '-')
|
||||
raise "Cannot download v#{version} snapshot: #{error.strip}" unless status.success?
|
||||
content
|
||||
end
|
||||
|
||||
def read_snapshot(version, asset)
|
||||
raise 'Invalid version.yaml size' unless (1..LIMIT).cover?(asset.fetch('size'))
|
||||
content = download(version)
|
||||
digest = Digest::SHA256.hexdigest(content)
|
||||
raise "Snapshot size or digest differs for v#{version}" unless content.bytesize == asset.fetch('size') && asset.fetch('digest') == "sha256:#{digest}"
|
||||
doc = YAML.safe_load(content)
|
||||
raise 'Invalid channel snapshot' unless doc.fetch('version') == version && doc.fetch('published').is_a?(Hash)
|
||||
[doc, digest]
|
||||
end
|
||||
|
||||
def previous
|
||||
if (current = @state.fetch('current'))
|
||||
assets = verify_assets(current, release(current), index: true)
|
||||
return read_snapshot(current, assets.fetch('version.yaml')).first
|
||||
end
|
||||
# 首次接入时从实际公开的稳定版引导;没有历史 Release 的仓库从空表开始
|
||||
doc = { 'published' => {} }
|
||||
latest = api("#{@base}/releases/latest", optional: true)
|
||||
if latest
|
||||
version = ReleaseMetadata.version(latest.fetch('tag_name'))
|
||||
verify_assets(version, latest, index: false)
|
||||
PublishedChannels.advance(doc, version)
|
||||
end
|
||||
doc
|
||||
end
|
||||
|
||||
# 调用方持有同一仓库的发布锁;快照必须在取锁后生成,不能在并行构建中预先合并
|
||||
def prepare(version, directory)
|
||||
if (pending = @state.dig('pending', 'version')) && (old = release(pending)) && !old.fetch('draft')
|
||||
finish(pending)
|
||||
end
|
||||
existing = release(version)
|
||||
def check_unpublished(version)
|
||||
existing = find_release(version)
|
||||
raise "Release v#{version} already public; use index-only recovery" if existing && !existing.fetch('draft')
|
||||
doc = previous
|
||||
doc['version'] = version
|
||||
PublishedChannels.advance(doc, version)
|
||||
content = YAML.dump(doc)
|
||||
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
|
||||
digest = Digest::SHA256.hexdigest(content)
|
||||
File.write(File.join(directory, 'version.yaml'), content)
|
||||
File.open(File.join(directory, 'checksums.txt'), 'a') { |file| file.puts "#{digest} version.yaml" }
|
||||
save(current: @state.fetch('current'), pending: { 'version' => version, 'sha256' => digest })
|
||||
end
|
||||
|
||||
def finish(version)
|
||||
assets = verify_assets(version, release(version), index: true)
|
||||
_, digest = read_snapshot(version, assets.fetch('version.yaml'))
|
||||
return if @state.fetch('current') == version
|
||||
unless @state['pending'] == { 'version' => version, 'sha256' => digest }
|
||||
raise "v#{version} is not the reserved snapshot; refusing to overwrite another publication"
|
||||
end
|
||||
save(current: version)
|
||||
def publish(version)
|
||||
verify_assets(version)
|
||||
# 读取 main 的当前文件,实验分支和较晚结束的旧构建都不能覆盖其他通道或构建版本
|
||||
endpoint = "#{@base}/contents/#{PATH}"
|
||||
file = api("#{endpoint}?ref=main")
|
||||
document = YAML.safe_load(Base64.decode64(file.fetch('content')))
|
||||
return unless PublishedChannels.advance(document, version)
|
||||
|
||||
content = YAML.dump(document).delete_prefix("---\n")
|
||||
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
|
||||
# 按读取的文件 SHA 写入;并发备版改过同一文件时交给恢复任务重新合并
|
||||
api(endpoint, method: 'PUT', data: {
|
||||
branch: 'main', sha: file.fetch('sha'), content: Base64.strict_encode64(content),
|
||||
message: "chore(release): update release channels to #{version}"
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -163,8 +98,8 @@ if $PROGRAM_NAME == __FILE__
|
||||
version = ReleaseMetadata.version(raw || '')
|
||||
publisher = PublishedChannels::Publisher.new(ENV.fetch('GITHUB_REPOSITORY'))
|
||||
case mode
|
||||
when 'prepare' then publisher.prepare(version, 'dist/release')
|
||||
when 'finish' then publisher.finish(version)
|
||||
else abort 'usage: published-channels.rb prepare|finish VERSION'
|
||||
when 'check' then publisher.check_unpublished(version)
|
||||
when 'publish' then publisher.publish(version)
|
||||
else abort 'usage: published-channels.rb check|publish VERSION'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
require 'tmpdir'
|
||||
require_relative '../published-channels'
|
||||
|
||||
# 测试只替换 GitHub 读写边界,执行真实的发布状态转换
|
||||
class PublishedChannelsTest
|
||||
def assert_equal(expected, actual)
|
||||
raise "expected #{expected.inspect}, got #{actual.inspect}" unless expected == actual
|
||||
end
|
||||
|
||||
def assert_rejected
|
||||
yield
|
||||
rescue RuntimeError
|
||||
return
|
||||
else
|
||||
raise 'expected publication to be rejected'
|
||||
end
|
||||
|
||||
def initialize
|
||||
@pointer = nil
|
||||
@releases = {}
|
||||
@files = {}
|
||||
@calls = []
|
||||
@platforms = [{ 'os' => 'linux', 'arch' => 'amd64', 'archive' => 'tar.gz' }]
|
||||
end
|
||||
|
||||
def api(endpoint, method: 'GET', data: nil, optional: false)
|
||||
path = endpoint.delete_prefix('repos/test/repo/')
|
||||
@calls << [method, path]
|
||||
case [method, path]
|
||||
when ['GET', 'releases/tags/update-index'] then @pointer
|
||||
when ['POST', 'releases']
|
||||
raise 'duplicate pointer' if @pointer
|
||||
@pointer = data.transform_keys(&:to_s).merge('id' => 12, 'draft' => false)
|
||||
when ['PATCH', 'releases/12']
|
||||
@pointer.merge!(data.transform_keys(&:to_s))
|
||||
when ['GET', 'releases/latest'] then @releases['3.41.2']
|
||||
else
|
||||
return @releases[path.delete_prefix('releases/tags/v')] if method == 'GET' && path.start_with?('releases/tags/v')
|
||||
return { 'content' => Base64.encode64(YAML.dump({ 'platforms' => @platforms })) } if path.start_with?('contents/release/platforms.yaml?ref=v')
|
||||
raise "unexpected #{method} #{path}"
|
||||
end
|
||||
end
|
||||
|
||||
def publish_assets(version, content = nil)
|
||||
names = %w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json]
|
||||
names << ReleaseMetadata.archive_name(version, @platforms.first)
|
||||
@files[version] = content if content
|
||||
assets = names.map { |name| { 'name' => name, 'size' => 100, 'state' => 'uploaded' } }
|
||||
assets << { 'name' => 'version.yaml', 'size' => content.bytesize, 'digest' => "sha256:#{Digest::SHA256.hexdigest(content)}", 'state' => 'uploaded' } if content
|
||||
@releases[version] = { 'tag_name' => "v#{version}", 'draft' => false, 'prerelease' => ReleaseMetadata.prerelease?(version), 'assets' => assets }
|
||||
end
|
||||
|
||||
def publisher
|
||||
github = self
|
||||
files = @files
|
||||
Class.new(PublishedChannels::Publisher) do
|
||||
define_method(:api) { |endpoint, **args| github.api(endpoint, **args) }
|
||||
define_method(:download) { |version| files.fetch(version) }
|
||||
end.new('test/repo')
|
||||
end
|
||||
|
||||
def prepare(version)
|
||||
Dir.mktmpdir do |dir|
|
||||
File.write(File.join(dir, 'checksums.txt'), '')
|
||||
publisher.prepare(version, dir)
|
||||
content = File.read(File.join(dir, 'version.yaml'))
|
||||
assert_equal "#{Digest::SHA256.hexdigest(content)} version.yaml\n", File.read(File.join(dir, 'checksums.txt'))
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def current = JSON.parse(@pointer.fetch('body')).fetch('current')
|
||||
|
||||
def test_interrupted_publication_recovers_without_losing_another_channel
|
||||
publish_assets('3.41.2')
|
||||
stable = prepare('3.42.0')
|
||||
assert_equal nil, current
|
||||
assert_rejected { publisher.finish('3.42.0') }
|
||||
publish_assets('3.42.0', stable)
|
||||
# 模拟公开后更新指针中断,下一次发布在同一把锁内补写,再合并自己的通道
|
||||
beta = prepare('3.43.0-beta.1')
|
||||
assert_equal '3.42.0', current
|
||||
assert_equal({ 'stable' => '3.42.0', 'rc' => '3.42.0', 'beta' => '3.43.0-beta.1', 'alpha' => '3.43.0-beta.1' },
|
||||
YAML.safe_load(beta).dig('published', '3'))
|
||||
publish_assets('3.43.0-beta.1', beta)
|
||||
publisher.finish('3.43.0-beta.1')
|
||||
publisher.finish('3.43.0-beta.1')
|
||||
assert_rejected { publisher.finish('3.42.0') }
|
||||
assert_equal '3.43.0-beta.1', current
|
||||
experiment = YAML.safe_load(prepare('3.44.0-exp.1')).dig('published', '3')
|
||||
assert_equal({ '3.44.0' => '3.44.0-exp.1' }, experiment.fetch('exp'))
|
||||
assert_equal '3.43.0-beta.1', experiment.fetch('beta')
|
||||
assert_equal false, @calls.include?(['GET', 'releases'])
|
||||
end
|
||||
|
||||
def test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot
|
||||
publish_assets('3.42.0', prepare('3.42.0'))
|
||||
publisher.finish('3.42.0')
|
||||
beta = prepare('3.43.0-beta.1')
|
||||
publish_assets('3.43.0-beta.1', beta)
|
||||
release = @releases.fetch('3.43.0-beta.1')
|
||||
release['draft'] = true
|
||||
assert_rejected { publisher.finish('3.43.0-beta.1') }
|
||||
release['draft'] = false
|
||||
missing = release.fetch('assets').shift
|
||||
assert_rejected { publisher.finish('3.43.0-beta.1') }
|
||||
release.fetch('assets') << missing
|
||||
publish_assets('3.43.0-beta.1', beta.sub('3.42.0', '3.10.0'))
|
||||
assert_rejected { publisher.finish('3.43.0-beta.1') }
|
||||
assert_equal '3.42.0', current
|
||||
end
|
||||
end
|
||||
PublishedChannelsTest.new.test_interrupted_publication_recovers_without_losing_another_channel
|
||||
PublishedChannelsTest.new.test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot
|
||||
puts 'Published channel recovery and integrity checks passed'
|
||||
@@ -258,5 +258,4 @@ jobs:
|
||||
for script in .github/**/*.sh; do bash -n "$script"; done
|
||||
for script in .github/scripts/*.rb; do ruby -c "$script"; done
|
||||
ruby .github/scripts/toolchains.rb
|
||||
ruby .github/scripts/tests/published-channels-test.rb
|
||||
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
|
||||
|
||||
@@ -508,24 +508,12 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
INSTALLATION_NOTES: |
|
||||
## 📥 安装
|
||||
## Linux 一键安装(Docker Compose)
|
||||
|
||||
**Docker 部署:**
|
||||
```bash
|
||||
# 从 GitHub 容器镜像仓库拉取
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
||||
curl -fsSLO https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.release-info.outputs.tag }}/deploy/install.sh && CPR_RELEASE_TAG=${{ needs.release-info.outputs.tag }} bash install.sh
|
||||
```
|
||||
|
||||
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
|
||||
|
||||
**手动下载:**
|
||||
请从下方附件中下载适合当前平台的归档包。
|
||||
|
||||
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
|
||||
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
|
||||
|
||||
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
||||
|
||||
## 📚 文档
|
||||
|
||||
- [GitHub 仓库](https://github.com/${{ github.repository }})
|
||||
@@ -538,11 +526,11 @@ jobs:
|
||||
File.write(output, "#{notes}\n\n#{installation}\n")
|
||||
RUBY
|
||||
|
||||
- name: Prepare versioned channel snapshot under the publication lock
|
||||
- name: Check that the release is unpublished
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
run: ruby .github/scripts/published-channels.rb prepare "$VERSION"
|
||||
run: ruby .github/scripts/published-channels.rb check "$VERSION"
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
@@ -573,11 +561,11 @@ jobs:
|
||||
RELEASE_ID: ${{ steps.release.outputs.id }}
|
||||
run: ruby .github/scripts/publish-release.rb
|
||||
|
||||
- name: Advance update-index to the verified snapshot
|
||||
- name: Publish verified channels to main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
run: ruby .github/scripts/published-channels.rb finish "$VERSION"
|
||||
run: ruby .github/scripts/published-channels.rb publish "$VERSION"
|
||||
|
||||
recover-index:
|
||||
if: inputs.index_only
|
||||
@@ -589,8 +577,8 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Verify public assets and finish pending publication
|
||||
- name: Verify public assets and publish channels to main
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: ruby .github/scripts/published-channels.rb finish "$TAG"
|
||||
run: ruby .github/scripts/published-channels.rb publish "$TAG"
|
||||
|
||||
@@ -304,6 +304,12 @@ impl PendingAuthorizationMutation {
|
||||
"outbound_proxy_url".to_owned(),
|
||||
Value::String(proxy.expose_url().to_owned()),
|
||||
);
|
||||
if proxy.max_websocket_connections() != 0 {
|
||||
document.insert(
|
||||
"outbound_proxy_max_websocket_connections".to_owned(),
|
||||
Value::from(proxy.max_websocket_connections()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(id) = &self.outbound_proxy_id {
|
||||
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
|
||||
@@ -344,6 +350,11 @@ impl PendingAuthorizationMutation {
|
||||
.map(gateway_core::account::OutboundProxy::parse)
|
||||
.transpose()
|
||||
.map_err(|_| invalid())?;
|
||||
let proxy = match (proxy, document.outbound_proxy_max_websocket_connections) {
|
||||
(Some(proxy), Some(limit)) => Some(proxy.with_max_websocket_connections(limit)),
|
||||
(None, Some(_)) => return Err(invalid()),
|
||||
(proxy, None) => proxy,
|
||||
};
|
||||
Ok(Self::new(
|
||||
provider_kind,
|
||||
target,
|
||||
@@ -367,6 +378,8 @@ struct StoredAuthorizationMutationV1 {
|
||||
#[serde(default)]
|
||||
outbound_proxy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
outbound_proxy_max_websocket_connections: Option<u32>,
|
||||
#[serde(default)]
|
||||
outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -267,7 +267,8 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
|
||||
gateway_core::account::OutboundProxy::parse(
|
||||
"http://user:secret@proxy.example:8080",
|
||||
)
|
||||
.unwrap(),
|
||||
.unwrap()
|
||||
.with_max_websocket_connections(16),
|
||||
))
|
||||
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
|
||||
assert_eq!(
|
||||
@@ -293,6 +294,27 @@ fn pending_mutation_v1_should_keep_existing_storage_fields_and_reject_invalid_do
|
||||
let restored =
|
||||
PendingAuthorizationMutation::from_storage_v1(legacy.clone()).expect("v1 document");
|
||||
assert_eq!(Value::Object(restored.to_storage_v1()), legacy);
|
||||
let mut legacy_proxy = legacy.clone();
|
||||
legacy_proxy["outbound_proxy_url"] = json!("socks5h://proxy.example.invalid:1080");
|
||||
legacy_proxy["outbound_proxy_id"] = json!("proxy_saved");
|
||||
let restored = PendingAuthorizationMutation::from_storage_v1(legacy_proxy.clone())
|
||||
.expect("legacy proxy without a capacity field remains readable");
|
||||
assert_eq!(
|
||||
restored
|
||||
.outbound_proxy()
|
||||
.unwrap()
|
||||
.max_websocket_connections(),
|
||||
0
|
||||
);
|
||||
assert_eq!(Value::Object(restored.to_storage_v1()), legacy_proxy);
|
||||
for invalid in [json!(-1), json!(4294967296_u64), json!("16")] {
|
||||
let mut document = legacy_proxy.clone();
|
||||
document["outbound_proxy_max_websocket_connections"] = invalid;
|
||||
assert!(PendingAuthorizationMutation::from_storage_v1(document).is_err());
|
||||
}
|
||||
let mut missing_proxy = legacy.clone();
|
||||
missing_proxy["outbound_proxy_max_websocket_connections"] = json!(16);
|
||||
assert!(PendingAuthorizationMutation::from_storage_v1(missing_proxy).is_err());
|
||||
for (pointer, invalid) in [
|
||||
("/provider_kind", json!("")),
|
||||
("/target/account_id", json!("")),
|
||||
|
||||
@@ -7,7 +7,6 @@ use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::system::SystemUpdateChannel as Channel;
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::{Mutex, OnceCell};
|
||||
use tokio::time::Instant;
|
||||
use yaml_rust2::{
|
||||
@@ -15,14 +14,13 @@ use yaml_rust2::{
|
||||
parser::{Event, Parser},
|
||||
};
|
||||
|
||||
use super::release::{GitHubRelease, channel_accepts, channel_for_version, download_client};
|
||||
use super::release::{GitHubRelease, channel_accepts, channel_for_version};
|
||||
use super::{OperationError, SystemUpdateConfig, invalid, upstream};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Metadata {
|
||||
client: OnceCell<Client>,
|
||||
snapshot: Mutex<Option<(Version, Arc<Yaml>)>>,
|
||||
asset_until: Mutex<Option<Cooldown>>,
|
||||
raw_until: Mutex<Option<Cooldown>>,
|
||||
api_until: Mutex<Option<Cooldown>>,
|
||||
}
|
||||
|
||||
@@ -43,42 +41,18 @@ impl Metadata {
|
||||
&self,
|
||||
config: &SystemUpdateConfig,
|
||||
) -> Result<Arc<Yaml>, OperationError> {
|
||||
#[derive(Deserialize)]
|
||||
struct Pointer {
|
||||
current: Option<String>,
|
||||
}
|
||||
let pointer = self
|
||||
.by_tag(config, "update-index")
|
||||
.await?
|
||||
.filter(|r| r.tag_name == "update-index" && !r.draft && r.prerelease)
|
||||
.ok_or_else(|| upstream("更新仓库缺少公开的 update-index 指针"))?;
|
||||
let pointer: Pointer = serde_json::from_str(pointer.body.as_deref().unwrap_or_default())
|
||||
.map_err(|_| upstream("发行索引指针格式无效"))?;
|
||||
let version = pointer
|
||||
.current
|
||||
.ok_or_else(|| upstream("尚未发布通道索引"))?;
|
||||
let version = Version::parse(&version).map_err(|_| upstream("发行索引指针版本无效"))?;
|
||||
let mut snapshot = self.snapshot.lock().await;
|
||||
if let Some((previous, yaml)) = snapshot.as_ref()
|
||||
&& previous == &version
|
||||
{
|
||||
return Ok(Arc::clone(yaml));
|
||||
}
|
||||
// 正文只接受版本号,下载地址始终由已配置的仓库和固定附件名构造
|
||||
let repo = repository(config)?;
|
||||
let mut url =
|
||||
Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?;
|
||||
if url.host_str() == Some("api.github.com") {
|
||||
url = Url::parse("https://github.com").expect("固定的 GitHub 发行源");
|
||||
url = Url::parse("https://raw.githubusercontent.com").expect("固定的 GitHub raw 源");
|
||||
}
|
||||
url.set_path(&format!(
|
||||
"/{}/releases/download/v{version}/version.yaml",
|
||||
repository(config)?
|
||||
));
|
||||
let client = download_client(&config.github_api_base, Duration::from_secs(10))?;
|
||||
// 配置校验只额外接受回环源,测试时索引和 Release 均留在本机
|
||||
url.set_path(&format!("/{repo}/refs/heads/main/release/version.yaml"));
|
||||
let bytes = self
|
||||
.read(client.get(url), &self.asset_until, 128 * 1024)
|
||||
.read(url.as_str(), &self.raw_until, 128 * 1024, false)
|
||||
.await?
|
||||
.ok_or_else(|| upstream("发行附件缺少 version.yaml"))?;
|
||||
.ok_or_else(|| upstream("更新仓库缺少发布索引"))?;
|
||||
let source = std::str::from_utf8(&bytes).map_err(|_| upstream("发布索引格式无效"))?;
|
||||
let bad = || upstream("发布索引格式无效");
|
||||
// 远程 YAML 不使用别名,限制嵌套后再构造节点,避免小响应放大内存
|
||||
@@ -102,12 +76,7 @@ impl Metadata {
|
||||
if docs.len() != 1 || docs[0]["published"].as_hash().is_none() {
|
||||
return Err(upstream("发布索引缺少有效的 published 通道表"));
|
||||
}
|
||||
let yaml = Arc::new(docs.remove(0));
|
||||
if yaml["version"].as_str() != Some(version.to_string().as_str()) {
|
||||
return Err(upstream("发行索引附件与指针版本不符"));
|
||||
}
|
||||
*snapshot = Some((version, Arc::clone(&yaml)));
|
||||
Ok(yaml)
|
||||
Ok(Arc::new(docs.remove(0)))
|
||||
}
|
||||
|
||||
pub(super) async fn release(
|
||||
@@ -115,17 +84,29 @@ impl Metadata {
|
||||
config: &SystemUpdateConfig,
|
||||
version: &Version,
|
||||
) -> Result<Option<Arc<GitHubRelease>>, OperationError> {
|
||||
let stable = version.pre.is_empty();
|
||||
let mut url =
|
||||
Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?;
|
||||
let prerelease = !version.pre.is_empty();
|
||||
let version = version.to_string();
|
||||
let tag = format!("v{version}");
|
||||
let Some(release) = self.by_tag(config, &tag).await? else {
|
||||
url.path_segments_mut()
|
||||
.map_err(|()| invalid("发行查询地址无效"))?
|
||||
.pop_if_empty()
|
||||
.extend(repository(config)?.split('/'))
|
||||
.extend(["releases", "tags", &tag]);
|
||||
let Some(bytes) = self
|
||||
.read(url.as_str(), &self.api_until, 4 * 1024 * 1024, true)
|
||||
.await?
|
||||
else {
|
||||
// 当前运行版本可以没有公开说明;索引承诺的升级目标缺失属于短期查询失败
|
||||
if config.version.trim_start_matches('v') == version {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(upstream("发布索引指向的 Release 不存在"));
|
||||
};
|
||||
if release.tag_name != tag || release.draft || release.prerelease == stable {
|
||||
let release: GitHubRelease = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| upstream("GitHub 发行信息格式无效").with_source(error))?;
|
||||
if release.tag_name != tag || release.draft || release.prerelease != prerelease {
|
||||
return Err(upstream("GitHub 发行身份与发布索引不符"));
|
||||
}
|
||||
if config.version.trim_start_matches('v') != version {
|
||||
@@ -134,18 +115,20 @@ impl Metadata {
|
||||
Ok(Some(Arc::new(release)))
|
||||
}
|
||||
|
||||
async fn by_tag(
|
||||
async fn read(
|
||||
&self,
|
||||
config: &SystemUpdateConfig,
|
||||
tag: &str,
|
||||
) -> Result<Option<GitHubRelease>, OperationError> {
|
||||
let mut url =
|
||||
Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|()| invalid("发行查询地址无效"))?
|
||||
.pop_if_empty()
|
||||
.extend(repository(config)?.split('/'))
|
||||
.extend(["releases", "tags", tag]);
|
||||
url: &str,
|
||||
cooldown: &Mutex<Option<Cooldown>>,
|
||||
limit: usize,
|
||||
api: bool,
|
||||
) -> Result<Option<Vec<u8>>, OperationError> {
|
||||
if let Some(active) = cooldown
|
||||
.lock()
|
||||
.await
|
||||
.filter(|active| active.until > Instant::now())
|
||||
{
|
||||
return Err(active.warning());
|
||||
}
|
||||
let client = self
|
||||
.client
|
||||
.get_or_try_init(|| async {
|
||||
@@ -159,40 +142,15 @@ impl Metadata {
|
||||
})
|
||||
.await
|
||||
.map_err(|e| super::error::http("无法初始化发行查询连接", e))?;
|
||||
let bytes = self
|
||||
.read(
|
||||
client
|
||||
.get(url)
|
||||
.header("Accept", "application/vnd.github+json"),
|
||||
&self.api_until,
|
||||
4 * 1024 * 1024,
|
||||
)
|
||||
.await?;
|
||||
bytes
|
||||
.map(|bytes| {
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|error| upstream("GitHub 发行信息格式无效").with_source(error))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
request: reqwest::RequestBuilder,
|
||||
cooldown: &Mutex<Option<Cooldown>>,
|
||||
limit: usize,
|
||||
) -> Result<Option<Vec<u8>>, OperationError> {
|
||||
if let Some(active) = cooldown
|
||||
.lock()
|
||||
.await
|
||||
.filter(|active| active.until > Instant::now())
|
||||
{
|
||||
return Err(active.warning());
|
||||
let request = client.get(url);
|
||||
let mut response = if api {
|
||||
request.header("Accept", "application/vnd.github+json")
|
||||
} else {
|
||||
request
|
||||
}
|
||||
let mut response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| super::error::http("无法查询 GitHub 发行版本", e))?;
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| super::error::http("无法查询 GitHub 发行版本", e))?;
|
||||
let status = response.status();
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
|
||||
@@ -49,7 +49,7 @@ async fn temporary_channel_should_filter_future_releases_without_changing_runnin
|
||||
assert_eq!(version.latest_version, "1.1.0");
|
||||
assert!(!fixture.state().exists(), "检查通道不能写入实例偏好");
|
||||
let requests = server.received_requests().await.expect("requests");
|
||||
assert_eq!(requests.len(), 6);
|
||||
assert_eq!(requests.len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -52,7 +52,6 @@ async fn discovery_request_budget_is_constant_under_load() {
|
||||
.collect::<serde_json::Map<_, _>>()
|
||||
.into();
|
||||
}
|
||||
mount_pointer(&server).await;
|
||||
Mock::given(path(INDEX_PATH))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
@@ -81,7 +80,7 @@ async fn discovery_request_budget_is_constant_under_load() {
|
||||
.unwrap();
|
||||
assert_eq!(summary.latest_version, target);
|
||||
assert!(summary.notes.is_none());
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 3);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
|
||||
let mut callers = tokio::task::JoinSet::new();
|
||||
for i in 0..512 {
|
||||
@@ -103,9 +102,9 @@ async fn discovery_request_budget_is_constant_under_load() {
|
||||
for _ in 0..64 {
|
||||
assert!(service.version().await.unwrap().has_update);
|
||||
}
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 3);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
eprintln!(
|
||||
"history={history} concurrent_detail=512 local_probes=65 pointer=1 snapshot=1 release=1 lists=0 elapsed={:?}",
|
||||
"history={history} concurrent_detail=512 local_probes=65 index=1 release=1 lists=0 elapsed={:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
@@ -117,7 +116,6 @@ async fn cold_summary_stampede_and_slow_source_do_not_block_runtime_probes() {
|
||||
let fixture = Fixture::new();
|
||||
let service = Arc::new(fixture.service(&server));
|
||||
let (started, mut observed) = tokio::sync::mpsc::unbounded_channel();
|
||||
mount_pointer(&server).await;
|
||||
Mock::given(path(INDEX_PATH))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
started.send(()).unwrap();
|
||||
@@ -161,7 +159,7 @@ async fn cold_summary_stampede_and_slow_source_do_not_block_runtime_probes() {
|
||||
while let Some(summary) = callers.join_next().await {
|
||||
assert!(summary.unwrap().has_update);
|
||||
}
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 3);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -215,7 +213,7 @@ async fn api_cooldown_is_shared_and_summary_does_not_advertise_an_unverified_rel
|
||||
.unwrap()
|
||||
.has_update
|
||||
);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 3);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,15 +222,14 @@ async fn invalid_or_missing_published_data_does_not_fallback_to_build_version_or
|
||||
for body in [
|
||||
"version: '1.9.9'".to_owned(),
|
||||
"version: '9.0.0'\npublished: {'2': {stable: '2.0.0'}}".to_owned(),
|
||||
format!("version: '{SNAPSHOT}'\npublished: {{'1': {{stable: '2.0.0'}}}}"),
|
||||
format!("version: '{SNAPSHOT}'\npublished: {{'1': {{stable: '1.2.0-rc.1'}}}}"),
|
||||
format!("version: '{BUILD_VERSION}'\npublished: {{'1': {{stable: '2.0.0'}}}}"),
|
||||
format!("version: '{BUILD_VERSION}'\npublished: {{'1': {{stable: '1.2.0-rc.1'}}}}"),
|
||||
"bad: &loop [1]\npublished: *loop".to_owned(),
|
||||
"x".repeat(128 * 1024 + 1),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
let service = fixture.service(&server);
|
||||
mount_pointer(&server).await;
|
||||
Mock::given(path(INDEX_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.expect(1)
|
||||
@@ -246,7 +243,7 @@ async fn invalid_or_missing_published_data_does_not_fallback_to_build_version_or
|
||||
assert!(detail.warning.is_some());
|
||||
assert!(!detail.has_update);
|
||||
}
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,33 +280,18 @@ async fn install_rechecks_even_a_warm_candidate_and_never_downloads_a_changed_ta
|
||||
let fixture = Fixture::new();
|
||||
let service = fixture.service(&server);
|
||||
let requests = AtomicUsize::new(0);
|
||||
Mock::given(path(POINTER_PATH))
|
||||
Mock::given(path(INDEX_PATH))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
pointer_response(if requests.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
SNAPSHOT
|
||||
let target = if requests.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
"1.1.0"
|
||||
} else {
|
||||
"4.0.0"
|
||||
})
|
||||
"1.2.0"
|
||||
};
|
||||
ResponseTemplate::new(200).set_body_json(fixture_index(target))
|
||||
})
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
for (path_name, latest, target) in [
|
||||
(INDEX_PATH, SNAPSHOT, "1.1.0"),
|
||||
(
|
||||
"/owner/repository/releases/download/v4.0.0/version.yaml",
|
||||
"4.0.0",
|
||||
"1.2.0",
|
||||
),
|
||||
] {
|
||||
let mut index = fixture_index(target);
|
||||
index["version"] = latest.into();
|
||||
Mock::given(path(path_name))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(index))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
Mock::given(path("/repos/owner/repository/releases/tags/v1.1.0"))
|
||||
.respond_with(release_response("1.1.0", available_assets("1.1.0")))
|
||||
.expect(1)
|
||||
@@ -323,7 +305,7 @@ async fn install_rechecks_even_a_warm_candidate_and_never_downloads_a_changed_ta
|
||||
let status = complete_update(&service, &detail.latest_version).await;
|
||||
assert_eq!(status.operation.status, SystemOperationStatus::Failed);
|
||||
assert!(status.operation.error.unwrap().contains("重新检查并确认"));
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 5);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -352,7 +334,7 @@ async fn channels_pointing_to_one_release_share_its_detail() {
|
||||
.has_update
|
||||
);
|
||||
}
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 3);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -360,10 +342,9 @@ async fn transient_release_absence_expires_and_refresh_interval_is_bounded() {
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
let service = fixture.service(&server);
|
||||
mount_pointer(&server).await;
|
||||
Mock::given(path(INDEX_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(fixture_index(TARGET_VERSION)))
|
||||
.expect(1)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let calls = AtomicUsize::new(0);
|
||||
@@ -463,7 +444,7 @@ async fn metadata_never_follows_redirects_or_accepts_oversized_release() {
|
||||
} else {
|
||||
"大小限制"
|
||||
}));
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 3);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +454,6 @@ async fn slow_metadata_times_out_without_retry_or_blocking_future_version_reads(
|
||||
let fixture = Fixture::new();
|
||||
let service = Arc::new(fixture.service(&server));
|
||||
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel();
|
||||
mount_pointer(&server).await;
|
||||
Mock::given(path(INDEX_PATH))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
sent.send(()).unwrap();
|
||||
@@ -497,7 +477,7 @@ async fn slow_metadata_times_out_without_retry_or_blocking_future_version_reads(
|
||||
assert!(detail.warning.is_some());
|
||||
assert!(!detail.has_update);
|
||||
assert_eq!(service.version().await.unwrap().version, "1.0.0");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -525,27 +505,3 @@ async fn summary_and_local_probe_require_downloadable_target() {
|
||||
assert!(!service.version().await.unwrap().has_update);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unpublished_or_untrusted_pointer_cannot_redirect_discovery() {
|
||||
for (draft, current) in [
|
||||
(true, Some("3.42.0")),
|
||||
(false, None),
|
||||
(false, Some("https://example.com/v1")),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
let service = fixture.service(&server);
|
||||
Mock::given(path(POINTER_PATH)).respond_with(ResponseTemplate::new(200).set_body_json(
|
||||
serde_json::json!({"tag_name":"update-index","prerelease":true,"draft":draft,
|
||||
"body":serde_json::json!({"current":current,"pending":{"version":"3.42.0"}}).to_string()})
|
||||
)).expect(1).mount(&server).await;
|
||||
let detail = service
|
||||
.update_detail(SystemUpdateCheck::Summary, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!detail.has_update);
|
||||
assert!(detail.warning.is_some());
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ async fn archive_is_the_last_remote_request_before_activation() {
|
||||
assert_eq!(
|
||||
paths,
|
||||
[
|
||||
POINTER_PATH,
|
||||
INDEX_PATH,
|
||||
&format!("/repos/owner/repository/releases/tags/v{TARGET_VERSION}"),
|
||||
"/checksums",
|
||||
|
||||
@@ -515,23 +515,8 @@ async fn mount_release_json(
|
||||
}
|
||||
}
|
||||
|
||||
const POINTER_PATH: &str = "/repos/owner/repository/releases/tags/update-index";
|
||||
const SNAPSHOT: &str = "4.0.0-beta.1";
|
||||
const INDEX_PATH: &str = "/owner/repository/releases/download/v4.0.0-beta.1/version.yaml";
|
||||
|
||||
fn pointer_response(version: &str) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"tag_name":"update-index", "draft":false, "prerelease":true,
|
||||
"body":serde_json::json!({"current":version}).to_string()
|
||||
}))
|
||||
}
|
||||
|
||||
async fn mount_pointer(server: &MockServer) {
|
||||
Mock::given(path(POINTER_PATH))
|
||||
.respond_with(pointer_response(SNAPSHOT))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
const BUILD_VERSION: &str = "4.0.0-beta.1";
|
||||
const INDEX_PATH: &str = "/owner/repository/refs/heads/main/release/version.yaml";
|
||||
|
||||
fn available_assets(version: &str) -> Vec<serde_json::Value> {
|
||||
[archive_name(version), "checksums.txt".to_owned()].into_iter()
|
||||
@@ -541,12 +526,10 @@ fn available_assets(version: &str) -> Vec<serde_json::Value> {
|
||||
fn fixture_index(version: &str) -> serde_json::Value {
|
||||
let major = version.split('.').next().unwrap();
|
||||
let base = version.split('-').next().unwrap();
|
||||
serde_json::json!({"version":SNAPSHOT, "published": {major: {"stable": version,"beta":version,"rc":version,"alpha":version,"exp":{base: version}}}})
|
||||
serde_json::json!({"version":BUILD_VERSION, "published": {major: {"stable": version,"beta":version,"rc":version,"alpha":version,"exp":{base: version}}}})
|
||||
}
|
||||
|
||||
async fn mount_index(server: &MockServer, mut index: serde_json::Value) {
|
||||
mount_pointer(server).await;
|
||||
index["version"] = SNAPSHOT.into();
|
||||
async fn mount_index(server: &MockServer, index: serde_json::Value) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(INDEX_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(index))
|
||||
|
||||
@@ -129,18 +129,15 @@ async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() {
|
||||
#[tokio::test]
|
||||
async fn diagnostics_report_a_published_process_fault_without_plugin_details() {
|
||||
let (cache, store, runtime) = super::setup().await;
|
||||
let controls = tempfile::tempdir().unwrap();
|
||||
let exit = controls.path().join("exit");
|
||||
let mut snapshot = store.snapshot.lock().unwrap().clone();
|
||||
snapshot.instances[0].configuration = serde_json::json!({"exit_after_ready_ms":20});
|
||||
snapshot.instances[0].configuration = serde_json::json!({"exit_after_ready_signals":[exit]});
|
||||
let active = PluginPreparation::prepare(&runtime, snapshot.clone())
|
||||
.await
|
||||
.expect("candidate starts before the controlled exit");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while active.is_ready() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("worker exit observed");
|
||||
std::fs::write(exit, b"exit").unwrap();
|
||||
wait_until_unready(&active).await;
|
||||
|
||||
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
|
||||
&runtime,
|
||||
@@ -171,17 +168,20 @@ async fn diagnostics_report_a_published_process_fault_without_plugin_details() {
|
||||
#[tokio::test]
|
||||
async fn a_published_process_crash_preserves_the_serving_snapshot_and_other_plugin_status() {
|
||||
let (cache, store, runtime) = super::setup().await;
|
||||
let controls = tempfile::tempdir().unwrap();
|
||||
let exit = controls.path().join("exit");
|
||||
let snapshot = {
|
||||
let mut snapshot = store.snapshot.lock().unwrap();
|
||||
let mut failed = snapshot.instances[0].clone();
|
||||
failed.id = "crashing-plugin".into();
|
||||
failed.configuration = serde_json::json!({"exit_after_ready_ms":100});
|
||||
failed.configuration = serde_json::json!({"exit_after_ready_signals":[exit]});
|
||||
snapshot.instances.push(failed);
|
||||
snapshot.clone()
|
||||
};
|
||||
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::write(exit, b"exit").unwrap();
|
||||
wait_until_unready(&generation).await;
|
||||
assert!(generation.can_serve());
|
||||
assert!(!generation.is_ready());
|
||||
|
||||
@@ -94,29 +94,32 @@ async fn registration_with_a_changed_contribution_id_is_rejected() {
|
||||
async fn restart_circuit_caps_short_lived_crashes_and_a_new_revision_recovers() {
|
||||
let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig {
|
||||
maximum_failures: NonZeroUsize::new(3).unwrap(),
|
||||
stability_window: Duration::from_secs(1),
|
||||
// 本用例只验证连续崩溃的预算,不让宿主调度延迟触发稳定运行后的重置
|
||||
stability_window: Duration::MAX,
|
||||
};
|
||||
let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await;
|
||||
let controls = tempfile::tempdir().unwrap();
|
||||
let marker = controls.path().join("starts.jsonl");
|
||||
let exits = [1, 2, 3].map(|start| controls.path().join(format!("exit-{start}")));
|
||||
let mut snapshot = store.snapshot.lock().unwrap().clone();
|
||||
snapshot.instances[0].configuration = serde_json::json!({
|
||||
"startup_marker": marker,
|
||||
"exit_after_ready_ms": 20,
|
||||
"exit_after_ready_signals": exits,
|
||||
});
|
||||
let mut active = PluginPreparation::prepare(&runtime, snapshot.clone())
|
||||
.await
|
||||
.expect("first incarnation");
|
||||
for expected_start in 2..=3 {
|
||||
for (index, exit) in exits.iter().enumerate() {
|
||||
assert_eq!(startup_count(&marker), index + 1);
|
||||
// Ready 早于注册完成;只有宿主拿到这一代后,才让对应的进程退出
|
||||
std::fs::write(exit, b"exit").unwrap();
|
||||
wait_until_unready(&active).await;
|
||||
let replacement = PluginPreparation::prepare(&runtime, snapshot.clone())
|
||||
.await
|
||||
.expect("failure budget still permits a restart");
|
||||
drop(active);
|
||||
active = replacement;
|
||||
assert_eq!(startup_count(&marker), expected_start);
|
||||
if index + 1 < exits.len() {
|
||||
active = PluginPreparation::prepare(&runtime, snapshot.clone())
|
||||
.await
|
||||
.expect("failure budget still permits a restart");
|
||||
}
|
||||
}
|
||||
wait_until_unready(&active).await;
|
||||
assert!(
|
||||
PluginPreparation::prepare(&runtime, snapshot.clone())
|
||||
.await
|
||||
@@ -434,7 +437,6 @@ async fn abandoned_candidate_keeps_shared_process_failure_in_restart_budget() {
|
||||
let mut snapshot = store.snapshot.lock().unwrap().clone();
|
||||
snapshot.instances[0].configuration = serde_json::json!({
|
||||
"startup_marker":marker,
|
||||
"exit_after_ready_delays_ms":[null,null],
|
||||
"exit_after_ready_signals":[old_exit,null],
|
||||
});
|
||||
let mut other = snapshot.instances[0].clone();
|
||||
|
||||
@@ -1546,11 +1546,6 @@ async fn main() {
|
||||
resumptions: Mutex::new(BTreeMap::new()),
|
||||
streams: Mutex::new(BTreeMap::new()),
|
||||
});
|
||||
let exit_after_ready = peer.configuration["exit_after_ready_delays_ms"]
|
||||
.as_array()
|
||||
.and_then(|delays| delays.get(startup))
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| peer.configuration["exit_after_ready_ms"].as_u64());
|
||||
let exit_after_ready_signal = peer.configuration["exit_after_ready_signals"]
|
||||
.as_array()
|
||||
.and_then(|signals| signals.get(startup))
|
||||
@@ -1571,12 +1566,7 @@ async fn main() {
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
if let Some(delay) = exit_after_ready {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||
std::process::exit(7);
|
||||
});
|
||||
} else if let Some(signal) = exit_after_ready_signal {
|
||||
if let Some(signal) = exit_after_ready_signal {
|
||||
tokio::spawn(async move {
|
||||
while !std::path::Path::new(&signal).exists() {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
|
||||
@@ -692,7 +692,12 @@ keep、replace 或 delete;全部完成后宿主才原子提交新配置与状
|
||||
宿主注入只读的 `window.codexProxyPlugin`。`request({ method, path, query?, contentType?, body? })` 只能访问当前版本
|
||||
已注册的管理路由,返回 `{ status, contentType, body: ArrayBuffer }`;`callbackTicket({ path, ttlSeconds })` 为已注册
|
||||
回调签发 1–600 秒的一次性地址;`resourceUrl(path)` 只解析同一制品已注册的非 HTML 资源。`theme` 为当前
|
||||
`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。页面不能指定其他实例、制品、版本、URL 或请求 header
|
||||
`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。管理请求不能指定其他实例、制品、版本、URL 或请求 header
|
||||
|
||||
需要跳转到外部页面时,在插件页面的用户点击处理器中调用 `openExternal(url): Promise<void>`。
|
||||
地址必须是绝对 HTTPS URL,不能包含用户名或密码。宿主展示目标主机名和非默认端口,管理员可打开新标签页、复制链接或取消。
|
||||
Promise 成功只表示宿主已接受确认请求,不代表链接已打开;无用户操作、地址无效或已有链接待确认时,请求会被拒绝。
|
||||
切换页面会关闭确认框。兼容较早的 v2 宿主时,先检查 `typeof window.codexProxyPlugin.openExternal === 'function'`
|
||||
|
||||
普通模型请求使用标准 `Response` 接口:
|
||||
|
||||
|
||||
@@ -275,6 +275,7 @@ async fn connect(
|
||||
REDIS_NAMESPACE,
|
||||
)?));
|
||||
|
||||
let lease_cleanup = provider_leases.cleanup_worker();
|
||||
let provider_ports = ProviderStorePorts::new(
|
||||
account_store,
|
||||
provider_leases,
|
||||
@@ -311,6 +312,7 @@ async fn connect(
|
||||
client_key_usage_writer,
|
||||
admission_release_writer,
|
||||
budget_writer,
|
||||
lease_cleanup,
|
||||
)?,
|
||||
None,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 监督本进程账号租约的释放任务,关闭时取消持有请求并有界等待 Redis 清理
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError};
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, oneshot};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct SchedulingLeaseCleanup(Arc<State>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
stopping: CancellationToken,
|
||||
acquisitions: RwLock<()>,
|
||||
tasks: Mutex<JoinSet<()>>,
|
||||
}
|
||||
|
||||
pub(crate) struct SchedulingLeaseGuard {
|
||||
_release: oneshot::Sender<()>,
|
||||
// 活跃请求可能比组合根活得久,不能因端口析构提前中止其续期与租约
|
||||
_cleanup: SchedulingLeaseCleanup,
|
||||
}
|
||||
|
||||
impl SchedulingLeaseCleanup {
|
||||
// 关闭必须等已发出的申请完成登记;不能漏掉 Redis 已创建、尚未返回的租约
|
||||
pub(crate) async fn acquire(&self) -> Option<RwLockReadGuard<'_, ()>> {
|
||||
let permit = self.0.acquisitions.read().await;
|
||||
(!self.0.stopping.is_cancelled()).then_some(permit)
|
||||
}
|
||||
|
||||
pub(crate) fn track(
|
||||
&self,
|
||||
cancellation: CancellationToken,
|
||||
release: impl Future<Output = crate::StoreResult<bool>> + Send + 'static,
|
||||
) -> SchedulingLeaseGuard {
|
||||
let (guard, dropped) = oneshot::channel::<()>();
|
||||
let stopping = self.0.stopping.clone();
|
||||
let mut tasks = self
|
||||
.0
|
||||
.tasks
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
while let Some(result) = tasks.try_join_next() {
|
||||
report_join(result);
|
||||
}
|
||||
tasks.spawn(async move {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = stopping.cancelled() => cancellation.cancel(),
|
||||
_ = dropped => {}
|
||||
}
|
||||
if let Err(error) = release.await {
|
||||
tracing::warn!(%error, "账号租约释放失败,依赖 TTL 回收");
|
||||
}
|
||||
});
|
||||
SchedulingLeaseGuard {
|
||||
_release: guard,
|
||||
_cleanup: self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {
|
||||
self.0.stopping.cancel();
|
||||
let drain = async {
|
||||
let _closed = self.0.acquisitions.write().await;
|
||||
let mut tasks = std::mem::take(
|
||||
&mut *self
|
||||
.0
|
||||
.tasks
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner()),
|
||||
);
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
report_join(result);
|
||||
}
|
||||
};
|
||||
if tokio::time::timeout(SHUTDOWN_TIMEOUT, drain).await.is_err() {
|
||||
tracing::warn!("账号租约关闭清理超时,剩余租约依赖 TTL 回收");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonTask for SchedulingLeaseCleanup {
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
context.cancellation().cancelled().await;
|
||||
self.shutdown().await;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn report_join(result: Result<(), tokio::task::JoinError>) {
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error, "账号租约释放任务中断,依赖 TTL 回收");
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ use crate::{Revision, StoreError, StoreResult, redis_unavailable, require_nonemp
|
||||
|
||||
use super::{MAX_REDIS_EXACT_INTEGER, namespace, resource_fingerprint};
|
||||
|
||||
mod cleanup;
|
||||
mod details;
|
||||
|
||||
const SIGNAL_TTL_MILLIS: u64 = 24 * 60 * 60 * 1_000;
|
||||
@@ -255,6 +256,17 @@ impl CredentialLeaseGuard {
|
||||
deadline: gateway_core::lifecycle::Deadline,
|
||||
cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
) -> StoreResult<impl gateway_core::provider_ports::ProviderLeaseGuard> {
|
||||
Ok(RenewingSchedulingLease {
|
||||
_renewal: self.renewal(deadline, cancellation)?,
|
||||
_guard: self,
|
||||
})
|
||||
}
|
||||
|
||||
fn renewal(
|
||||
&self,
|
||||
deadline: gateway_core::lifecycle::Deadline,
|
||||
cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
) -> StoreResult<crate::lease_renewal::LeaseRenewal> {
|
||||
let repository = self.repository.clone();
|
||||
let lease_request = self.request.clone();
|
||||
let grant = self
|
||||
@@ -265,7 +277,7 @@ impl CredentialLeaseGuard {
|
||||
entity: "credential lease",
|
||||
message: "lease has already been released".to_owned(),
|
||||
})?;
|
||||
let renewal = crate::lease_renewal::LeaseRenewal::spawn(
|
||||
Ok(crate::lease_renewal::LeaseRenewal::spawn(
|
||||
deadline,
|
||||
Some((
|
||||
cancellation,
|
||||
@@ -283,11 +295,7 @@ impl CredentialLeaseGuard {
|
||||
.map(|grant| grant.is_some())
|
||||
})
|
||||
},
|
||||
);
|
||||
Ok(RenewingSchedulingLease {
|
||||
_renewal: renewal,
|
||||
_guard: self,
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -591,6 +599,7 @@ pub(crate) struct RedisProviderLeaseCoordinator {
|
||||
repository: RedisCredentialLeaseRepository,
|
||||
process_id: String,
|
||||
sequence: AtomicU64,
|
||||
cleanup: cleanup::SchedulingLeaseCleanup,
|
||||
}
|
||||
|
||||
impl RedisProviderLeaseCoordinator {
|
||||
@@ -600,9 +609,14 @@ impl RedisProviderLeaseCoordinator {
|
||||
repository,
|
||||
process_id: format!("gateway_{}", Uuid::now_v7().simple()),
|
||||
sequence: AtomicU64::new(0),
|
||||
cleanup: cleanup::SchedulingLeaseCleanup::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup_worker(&self) -> impl gateway_core::task::DaemonTask + use<> {
|
||||
self.cleanup.clone()
|
||||
}
|
||||
|
||||
fn owner_id(&self, operation: &str) -> String {
|
||||
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed);
|
||||
format!("{}:{operation}:{sequence}", self.process_id)
|
||||
@@ -662,6 +676,12 @@ impl RedisProviderLeaseCoordinator {
|
||||
&self,
|
||||
request: &ProviderSchedulingLeaseRequest,
|
||||
) -> Result<ProviderLeaseAcquisition, ProviderStoreError> {
|
||||
let _acquisition = self.cleanup.acquire().await.ok_or_else(|| {
|
||||
ProviderStoreError::new(
|
||||
ProviderStoreErrorKind::Unavailable,
|
||||
"account lease coordinator is shutting down",
|
||||
)
|
||||
})?;
|
||||
let ttl = request.deadline().bounded(REQUEST_LEASE_TTL);
|
||||
if ttl.is_zero() {
|
||||
return Err(ProviderStoreError::new(
|
||||
@@ -685,13 +705,18 @@ impl RedisProviderLeaseCoordinator {
|
||||
.map_err(|source| crate::provider_unavailable("acquire scheduling lease", source))?;
|
||||
Ok(match acquisition {
|
||||
CredentialBoundedLeaseAcquisition::Acquired(guard) => {
|
||||
ProviderLeaseAcquisition::Acquired(Box::new(
|
||||
guard
|
||||
.maintain(request.deadline(), request.cancellation())
|
||||
.map_err(|source| {
|
||||
crate::provider_unavailable("maintain scheduling lease", source)
|
||||
})?,
|
||||
))
|
||||
let renewal = guard
|
||||
.renewal(request.deadline(), request.cancellation())
|
||||
.map_err(|source| {
|
||||
crate::provider_unavailable("maintain scheduling lease", source)
|
||||
})?;
|
||||
ProviderLeaseAcquisition::Acquired(Box::new(self.cleanup.track(
|
||||
request.cancellation(),
|
||||
async move {
|
||||
drop(renewal);
|
||||
guard.release().await
|
||||
},
|
||||
)))
|
||||
}
|
||||
CredentialBoundedLeaseAcquisition::Busy { retry_after } => {
|
||||
ProviderLeaseAcquisition::Busy { retry_after }
|
||||
|
||||
@@ -105,6 +105,7 @@ pub(crate) fn store_worker_contributions(
|
||||
client_key_usage_writer: postgres::PgClientApiKeyUsageWriter,
|
||||
admission_release_writer: redis::ClientAdmissionReleaseWriter,
|
||||
budget_writer: postgres::ClientBudgetWriter,
|
||||
lease_cleanup: impl DaemonTask + 'static,
|
||||
) -> StoreResult<Vec<WorkerContribution>> {
|
||||
let stale_id = WorkerId::try_new(WorkerKind::StaleModelRequestRecovery, "postgres")
|
||||
.map_err(worker_definition_error)?;
|
||||
@@ -117,10 +118,22 @@ pub(crate) fn store_worker_contributions(
|
||||
.map_err(worker_definition_error)?;
|
||||
let budget_flush_id = WorkerId::try_new(WorkerKind::OpsFlush, "postgres_client_budget")
|
||||
.map_err(worker_definition_error)?;
|
||||
let lease_cleanup_id = WorkerId::try_new(WorkerKind::OpsFlush, "redis_account_leases")
|
||||
.map_err(worker_definition_error)?;
|
||||
let ops_flush_restart =
|
||||
DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(worker_definition_error)?;
|
||||
Ok(vec![
|
||||
WorkerContribution::Registration(
|
||||
WorkerRegistration::try_new(
|
||||
lease_cleanup_id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart: ops_flush_restart,
|
||||
task: Box::new(lease_cleanup),
|
||||
},
|
||||
)
|
||||
.map_err(worker_definition_error)?,
|
||||
),
|
||||
WorkerContribution::Registration(
|
||||
WorkerRegistration::try_new(
|
||||
budget_flush_id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 验证 Store 认证、凭据编码与只读检查 Bundle 的写入边界
|
||||
//! 验证 Store 认证、凭据编码、只读写入边界与账号租约关闭清理
|
||||
|
||||
use gateway_core::{
|
||||
account::{
|
||||
@@ -13,6 +13,166 @@ use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
use crate::support::config_connection;
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_releases_owned_account_pools_and_preserves_other_instances() {
|
||||
use gateway_core::lifecycle::{CancellationToken, Deadline};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderConcurrencyPool, ProviderLeaseAcquisition, ProviderLeaseRequest,
|
||||
ProviderSchedulingLeaseRequest,
|
||||
};
|
||||
use gateway_core::task::{WorkerContribution, WorkerDaemonContext, WorkerRunnable};
|
||||
use std::{num::NonZeroU32, time::Duration};
|
||||
|
||||
let (Some(database), Some(redis)) = (
|
||||
crate::support::test_env("CPR_TEST_DATABASE_URL"),
|
||||
crate::support::test_env("CPR_TEST_REDIS_URL"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let mut config: StoreConfig = serde_json::from_value(json!({
|
||||
"database": config_connection(url::Url::parse(&database).unwrap()),
|
||||
"redis": config_connection(url::Url::parse(&redis).unwrap()),
|
||||
}))
|
||||
.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
config.resolve_and_validate(directory.path()).unwrap();
|
||||
// 只使用真实 Bundle 的租约端口与关闭任务,不需要创建业务账号或启动其他 Worker
|
||||
let mut first = gateway_store::initialize_read_only(config.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut second = gateway_store::initialize_read_only(config).await.unwrap();
|
||||
let first_port = first.provider_ports().leases();
|
||||
let second_port = second.provider_ports().leases();
|
||||
let account =
|
||||
ProviderAccountId::new(format!("acct_shutdown_{}", uuid::Uuid::new_v4().simple())).unwrap();
|
||||
let mut first_requests = Vec::new();
|
||||
let mut first_guards = Vec::new();
|
||||
let mut second_requests = Vec::new();
|
||||
let mut second_guards = Vec::new();
|
||||
for pool in [
|
||||
ProviderConcurrencyPool::Shared,
|
||||
ProviderConcurrencyPool::Reserved,
|
||||
] {
|
||||
let request = ProviderSchedulingLeaseRequest::new(
|
||||
ProviderKind::new("openai").unwrap(),
|
||||
account.clone(),
|
||||
CredentialRevision::new(1).unwrap(),
|
||||
NonZeroU32::new(2).unwrap(),
|
||||
Deadline::default(),
|
||||
)
|
||||
.with_concurrency_pool(pool);
|
||||
let ProviderLeaseAcquisition::Acquired(first_guard) = first_port
|
||||
.try_acquire(ProviderLeaseRequest::Scheduling(request.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("first instance must acquire a slot")
|
||||
};
|
||||
let other = request.clone().with_cancellation(CancellationToken::new());
|
||||
let ProviderLeaseAcquisition::Acquired(second_guard) = second_port
|
||||
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("second instance must acquire a slot")
|
||||
};
|
||||
assert!(matches!(
|
||||
second_port
|
||||
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
|
||||
.await
|
||||
.unwrap(),
|
||||
ProviderLeaseAcquisition::Busy { .. }
|
||||
));
|
||||
first_requests.push(request);
|
||||
second_requests.push(other);
|
||||
first_guards.push(first_guard);
|
||||
second_guards.push(second_guard);
|
||||
}
|
||||
assert_eq!(
|
||||
second_port
|
||||
.account_in_flight(std::slice::from_ref(&account))
|
||||
.await
|
||||
.unwrap()[&account],
|
||||
2
|
||||
);
|
||||
|
||||
for (index, store) in [&mut first, &mut second].into_iter().enumerate() {
|
||||
let cleanup = store
|
||||
.take_worker_contributions()
|
||||
.into_iter()
|
||||
.find_map(|contribution| {
|
||||
let WorkerContribution::Registration(registration) = contribution else {
|
||||
return None;
|
||||
};
|
||||
if registration.id.owner() != "redis_account_leases" {
|
||||
return None;
|
||||
}
|
||||
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
|
||||
panic!("cleanup is a daemon")
|
||||
};
|
||||
Some(task)
|
||||
})
|
||||
.expect("account lease shutdown worker must be registered");
|
||||
let stopping = CancellationToken::new();
|
||||
stopping.cancel();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
cleanup.run(WorkerDaemonContext::new(stopping)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if index == 0 {
|
||||
assert_eq!(
|
||||
second_port
|
||||
.account_in_flight(std::slice::from_ref(&account))
|
||||
.await
|
||||
.unwrap()[&account],
|
||||
1
|
||||
);
|
||||
for (first_request, other) in first_requests.iter().zip(&second_requests) {
|
||||
assert!(first_request.cancellation().is_cancelled());
|
||||
assert!(!other.cancellation().is_cancelled());
|
||||
assert!(
|
||||
first_port
|
||||
.try_acquire(ProviderLeaseRequest::Scheduling(first_request.clone()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let replacement = second_port
|
||||
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
&replacement,
|
||||
ProviderLeaseAcquisition::Acquired(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
second_port
|
||||
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
|
||||
.await
|
||||
.unwrap(),
|
||||
ProviderLeaseAcquisition::Busy { .. }
|
||||
));
|
||||
drop(replacement);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
second_requests
|
||||
.iter()
|
||||
.all(|request| request.cancellation().is_cancelled())
|
||||
);
|
||||
assert_eq!(
|
||||
second_port
|
||||
.account_in_flight(std::slice::from_ref(&account))
|
||||
.await
|
||||
.unwrap()[&account],
|
||||
0
|
||||
);
|
||||
drop((first_guards, second_guards));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() {
|
||||
let (Some(database), Some(redis)) = (
|
||||
|
||||
@@ -39,6 +39,130 @@ async fn authorization_persists_an_unnamed_account_with_its_email() {
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorization_preserves_saved_proxy_capacity_across_pending_storage() {
|
||||
use gateway_core::account::OutboundProxy;
|
||||
|
||||
let Some(database) = TestDatabase::create("authorization_proxy_snapshot").await else {
|
||||
return;
|
||||
};
|
||||
let proxy_url = "socks5h://proxy.example.invalid:1080";
|
||||
let proxy_id = "proxy_synthetic";
|
||||
sqlx::query(
|
||||
"insert into outbound_proxies (id, name, proxy_url, enabled, max_websocket_connections) \
|
||||
values ($1, 'Synthetic proxy', $2, true, 16)",
|
||||
)
|
||||
.bind(proxy_id)
|
||||
.bind(proxy_url)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut authorization, context) = command(credential("acct_proxy_test", "synthetic-user"));
|
||||
let pending = authorization
|
||||
.pending
|
||||
.with_outbound_proxy(Some(
|
||||
OutboundProxy::parse(proxy_url)
|
||||
.unwrap()
|
||||
.with_max_websocket_connections(16),
|
||||
))
|
||||
.with_outbound_proxy_id(Some(proxy_id.into()));
|
||||
authorization.pending = PendingAuthorizationMutation::from_storage_v1(
|
||||
serde_json::Value::Object(pending.to_storage_v1()),
|
||||
)
|
||||
.unwrap();
|
||||
if let AuthorizationCredentialCommit::Create(credential) = &mut authorization.credential {
|
||||
credential.outbound_proxy = authorization.pending.outbound_proxy().cloned();
|
||||
}
|
||||
let store = admin_account_store(&database.pool);
|
||||
let committed = store
|
||||
.commit_authorization(authorization, &context)
|
||||
.await
|
||||
.expect("unchanged saved proxy must not cause an authorization conflict");
|
||||
let saved_proxy: Option<String> =
|
||||
sqlx::query_scalar("select outbound_proxy_id from provider_accounts where id = $1")
|
||||
.bind(committed.result.account_id.as_str())
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved_proxy.as_deref(), Some(proxy_id));
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorization_rejects_a_saved_proxy_that_changed_after_start() {
|
||||
use gateway_admin::ports::store::AdminStoreErrorKind;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
|
||||
let Some(database) = TestDatabase::create("authorization_proxy_changed").await else {
|
||||
return;
|
||||
};
|
||||
let proxy_url = "socks5h://proxy.example.invalid:1080";
|
||||
let proxy_id = "proxy_synthetic";
|
||||
sqlx::query(
|
||||
"insert into outbound_proxies (id, name, proxy_url, enabled, max_websocket_connections) \
|
||||
values ($1, 'Synthetic proxy', $2, true, 16)",
|
||||
)
|
||||
.bind(proxy_id)
|
||||
.bind(proxy_url)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let store = admin_account_store(&database.pool);
|
||||
for (current_url, limit, enabled, expected) in [
|
||||
(
|
||||
"socks5h://other.example.invalid:1080",
|
||||
16_i64,
|
||||
true,
|
||||
AdminStoreErrorKind::StaleRevision,
|
||||
),
|
||||
(proxy_url, 17, true, AdminStoreErrorKind::StaleRevision),
|
||||
(proxy_url, 16, false, AdminStoreErrorKind::Conflict),
|
||||
] {
|
||||
sqlx::query(
|
||||
"update outbound_proxies set proxy_url=$2, max_websocket_connections=$3, enabled=$4 where id=$1",
|
||||
)
|
||||
.bind(proxy_id)
|
||||
.bind(current_url)
|
||||
.bind(limit)
|
||||
.bind(enabled)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut authorization, context) = command(credential("acct_proxy_test", "synthetic-user"));
|
||||
let pending = authorization
|
||||
.pending
|
||||
.with_outbound_proxy(Some(
|
||||
OutboundProxy::parse(proxy_url)
|
||||
.unwrap()
|
||||
.with_max_websocket_connections(16),
|
||||
))
|
||||
.with_outbound_proxy_id(Some(proxy_id.into()));
|
||||
authorization.pending = PendingAuthorizationMutation::from_storage_v1(
|
||||
serde_json::Value::Object(pending.to_storage_v1()),
|
||||
)
|
||||
.unwrap();
|
||||
let receipt_key = authorization.key.clone();
|
||||
let error = store
|
||||
.commit_authorization(authorization, &context)
|
||||
.await
|
||||
.expect_err("changed or disabled proxy must fail closed");
|
||||
assert_eq!(error.kind(), expected);
|
||||
assert!(
|
||||
store
|
||||
.authorization_receipt(&receipt_key)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let accounts: i64 = sqlx::query_scalar("select count(*) from provider_accounts")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(accounts, 0);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_authorization_retries_share_one_account_write_and_one_receipt() {
|
||||
let Some(database) = TestDatabase::create("authorization_receipt_race").await else {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 验证账号租约关闭与申请交错、异步释放完成和清理超时
|
||||
|
||||
#[path = "../../../src/redis/credential_leases/cleanup.rs"]
|
||||
mod implementation;
|
||||
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{DaemonTask, WorkerDaemonContext};
|
||||
use implementation::SchedulingLeaseCleanup;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_guard_keeps_its_lease_task_alive_after_the_port_is_dropped() {
|
||||
let cleanup = SchedulingLeaseCleanup::default();
|
||||
let (released, mut completed) = oneshot::channel();
|
||||
let guard = cleanup.track(CancellationToken::new(), async move {
|
||||
released.send(()).unwrap();
|
||||
Ok(true)
|
||||
});
|
||||
drop(cleanup);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(matches!(
|
||||
completed.try_recv(),
|
||||
Err(oneshot::error::TryRecvError::Empty)
|
||||
));
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_waits_for_pending_acquisitions_and_releases_without_waiting_for_guards() {
|
||||
let cleanup = SchedulingLeaseCleanup::default();
|
||||
let acquisition = cleanup.acquire().await.unwrap();
|
||||
let stopping = CancellationToken::new();
|
||||
let request = CancellationToken::new();
|
||||
let (released, completed) = oneshot::channel();
|
||||
let worker = {
|
||||
let cleanup = cleanup.clone();
|
||||
let stopping = stopping.clone();
|
||||
tokio::spawn(async move { cleanup.run(WorkerDaemonContext::new(stopping)).await })
|
||||
};
|
||||
stopping.cancel();
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!worker.is_finished());
|
||||
// Redis 申请在停机开始后才返回,登记仍必须被本次清理包含
|
||||
let guard = cleanup.track(request.clone(), async move {
|
||||
released.send(()).unwrap();
|
||||
Ok(true)
|
||||
});
|
||||
drop(acquisition);
|
||||
tokio::time::timeout(Duration::from_secs(1), worker)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
completed.await.unwrap();
|
||||
assert!(request.is_cancelled());
|
||||
assert!(cleanup.acquire().await.is_none());
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_joins_a_release_already_started_by_guard_drop() {
|
||||
let cleanup = SchedulingLeaseCleanup::default();
|
||||
let acquisition = cleanup.acquire().await.unwrap();
|
||||
let request = CancellationToken::new();
|
||||
let (started, entered) = oneshot::channel();
|
||||
let (finish, finished) = oneshot::channel();
|
||||
let guard = cleanup.track(request.clone(), async move {
|
||||
started.send(()).unwrap();
|
||||
finished.await.unwrap();
|
||||
Ok(true)
|
||||
});
|
||||
drop(acquisition);
|
||||
drop(guard);
|
||||
entered.await.unwrap();
|
||||
assert!(!request.is_cancelled());
|
||||
let stopping = CancellationToken::new();
|
||||
stopping.cancel();
|
||||
let worker = tokio::spawn(async move { cleanup.run(WorkerDaemonContext::new(stopping)).await });
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!worker.is_finished());
|
||||
finish.send(()).unwrap();
|
||||
worker.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn unavailable_release_does_not_hold_shutdown_past_its_budget() {
|
||||
let cleanup = SchedulingLeaseCleanup::default();
|
||||
let request = CancellationToken::new();
|
||||
let guard = cleanup.track(request.clone(), std::future::pending());
|
||||
let stopping = CancellationToken::new();
|
||||
stopping.cancel();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
cleanup.run(WorkerDaemonContext::new(stopping)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(request.is_cancelled());
|
||||
assert!(cleanup.acquire().await.is_none());
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn acquisition_returning_after_shutdown_timeout_is_still_released() {
|
||||
let cleanup = SchedulingLeaseCleanup::default();
|
||||
let acquisition = cleanup.acquire().await.unwrap();
|
||||
let stopping = CancellationToken::new();
|
||||
stopping.cancel();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
cleanup.run(WorkerDaemonContext::new(stopping)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let request = CancellationToken::new();
|
||||
let (released, completed) = oneshot::channel();
|
||||
let guard = cleanup.track(request.clone(), async move {
|
||||
released.send(()).unwrap();
|
||||
Ok(true)
|
||||
});
|
||||
drop(acquisition);
|
||||
tokio::time::timeout(Duration::from_secs(1), completed)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(request.is_cancelled());
|
||||
assert!(cleanup.acquire().await.is_none());
|
||||
drop(guard);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 验证账号调度租约的容量、续租、失效与释放
|
||||
|
||||
mod cleanup;
|
||||
mod details;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -480,12 +480,11 @@ impl CodexCredentialCatalogService {
|
||||
content: if model.document().protocol() == "codex" {
|
||||
ProviderModelContent::Native(model.document().clone())
|
||||
} else {
|
||||
let model_id = model.request_model().as_str();
|
||||
ProviderModelContent::Adapted(
|
||||
ModelPresentation::new(
|
||||
Some(model.request_model().as_str().to_owned()),
|
||||
None,
|
||||
)
|
||||
.with_agent_tools(true, false),
|
||||
ModelPresentation::new(Some(model_id.to_owned()), None)
|
||||
.with_agent_tools(true, false)
|
||||
.with_hidden(is_specialized_api_model(model_id)),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -1052,6 +1051,54 @@ impl CodexCredentialCatalogService {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_specialized_api_model(model: &str) -> bool {
|
||||
// 普通目录不提供 Codex 可见性;仅按已知的专用模型 ID 隐藏,保留未知 ID 和直接调用
|
||||
// 依据 https://developers.openai.com/api/docs/models 与 Codex 的语音通话模型
|
||||
matches!(
|
||||
model,
|
||||
"dall-e-2"
|
||||
| "dall-e-3"
|
||||
| "chatgpt-image-latest"
|
||||
| "gpt-image-1"
|
||||
| "gpt-image-1-mini"
|
||||
| "gpt-image-1.5"
|
||||
| "gpt-image-1.5-2025-12-16"
|
||||
| "gpt-image-2"
|
||||
| "gpt-image-2-2026-04-21"
|
||||
| "gpt-image-2.5-flare"
|
||||
| "gpt-image-2.5-flare-2026-09-08"
|
||||
| "gpt-image-2.5-sunburst"
|
||||
| "gpt-image-2.5-sunburst-2026-09-08"
|
||||
| "gpt-image-2.6-flare"
|
||||
| "gpt-image-2.6-sunburst"
|
||||
| "gpt-live-1"
|
||||
| "gpt-live-1-codex"
|
||||
| "gpt-live-transcribe"
|
||||
| "gpt-realtime"
|
||||
| "gpt-realtime-2025-08-28"
|
||||
| "gpt-realtime-1.5"
|
||||
| "gpt-realtime-2"
|
||||
| "gpt-realtime-2.1"
|
||||
| "gpt-realtime-2.1-mini"
|
||||
| "gpt-realtime-mini"
|
||||
| "gpt-realtime-mini-2025-10-06"
|
||||
| "gpt-realtime-mini-2025-12-15"
|
||||
| "gpt-realtime-translate"
|
||||
| "gpt-realtime-whisper"
|
||||
| "gpt-4o-realtime-preview"
|
||||
| "gpt-4o-realtime-preview-2024-10-01"
|
||||
| "gpt-4o-realtime-preview-2024-12-17"
|
||||
| "gpt-4o-realtime-preview-2025-06-03"
|
||||
| "gpt-4o-mini-realtime-preview"
|
||||
| "gpt-4o-mini-realtime-preview-2024-12-17"
|
||||
| "tts-1"
|
||||
| "tts-1-hd"
|
||||
| "gpt-4o-mini-tts"
|
||||
| "gpt-4o-mini-tts-2025-03-20"
|
||||
| "gpt-4o-mini-tts-2025-12-15"
|
||||
)
|
||||
}
|
||||
|
||||
fn model_ids(models: &[CodexCatalogModel]) -> Vec<String> {
|
||||
models
|
||||
.iter()
|
||||
|
||||
@@ -480,17 +480,18 @@ impl CodexCredentialSelector {
|
||||
&& request
|
||||
.session_affinity_observation
|
||||
.is_some_and(CodexSessionAffinity::follow_only);
|
||||
let select = |queue_policy| {
|
||||
let select = |queue_policy, queue_rejection| {
|
||||
self.select_inner_loop(
|
||||
request,
|
||||
cyber_policy_session_key,
|
||||
model,
|
||||
follow_only,
|
||||
queue_policy,
|
||||
queue_rejection,
|
||||
)
|
||||
};
|
||||
let selection = async {
|
||||
let rejection = match select(policy.queue_policy()).await {
|
||||
let rejection = match select(policy.queue_policy(), None).await {
|
||||
Err(CredentialSelectionError::QueueRejected(reason))
|
||||
if !follow_only
|
||||
&& request.attempt.required_account().is_none()
|
||||
@@ -512,7 +513,7 @@ impl CodexCredentialSelector {
|
||||
"account.queue.reselect",
|
||||
serde_json::json!({"reason": rejection.to_string()}),
|
||||
);
|
||||
select(queue)
|
||||
select(queue, Some(rejection))
|
||||
},
|
||||
|err| {
|
||||
matches!(
|
||||
@@ -555,6 +556,7 @@ impl CodexCredentialSelector {
|
||||
model: CodexSelectionModel<'_>,
|
||||
follow_only: bool,
|
||||
mut queue_policy: ConcurrencyQueuePolicy,
|
||||
queue_rejection: Option<QueueRejection>,
|
||||
) -> Result<CodexCredentialLease, CredentialSelectionError> {
|
||||
let adapter_model = match model {
|
||||
CodexSelectionModel::Requested(model) if request.managed_upstream => Some(
|
||||
@@ -1351,7 +1353,30 @@ impl CodexCredentialSelector {
|
||||
tracing::warn!(%error, "OpenAI cache preference update unavailable");
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
Ok(true) => {}
|
||||
Ok(true) => {
|
||||
if let Some(reason) = queue_rejection
|
||||
&& let Some(previous) = binding.as_ref().filter(|binding| {
|
||||
binding.account_id() != binding_account
|
||||
})
|
||||
{
|
||||
tracing::info!(
|
||||
request_id = %request.attempt.request_id(),
|
||||
attempt_index = request.attempt.attempt_index().get(),
|
||||
reason = %reason,
|
||||
from_account_id = %previous.account_id(),
|
||||
to_account_id = %binding_account,
|
||||
"OpenAI 账号排队失败后已迁移绑定"
|
||||
);
|
||||
request.attempt.trace().record_evidence(
|
||||
"account.binding.migrated",
|
||||
serde_json::json!({
|
||||
"reason": reason.to_string(),
|
||||
"fromAccountId": previous.account_id().as_str(),
|
||||
"toAccountId": binding_account.as_str(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let affinity_telemetry = affinity.telemetry(account.id(), cache_preference);
|
||||
|
||||
@@ -1197,7 +1197,7 @@ async fn api_key_client_catalog_cache_is_invalidated_by_credential_revision() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_catalog_accepts_namespaced_model_ids_without_inventing_capabilities() {
|
||||
async fn api_key_catalog_hides_only_known_specialized_models_in_codex_picker() {
|
||||
use provider_openai::credential::ResponsesTransport;
|
||||
use provider_openai::transport::CodexCatalogCapabilityEvidence;
|
||||
|
||||
@@ -1206,11 +1206,18 @@ async fn api_key_catalog_accepts_namespaced_model_ids_without_inventing_capabili
|
||||
store
|
||||
.seed_api_key("acct_api", upstream.uri(), ResponsesTransport::Http)
|
||||
.await;
|
||||
let ids = [
|
||||
"gpt-5.4",
|
||||
"ft:gpt-4.1-mini:example:custom:abc",
|
||||
"vendor/model",
|
||||
let picker_visibility = [
|
||||
("gpt-5.4", true),
|
||||
("ft:gpt-4.1-mini:example:custom:abc", true),
|
||||
("vendor/model", true),
|
||||
("gpt-image-2-custom", true),
|
||||
("gpt-image-2", false),
|
||||
("gpt-image-2-2026-04-21", false),
|
||||
("gpt-live-1", false),
|
||||
("gpt-realtime-2", false),
|
||||
("tts-1", false),
|
||||
];
|
||||
let ids = picker_visibility.map(|(id, _)| id);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
@@ -1240,21 +1247,19 @@ async fn api_key_catalog_accepts_namespaced_model_ids_without_inventing_capabili
|
||||
.client_model_catalog(&CLIENT_KEY, &client_scope(&[account]), "1.0.0")
|
||||
.await
|
||||
.expect("client catalog");
|
||||
assert_eq!(
|
||||
models
|
||||
.iter()
|
||||
.map(|model| model.model.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
ids.into_iter().collect()
|
||||
);
|
||||
for model in &models {
|
||||
let actual_visibility = models.iter().map(|model| {
|
||||
let gateway_core::routing::ProviderModelContent::Adapted(presentation) = &model.content
|
||||
else {
|
||||
panic!("plain API IDs must remain adapted")
|
||||
};
|
||||
assert!(presentation.supported_reasoning_efforts().is_empty());
|
||||
assert_eq!(presentation.default_reasoning_effort(), None);
|
||||
}
|
||||
(model.model.as_str(), !presentation.hidden())
|
||||
});
|
||||
assert_eq!(
|
||||
actual_visibility.collect::<std::collections::BTreeMap<_, _>>(),
|
||||
picker_visibility.into_iter().collect()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -18,7 +18,10 @@ fn projection_attempt(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new("req_projection").unwrap(),
|
||||
ClientApiKeyId::new(client_key).unwrap(),
|
||||
),
|
||||
)
|
||||
.with_trace(gateway_core::diagnostics::TraceContext::new(
|
||||
"req_projection",
|
||||
)),
|
||||
NonZeroU32::new(1).unwrap(),
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
policy,
|
||||
@@ -420,15 +423,35 @@ async fn strict_queue_failure_reselects_once_and_only_rebinds_after_admission()
|
||||
assert!(pending.as_mut().now_or_never().is_none());
|
||||
}
|
||||
let result = pending.await;
|
||||
let reason = if full {
|
||||
QueueRejection::Full
|
||||
} else {
|
||||
QueueRejection::Timeout
|
||||
};
|
||||
let snapshot = ctx.trace().snapshot().unwrap();
|
||||
let migrations = snapshot["events"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|event| event["stage"] == "account.binding.migrated")
|
||||
.map(|event| &event["data"])
|
||||
.collect::<Vec<_>>();
|
||||
if spare && enabled {
|
||||
assert_eq!(
|
||||
migrations,
|
||||
vec![&json!({
|
||||
"reason": reason.to_string(),
|
||||
"fromAccountId": first.as_str(),
|
||||
"toAccountId": second.as_str(),
|
||||
})]
|
||||
);
|
||||
} else {
|
||||
assert!(migrations.is_empty());
|
||||
}
|
||||
let expected = if spare && enabled {
|
||||
assert_eq!(result.unwrap().account_id(), &second);
|
||||
&second
|
||||
} else {
|
||||
let reason = if full {
|
||||
QueueRejection::Full
|
||||
} else {
|
||||
QueueRejection::Timeout
|
||||
};
|
||||
assert!(
|
||||
matches!(result, Err(CredentialSelectionError::QueueRejected(actual)) if actual == reason)
|
||||
);
|
||||
|
||||
+7
-3
@@ -669,6 +669,9 @@ metrics_exporter = "none"
|
||||
WebSocket 会话完成,进程退出时强制断开剩余连接。已入队的观测写入与后台清理保留
|
||||
`host.worker_shutdown_timeout_seconds`(默认 30 秒)的收尾预算;客户端在新进程就绪后重新连接
|
||||
|
||||
正常关闭会在后台收尾预算内释放本实例的账号并发占位,租约清理最多等待 2 秒。
|
||||
强制终止、清理超时或 Redis 清理失败时,占位由最后一次续期后的 10 分钟 TTL 回收,不影响其他实例的租约
|
||||
|
||||
管理端重启受理后,到进程退出期间,更新、回滚和再次重启均被拒绝;可继续查询更新状态
|
||||
|
||||
Compose 的 `stop_grace_period` 为 75 秒,覆盖默认 30 秒后台收尾及进程清理余量。
|
||||
@@ -843,6 +846,7 @@ docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
|
||||
### 升级前检查
|
||||
|
||||
- OpenAI 或 xAI 授权使用已保存代理,且代理的 WebSocket 连接上限非零:升级或回退后,重新发起尚未完成的授权,不复用旧回调或授权码。上限为 `0` 的在途授权和已保存的账号、凭据不受影响
|
||||
- Key 配置含 `installationId`:数据库自动转换为 `installationSeed`,API 调用方须使用[当前字段](../docs/api.md#key-客户端身份)。出站安装身份及关联的会话、缓存标识会变化,已有 WebSocket 精确续写需重发完整上下文
|
||||
- 数据库或备份含 `accountAutoFreeze*` 设置:升级后错误保护关闭、规则为空,需在管理端重新配置并启用;API 调用方使用 [`accountErrorProtection`](../docs/api.md#账号错误保护)。已有冻结到当前冷却截止后解除,账号并发上限保留,需调整时手动设置
|
||||
|
||||
@@ -864,9 +868,9 @@ docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
切换通道不会降级,Stable 候选也必须高于当前运行版本。
|
||||
下载、安装或待重启期间不能切换通道
|
||||
|
||||
更新候选来自公开发行附件。更新器按固定 tag 查询预发行页 `update-index`,从正文 JSON 的 `current` 读取快照版本,再读取该版本 Release 的 `version.yaml` 附件。附件的 `version` 标识快照,`published` 按大版本和通道列出可安装版本;修改源码或仅打 tag 不会触发提示
|
||||
更新器读取更新仓库 `main` 分支的 `release/version.yaml`。`version` 是源码构建版本,只有 `published` 中当前大版本和通道对应的版本才参与更新检查
|
||||
|
||||
自定义更新仓库须提供相同入口、快照及各候选的公开 Release。使用本仓库发布工作流时自动维护索引;`update-index` 不设为 Latest,不需要额外分支
|
||||
自定义更新仓库须提供这份清单和候选版本的公开 Release。发布工作流在安装包公开并通过校验后更新 `published`,需要具有写入 `main` 的权限
|
||||
|
||||
提示有更新前会按精确 tag 核对候选 Release、当前平台包和校验文件,无需配置 GitHub 令牌。GitHub 限流时按提示稍后重试;
|
||||
也可从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载平台归档,按[手动安装](#手动安装)升级
|
||||
@@ -943,7 +947,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
|
||||
|
||||
| 文件或目录 | 用途 |
|
||||
| --- | --- |
|
||||
| `plugin-release-manifest.json` | 固定宿主版本、提交、平台与支持的插件合同;插件列表当前为空 |
|
||||
| `plugins/official/plugin-release-manifest.json` | 随平台安装包和镜像分发,记录宿主版本、提交与支持的插件合同 |
|
||||
| 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 |
|
||||
|
||||
- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后重启,不兼容实例保持未启动,启用配置、绑定、密钥和私有数据保留
|
||||
|
||||
+7
-1
@@ -580,7 +580,8 @@ OpenAI Provider 按客户端传入的 `client_version` 请求上游目录,完
|
||||
`base_instructions`、`model_messages`、`service_tiers`、工具与能力字段,以及未知嵌套字段、显式 `null`
|
||||
和字段缺失的区别。
|
||||
API Key 上游返回完整 Codex `models` 目录时沿用该合同;仅返回普通 `data` 模型列表时使用通用画像,
|
||||
未提供的推理能力保持未知,不补充推理档位。
|
||||
未提供的推理能力保持未知,不补充推理档位。已知的专用生图、Live/Realtime 和语音合成模型以 `visibility: "hide"`
|
||||
保留在 Codex 目录中,且不出现在默认选模列表。其他 ID 保持可见;此规则不影响普通模型列表或直接调用。
|
||||
模型别名仅替换 `slug`,不替换上游展示名、提示词、能力或 `priority`;保持原生模型顺序,新增别名附在后面。
|
||||
目录按当前路由快照的模型存在性及账号模型政策过滤,避免公布已知无法路由的模型;新模型需待后台目录对账后进入列表。
|
||||
xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用画像。推理菜单读取
|
||||
@@ -2762,6 +2763,10 @@ WebSocket 握手不主动添加专项计时请求头,客户端显式提供的
|
||||
`environment.scope = export_time` 明确版本、构建与部署信息是导出时状态,不能代替事发环境
|
||||
|
||||
`request.routed.data.accountPolicy` 保存执行时冻结的账号策略与预算。
|
||||
OpenAI 账号队满或超时后的重选记录为 `account.queue.reselect`;绑定迁移成功后,
|
||||
`account.binding.migrated` 保存拒绝原因 `reason`、原账号 `fromAccountId` 和新账号 `toAccountId`。
|
||||
首次认领、临时分流和失败重选不记录为绑定迁移
|
||||
|
||||
`attempt.failed.data.recovery` 保存 Provider 的重放安全性和恢复意图;`retry.decided` 保存当时
|
||||
实际参与决策的门槛、剩余预算和发送状态,`retry.policy` 保存宿主及插件策略的停止事实。
|
||||
已有的 `provider.precommit.released` 数据保留释放原因、预取字节数与等待时间,不等同于下游已提交
|
||||
@@ -3227,6 +3232,7 @@ GET/HEAD 不接受正文。响应状态码为 200–599,内容类型元数据
|
||||
`If-None-Match` 匹配时返回无正文的 `304`。停用、版本或授权变更后,旧目标仍被拒绝,不返回缓存命中。
|
||||
两类响应默认设置 nosniff、no-referrer 与限制性 CSP,动态处理器可显式覆盖默认响应头。管理端页面使用隔离 iframe,
|
||||
仅允许脚本,不具有父页面同源权限;联网、弹窗、表单、嵌套页面和顶层导航均不开放。
|
||||
插件页面可通过宿主桥请求打开 HTTPS 外链,管理员在宿主确认后才打开新标签页。
|
||||
页面访问已声明管理路由须经过固定目标的宿主桥,不能指定其他实例或版本。插件页面作者合同见
|
||||
[SDK 管理 API 与页面](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli)
|
||||
|
||||
|
||||
@@ -839,7 +839,9 @@ Core 的 `concurrency` 拥有中立的有界等待位置、优先级与同级 FI
|
||||
运行并发仍由 Redis 原子准入与账号租约裁决,等待队列只保存当前进程中的等待位置,不复制运行计数,
|
||||
也不持久化正文或在重启后重放请求。每个等待 owner 最多容纳 1,024 个等待者作为资源兜底
|
||||
|
||||
账号并发明细由 Redis 租约关联的最小入口事实投影,不读取请求历史。事实与租约原子创建,按 fencing 校验续期和释放,异常退出后随 TTL 回收;管理端只对普通池做有界分页读取
|
||||
账号并发明细由 Redis 租约关联的最小入口事实投影,不读取请求历史。事实与租约原子创建,按 fencing 校验续期和释放;管理端只对普通池做有界分页读取
|
||||
|
||||
Store 监督本进程普通池与审批池的账号租约释放任务,停机时拒绝新申请、取消持有请求并停止续期,在 2 秒预算内完成已发出申请的登记及租约释放,不等待模型生成结束,也不清理其他实例的租约。异常退出或清理失败时仍由 TTL 回收
|
||||
|
||||
新请求不能越过同级或更高优先级的等待者,取消或完成等待通过 Drop 回收位置并立即唤醒后继
|
||||
队首每 100ms 重查容量,需要复核会话绑定或跨账号容量的非队首每 500ms 重查,其余非队首等待晋升通知
|
||||
@@ -1294,9 +1296,9 @@ Host 在受理时持久化任务并转交后台执行,任务持有操作锁与
|
||||
二进制、Web 资源、官方插件目录及回滚备份须整体匹配,无法核实的备份不参与回滚
|
||||
|
||||
通道是单次查询或安装的参数,不持久化偏好,默认按运行版本推导。
|
||||
Host 按固定 tag `update-index` 读取公开快照版本,只从该版本的 `version.yaml` 附件选择候选。发布工作流在仓库级发布锁内合并通道,校验公开 Release 及附件后才推进指针;待发布记录用于中断恢复,不参与客户端选版。
|
||||
实例复用同版本快照,按精确 tag 缓存至多五份 Release 详情。版本接口只读本地状态,摘要与详情都须核对更高候选的发行身份、平台包和校验文件。
|
||||
查询合并并发读取,附件和 API 各自限流;单轮元数据请求含排队最多 20 秒,索引附件限 128 KiB,Release 限 4 MiB。
|
||||
Host 从更新仓库 `main:release/version.yaml` 的 `published` 选择候选。发布工作流持有仓库级发布锁,校验公开 Release 和平台附件后,按当前文件 SHA 合并本次版本对应的通道,不覆盖构建版本或回退已发布候选
|
||||
实例共享通道表,按精确 tag 缓存至多五份 Release 详情。版本接口只读本地状态,摘要与详情都须核对更高候选的发行身份、平台包和校验文件。
|
||||
查询合并并发读取,原始文件和 API 各自限流;单轮元数据请求含排队最多 20 秒,通道表限 128 KiB,Release 限 4 MiB。
|
||||
安装在下载前固定通道、目标和校验值,安装包是最后一项远端资源;校验、替换和重启只依赖本地发行文件。
|
||||
Admin/API 只转发策略事实,前端分别展示运行版本、通道候选与待生效版本
|
||||
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ flowchart LR
|
||||
|
||||
| 功能 | 使用方式 |
|
||||
| --- | --- |
|
||||
| 管理页面 | 从插件详情或左侧插件导航打开;页面在隔离 iframe 中运行 |
|
||||
| 管理页面 | 从插件详情或左侧插件导航打开;插件请求打开外链时,核对目标域名后可打开、复制或取消 |
|
||||
| 自动维护 | 启用、重启及账号等配置变化后自动对账;失败重试,周期补漏 |
|
||||
| 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE |
|
||||
| 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 |
|
||||
|
||||
+36
-15
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
|
||||
在管理端「主题」中调整颜色、字号、圆角和组件样式,保存后应用到当前浏览器。
|
||||
设置保存在浏览器本地,不会同步到服务器或其他设备。只想换配色时,选择预置主题即可
|
||||
设置保存在浏览器本地,不会同步到服务器或其他设备。只想换配色时,选择品牌色预置即可
|
||||
|
||||
下文说明实现和扩展方式。颜色使用 Ant Design 十阶色板与项目的明暗角色规则,
|
||||
中性表面从背景和文字 Seed 派生;Vue 组件通过 CSS Variables 与 Tailwind CSS 4 使用这些值。
|
||||
@@ -138,6 +138,7 @@ Seed 是用户配置与持久化的最小事实:
|
||||
|
||||
```ts
|
||||
type ThemeMode = 'system' | 'light' | 'dark'
|
||||
type ThemePresetId = 'default' | 'soft'
|
||||
type ThemeColorId = 'relay-blue' | 'deep-teal' | 'signal-violet' | 'graphite' | 'custom'
|
||||
|
||||
interface ThemeCustomization {
|
||||
@@ -157,7 +158,11 @@ interface ThemeCustomization {
|
||||
|
||||
表格行高与卡片圆角通过 `customization.component` 的 `tableRowHeight`、`cardBorderRadius` 配置,属于组件尺寸覆盖
|
||||
|
||||
持久化键为 `codex-proxy-rs-theme`。Store 不保存完整色板或 CSS Variables,损坏的模式、颜色与自定义值会在
|
||||
完整主题预设组合品牌配色、推荐材质、全局 Seed、组件尺寸和可编辑 Token。选择时替换草稿配置,保留当前明暗模式;保存后应用到管理端
|
||||
|
||||
表面材质可单独选择:`default` 使用简洁表面,`soft` 使用柔和光影。切换材质保留草稿的配色和组件配置,显式尺寸优先于材质默认值
|
||||
|
||||
持久化键为 `codex-proxy-rs-theme`,`themePreset` 缺省为 `default`。Store 不保存完整色板或 CSS Variables,损坏的模式、预设、颜色与自定义值会在
|
||||
初始化时规范化并回退到默认配置
|
||||
|
||||
### Map Token
|
||||
@@ -201,7 +206,7 @@ Preset 的实心色使用 P6,普通 Container、Strong Container 与边界按
|
||||
- `colorText / Heading / Secondary / Tertiary / Quaternary / Disabled`
|
||||
- `colorBorder / BorderSecondary / Split / Shadow`
|
||||
|
||||
中继蓝使用稳定的浅色 / 深色中性基线;深海青、古风色与石墨预置额外提供各自的 `colorBgBase`、`colorTextBase`
|
||||
星津夜航使用稳定的浅色 / 深色中性基线;水墨江汀、粉墙回廊与朱阙金瓦预置额外提供各自的 `colorBgBase`、`colorTextBase`
|
||||
画像,自定义主色则只向通用中性基线注入少量色温。默认浅色采用中性锚点,默认深色使用 HSL 色调派生;带色温
|
||||
主题按外观距离平滑过渡到背景和文字 Seed 的 RGB 混色结果。稳定锚点用于 Surface 与 Shadow,Component 从角色派生,
|
||||
所有预置、自定义 Seed 和用户覆盖仍进入同一条算法,不在页面或组件中追加 HEX 特判。
|
||||
@@ -242,14 +247,16 @@ Token 直接覆盖时不会自动重算同组件的其他状态;需要保持
|
||||
| Button | `--z-button-primary-color / bg / hover-bg / active-bg`、`--z-button-secondary-bg / hover-bg / active-bg` |
|
||||
| IconButton | `--z-icon-button-secondary-bg / hover-bg / active-bg` |
|
||||
| Input | `--z-input-bg / hover-bg / active-bg / error-active-bg` 与对应 Shadow |
|
||||
| Menu | `--z-menu-item-selected-bg` |
|
||||
| Popover | `--z-popover-header-bg` |
|
||||
| Menu | `--z-menu-item-selected-bg / bg-image / shadow` |
|
||||
| Popover | `--z-popover-header-bg`、`--z-popover-shadow` |
|
||||
| Table | `--z-table-row-bg / stripe-bg / hover-bg / selected-bg / selected-hover-bg / height` |
|
||||
| Card | `--z-card-bg / border-radius / shadow` |
|
||||
| Layout | `--z-layout-sider-bg / shadow` |
|
||||
| Card | `--z-card-bg / bg-image / border-radius / shadow` |
|
||||
| Layout | `--z-layout-sider-bg / bg-image / border-radius / shadow` |
|
||||
| Scrollbar | `--z-scrollbar-thumb-bg / hover-bg` |
|
||||
| BrandMark | `--z-brand-mark-bg` |
|
||||
|
||||
管理端侧栏由 UI 库的 `ZSidebar` 提供容器、折叠展示、两级导航与选中状态。应用提供菜单数据、品牌和底部操作插槽,在 `select` 中执行路由或插件导航;移动端遮罩与模态焦点由宿主布局持有
|
||||
|
||||
组件状态由 UI 库的 Component 派生层统一生成,页面不维护混色系数:
|
||||
|
||||
| 角色 | 状态与可读性约束 |
|
||||
@@ -279,14 +286,23 @@ Purple Strong Container 或 Purple Solid;同一组颜色仍由运行时色板
|
||||
|
||||
## 预置主题
|
||||
|
||||
| ID | 名称 | 主色 | 交互方向 |
|
||||
| --- | --- | --- | --- |
|
||||
| `relay-blue` | 中继蓝 | `#5983F4` | 清晰可信的冷蓝强调 |
|
||||
| `deep-teal` | 深海青 | `#0E7C72` | 冷静低饱和的青色强调 |
|
||||
| `signal-violet` | 古风色 | `#A0583D` | 温厚克制的赭色强调 |
|
||||
| `graphite` | 石墨 | `#525B66` | 近单色的灰色强调 |
|
||||
### 表面材质
|
||||
|
||||
预置主题改变品牌主色,并可提供与该品牌匹配的浅色 / 深色背景和文字 Seed;不改变字号、密度、圆角或布局。
|
||||
| ID | 名称 | 推荐品牌色 | 表面 |
|
||||
| --- | --- | --- | --- |
|
||||
| `default` | 默认 | 星津夜航 | 简洁表面与轻量阴影 |
|
||||
| `soft` | 柔光 | 星津夜航 | 圆润边缘、表面高光与分层阴影,配色跟随品牌 |
|
||||
|
||||
### 主题预设
|
||||
|
||||
| 配色 ID | 名称 | 主色 | 推荐材质与组件 |
|
||||
| --- | --- | --- | --- |
|
||||
| `relay-blue` | 星津夜航 | `#5983F4` | 默认材质,标准控件与表格 |
|
||||
| `deep-teal` | 水墨江汀 | `#50605E` | 默认材质,6px 卡片圆角与 60px 表格行高 |
|
||||
| `signal-violet` | 粉墙回廊 | `#D395A8` | 默认材质,14px 正文与 68px 表格行高 |
|
||||
| `graphite` | 朱阙金瓦 | `#A34537` | 柔光,12px 卡片圆角与 54px 表格行高 |
|
||||
|
||||
品牌配色改变品牌主色,并可提供与该品牌匹配的浅色 / 深色背景和文字 Seed;不改变字号、密度、圆角或布局。完整预设提供的 Seed 优先于品牌基线,用户覆盖优先于预设
|
||||
所有预置与自定义主题走同一条 Surface、Map、Alias 与 Component 派生链路,不存在页面级特判
|
||||
|
||||
## 运行时架构
|
||||
@@ -349,7 +365,8 @@ UI 构建插件 `CodexProxyUI({ themePrefix: 'z' })` 同时配置变量生成与
|
||||
|
||||
| 区域 | 能力 |
|
||||
| --- | --- |
|
||||
| 全局 / 颜色 | 模式、预置、自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
|
||||
| 全局 / 预设 | 模式、完整主题预设、表面材质 |
|
||||
| 全局 / 颜色 | 自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
|
||||
| 全局 / 尺寸 | 字号、基础间距、尺寸步长、控件高度 |
|
||||
| 全局 / 风格 | 通用圆角、阴影强度 |
|
||||
| 组件 | Action、Form、Surface、Data Display、Navigation、Layout |
|
||||
@@ -431,6 +448,10 @@ utility / arbitrary variant;Vue Transition、跨浏览器 Range、动态富文
|
||||
|
||||
### 增加预置主题
|
||||
|
||||
在 UI 库的 `themeProfiles` 中组合配色 ID 对应的材质与 `ThemeCustomization`。设置页将其复制为可编辑草稿,保存实际配置,无需持久化额外的选中标记。材质仍由组件 Token 派生层实现
|
||||
|
||||
单独增加品牌配色:
|
||||
|
||||
1. 在 `ThemeColorPresetId` 增加稳定 ID
|
||||
2. 在 `themeColorPresets` 声明名称、描述与主色 Seed
|
||||
3. 不新增页面特判,确认预置可通过同一 `createTheme()` 派生
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.8.0",
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.9.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.67.0",
|
||||
|
||||
Generated
+6
-6
@@ -195,8 +195,8 @@ importers:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1(vue@3.5.44(typescript@6.0.3))
|
||||
'@codex-proxy/ui':
|
||||
specifier: github:zyycn/codex-proxy-ui#v0.8.0
|
||||
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/d6100447010715c1203e67a4195fc6d875ff6249(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)
|
||||
specifier: github:zyycn/codex-proxy-ui#v0.9.0
|
||||
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)
|
||||
'@fontsource-variable/inter':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -443,9 +443,9 @@ packages:
|
||||
resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==}
|
||||
engines: {node: '>= 20.12.0'}
|
||||
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/d6100447010715c1203e67a4195fc6d875ff6249':
|
||||
resolution: {gitHosted: true, integrity: sha512-XNPErDXqq9OxvgLgSb0gVRGmI9Yf+Vx0dTjoZKzaAaCOX9exSirU5kEr9mJBeuo9E1DDw8817xV0B5Od9zH+5A==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/d6100447010715c1203e67a4195fc6d875ff6249}
|
||||
version: 0.8.0
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86':
|
||||
resolution: {gitHosted: true, integrity: sha512-RfPWnA41PRi4Q9gBPXK4jyik+9XZDLKaRMF12/Y4au6wQd5+7C+fIBVc6iROQFSxTW43EmEl8Gsx3tbNXE0w/Q==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86}
|
||||
version: 0.9.0
|
||||
engines: {node: '>=24'}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -3487,7 +3487,7 @@ snapshots:
|
||||
fast-wrap-ansi: 0.2.2
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/d6100447010715c1203e67a4195fc6d875ff6249(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)':
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)':
|
||||
dependencies:
|
||||
'@ant-design/colors': 8.0.1
|
||||
'@ant-design/fast-color': 3.0.1
|
||||
|
||||
@@ -43,7 +43,6 @@ const groupMarks = computed(() =>
|
||||
:aria-label="group.label"
|
||||
:aria-expanded="open"
|
||||
:aria-describedby="open ? id : undefined"
|
||||
:title="undefined"
|
||||
>
|
||||
<span class="size-4 rounded-sm" :style="{ backgroundColor: group.color }" aria-hidden="true" />
|
||||
</ZIconButton>
|
||||
|
||||
@@ -45,7 +45,6 @@ function select() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="t('keys.configuration.viewOnGitHub', { name: 'CC Switch' })"
|
||||
:title="t('keys.configuration.viewOnGitHub', { name: 'CC Switch' })"
|
||||
class="inline-grid size-9 shrink-0 place-items-center rounded-z-sm text-z-text-tertiary outline-none transition-colors hover:text-z-text focus-visible:ring-2 focus-visible:ring-z-control-outline focus-visible:ring-inset motion-reduce:transition-none"
|
||||
>
|
||||
<Github class="size-6" aria-hidden="true" />
|
||||
|
||||
@@ -15,7 +15,6 @@ const { t } = useI18n({ useScope: 'global' })
|
||||
<ZIconButton
|
||||
size="default"
|
||||
:aria-label="loading ? t('common.location.resolving') : t('common.location.detect')"
|
||||
:title="loading ? t('common.location.resolving') : t('common.location.detect')"
|
||||
:aria-busy="loading"
|
||||
:disabled="disabled || loading"
|
||||
@click="emit('detect')"
|
||||
|
||||
@@ -146,7 +146,7 @@ defineExpose({ focusField })
|
||||
<template v-if="field.description" #label-extra>
|
||||
<ZPopover trigger="hover-click" placement="top-start">
|
||||
<template #reference>
|
||||
<ZIconButton variant="link" :aria-label="t('common.form.fieldHelp', { label: field.label })" class="size-3.5!" :title="undefined">
|
||||
<ZIconButton variant="link" :aria-label="t('common.form.fieldHelp', { label: field.label })" class="size-3.5!">
|
||||
<Info class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -145,8 +145,7 @@
|
||||
},
|
||||
"capacity": {
|
||||
"concurrentRequestsLimit": "Concurrent requests: {value}, limit: {value2}",
|
||||
"viewConcurrencyDetails": "{label}, view concurrency details",
|
||||
"viewDetails": "{label}, view details"
|
||||
"viewConcurrencyDetails": "{label}, view concurrency details"
|
||||
},
|
||||
"concurrency": {
|
||||
"title": "Concurrency details",
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
"appearance": {
|
||||
"switchToLight": "Switch to light mode",
|
||||
"switchToDark": "Switch to dark mode",
|
||||
"themePresets": {
|
||||
"material": "Surface material",
|
||||
"title": "Theme preset",
|
||||
"default": {
|
||||
"label": "Default"
|
||||
},
|
||||
"soft": {
|
||||
"label": "Soft glow"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"global": "Global",
|
||||
"components": "Components",
|
||||
@@ -177,7 +187,6 @@
|
||||
"scrollBarSlider": "Scroll bar slider",
|
||||
"scrollBarHover": "Scroll bar Hover",
|
||||
"componentTokenComponentFamily": "Component token family",
|
||||
"description": "Adjust component tokens for {label}, unchanged values continue to use the global seed and alias algorithms",
|
||||
"noMatchingComponentToken": "No matching component tokens",
|
||||
"kindLabel": "Component tokens"
|
||||
},
|
||||
@@ -204,8 +213,6 @@
|
||||
"themeMode": "Theme mode",
|
||||
"globalTokenClassification": "Global token categories",
|
||||
"brandColorLabel": "Preset theme · Brand color · colorPrimary",
|
||||
"brandColor": "Brand color",
|
||||
"presetThemes": "Preset themes",
|
||||
"brandMainColor": "Brand main color",
|
||||
"paletteDescription": "Color scales and overall appearance",
|
||||
"semanticColorsTitle": "Semantic and neutral colors",
|
||||
@@ -215,7 +222,6 @@
|
||||
"basicSpacing": "Basic spacing",
|
||||
"sizeStep": "Size step",
|
||||
"styleToken": "Style tokens",
|
||||
"surfaceDescription": "Borderless surfaces, rounded corners, and layered shadows",
|
||||
"universalFillet": "Universal fillet",
|
||||
"shadowIntensity": "Shadow intensity"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"switchLanguage": "Switch language ({value})",
|
||||
"languageLoadFailed": "Unable to load language, reload to try again",
|
||||
"mainLabel": "Main navigation",
|
||||
"pluginsLabel": "Plugin navigation",
|
||||
"pluginManagement": "Manage",
|
||||
"online": "Online",
|
||||
"sidebar": {
|
||||
|
||||
@@ -330,6 +330,12 @@
|
||||
"modelResponseReadFailed": "Model response read failed",
|
||||
"duplicateManagementRequest": "Duplicate plugin management request ID",
|
||||
"managementConcurrencyLimit": "Plugin management concurrency limit reached",
|
||||
"openExternalLink": "Open external link",
|
||||
"openLink": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"externalLinkPending": "An external link is already awaiting confirmation",
|
||||
"externalLinkRequiresClick": "Click a link or button on the plugin page",
|
||||
"invalidExternalLink": "Use an absolute HTTPS URL without a username or password",
|
||||
"invalidEntryPoint": "Plugin page entry point is not a registered HTML resource",
|
||||
"unregisteredAsset": "Plugin page references an unregistered resource: {path}",
|
||||
"htmlSubresource": "Plugin HTML resources cannot be loaded as subresources",
|
||||
|
||||
@@ -629,7 +629,7 @@
|
||||
"access": "Security",
|
||||
"backups": "Backups"
|
||||
},
|
||||
"description": "Manage scheduling, upstreams, pricing, security, history retention, and backups",
|
||||
"description": "Manage scheduling, upstreams, pricing, security, retention and backups",
|
||||
"sectionsLabel": "Settings sections",
|
||||
"undoAllBasicSettingsChanges": "Undo all basic settings changes",
|
||||
"unsavedChanges": {
|
||||
|
||||
@@ -145,8 +145,7 @@
|
||||
},
|
||||
"capacity": {
|
||||
"concurrentRequestsLimit": "账号并发占用 {value},上限 {value2}",
|
||||
"viewConcurrencyDetails": "{label},查看并发明细",
|
||||
"viewDetails": "{label},查看明细"
|
||||
"viewConcurrencyDetails": "{label},查看并发明细"
|
||||
},
|
||||
"concurrency": {
|
||||
"title": "并发明细",
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
"appearance": {
|
||||
"switchToLight": "切换浅色模式",
|
||||
"switchToDark": "切换暗黑模式",
|
||||
"themePresets": {
|
||||
"material": "表面材质",
|
||||
"title": "主题预设",
|
||||
"default": {
|
||||
"label": "默认"
|
||||
},
|
||||
"soft": {
|
||||
"label": "柔光"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"global": "全局",
|
||||
"components": "组件",
|
||||
@@ -177,7 +187,6 @@
|
||||
"scrollBarSlider": "滚动条滑块",
|
||||
"scrollBarHover": "滚动条 Hover",
|
||||
"componentTokenComponentFamily": "Component Token 组件族",
|
||||
"description": "调整 {label} 的 Component Token,未修改的值继续由全局 Seed / Alias 算法生成",
|
||||
"noMatchingComponentToken": "没有匹配的 Component Token",
|
||||
"kindLabel": "组件 Token"
|
||||
},
|
||||
@@ -204,8 +213,6 @@
|
||||
"themeMode": "主题模式",
|
||||
"globalTokenClassification": "全局 Token 分类",
|
||||
"brandColorLabel": "预置主题 品牌色 colorPrimary",
|
||||
"brandColor": "品牌色",
|
||||
"presetThemes": "预置主题",
|
||||
"brandMainColor": "品牌主色",
|
||||
"paletteDescription": "完整色阶与界面基调",
|
||||
"semanticColorsTitle": "功能色与中性色",
|
||||
@@ -215,7 +222,6 @@
|
||||
"basicSpacing": "基础间距",
|
||||
"sizeStep": "尺寸步长",
|
||||
"styleToken": "风格 Token",
|
||||
"surfaceDescription": "固定无边设计,圆角塑形,阴影分层",
|
||||
"universalFillet": "通用圆角",
|
||||
"shadowIntensity": "阴影强度"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"switchLanguage": "切换语言({value})",
|
||||
"languageLoadFailed": "语言加载失败,请刷新重试",
|
||||
"mainLabel": "主导航",
|
||||
"pluginsLabel": "插件导航",
|
||||
"pluginManagement": "插件管理",
|
||||
"online": "在线",
|
||||
"sidebar": {
|
||||
|
||||
@@ -330,6 +330,12 @@
|
||||
"modelResponseReadFailed": "模型响应读取失败",
|
||||
"duplicateManagementRequest": "插件管理请求序号重复",
|
||||
"managementConcurrencyLimit": "插件管理请求已达并发上限",
|
||||
"openExternalLink": "打开外部链接",
|
||||
"openLink": "打开",
|
||||
"copyLink": "复制链接",
|
||||
"externalLinkPending": "请先处理当前外部链接",
|
||||
"externalLinkRequiresClick": "请在插件页面中点击链接或按钮",
|
||||
"invalidExternalLink": "请输入不含用户名和密码的 HTTPS 绝对地址",
|
||||
"invalidEntryPoint": "插件页面入口不是已注册的 HTML 资源",
|
||||
"unregisteredAsset": "插件页面引用了未注册资源:{path}",
|
||||
"htmlSubresource": "插件 HTML 资源不能作为子资源加载",
|
||||
|
||||
@@ -629,7 +629,7 @@
|
||||
"access": "安全与访问",
|
||||
"backups": "数据备份"
|
||||
},
|
||||
"description": "配置网关调度、上游与定价,管理访问安全、历史保留与备份",
|
||||
"description": "管理调度、上游、定价、安全、数据留存与备份",
|
||||
"sectionsLabel": "设置分区",
|
||||
"undoAllBasicSettingsChanges": "撤销全部基础设置更改",
|
||||
"unsavedChanges": {
|
||||
|
||||
@@ -145,8 +145,7 @@
|
||||
},
|
||||
"capacity": {
|
||||
"concurrentRequestsLimit": "賬號併發佔用 {value},上限 {value2}",
|
||||
"viewConcurrencyDetails": "{label},查看併發明細",
|
||||
"viewDetails": "{label},查看明細"
|
||||
"viewConcurrencyDetails": "{label},查看併發明細"
|
||||
},
|
||||
"concurrency": {
|
||||
"title": "併發明細",
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
"appearance": {
|
||||
"switchToLight": "切換淺色模式",
|
||||
"switchToDark": "切換暗黑模式",
|
||||
"themePresets": {
|
||||
"material": "表面材質",
|
||||
"title": "主題預設",
|
||||
"default": {
|
||||
"label": "預設"
|
||||
},
|
||||
"soft": {
|
||||
"label": "柔光"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"global": "全局",
|
||||
"components": "組件",
|
||||
@@ -177,7 +187,6 @@
|
||||
"scrollBarSlider": "滾動條滑塊",
|
||||
"scrollBarHover": "滾動條 Hover",
|
||||
"componentTokenComponentFamily": "Component Token 組件族",
|
||||
"description": "調整 {label} 的 Component Token,未修改的值繼續由全局 Seed / Alias 算法生成",
|
||||
"noMatchingComponentToken": "沒有匹配的 Component Token",
|
||||
"kindLabel": "組件 Token"
|
||||
},
|
||||
@@ -204,8 +213,6 @@
|
||||
"themeMode": "主題模式",
|
||||
"globalTokenClassification": "全局 Token 分類",
|
||||
"brandColorLabel": "預置主題 品牌色 colorPrimary",
|
||||
"brandColor": "品牌色",
|
||||
"presetThemes": "預置主題",
|
||||
"brandMainColor": "品牌主色",
|
||||
"paletteDescription": "完整色階與界面基調",
|
||||
"semanticColorsTitle": "功能色與中性色",
|
||||
@@ -215,7 +222,6 @@
|
||||
"basicSpacing": "基礎間距",
|
||||
"sizeStep": "尺寸步長",
|
||||
"styleToken": "風格 Token",
|
||||
"surfaceDescription": "固定無邊設計,圓角塑形,陰影分層",
|
||||
"universalFillet": "通用圓角",
|
||||
"shadowIntensity": "陰影強度"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"switchLanguage": "切換語言({value})",
|
||||
"languageLoadFailed": "語言加載失敗,請刷新重試",
|
||||
"mainLabel": "主導航",
|
||||
"pluginsLabel": "插件導航",
|
||||
"pluginManagement": "插件管理",
|
||||
"online": "在線",
|
||||
"sidebar": {
|
||||
|
||||
@@ -330,6 +330,12 @@
|
||||
"modelResponseReadFailed": "模型響應讀取失敗",
|
||||
"duplicateManagementRequest": "插件管理請求序號重複",
|
||||
"managementConcurrencyLimit": "插件管理請求已達併發上限",
|
||||
"openExternalLink": "開啟外部連結",
|
||||
"openLink": "開啟",
|
||||
"copyLink": "複製連結",
|
||||
"externalLinkPending": "請先處理目前的外部連結",
|
||||
"externalLinkRequiresClick": "請在插件頁面中點選連結或按鈕",
|
||||
"invalidExternalLink": "請輸入不含使用者名稱和密碼的 HTTPS 絕對網址",
|
||||
"invalidEntryPoint": "插件頁面入口不是已註冊的 HTML 資源",
|
||||
"unregisteredAsset": "插件頁面引用了未註冊資源:{path}",
|
||||
"htmlSubresource": "插件 HTML 資源不能作爲子資源加載",
|
||||
|
||||
@@ -629,7 +629,7 @@
|
||||
"access": "安全與訪問",
|
||||
"backups": "數據備份"
|
||||
},
|
||||
"description": "配置網關調度、上游與定價,管理訪問安全、歷史保留與備份",
|
||||
"description": "管理調度、上游、定價、安全、資料留存與備份",
|
||||
"sectionsLabel": "設置分區",
|
||||
"undoAllBasicSettingsChanges": "撤銷全部基礎設置更改",
|
||||
"unsavedChanges": {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { SidebarItem } from '@codex-proxy/ui'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { PluginManagementView } from '@/api'
|
||||
|
||||
import { ZIconButton, ZMotionIcon, ZPopover, ZScrollbar } from '@codex-proxy/ui'
|
||||
|
||||
import { ZIconButton, ZMotionIcon, ZSidebar } from '@codex-proxy/ui'
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
Blocks,
|
||||
BringToFront,
|
||||
ChartNoAxesColumn,
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
@@ -23,13 +22,10 @@ import {
|
||||
Sun,
|
||||
Users,
|
||||
} from '@lucide/vue'
|
||||
import { usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
|
||||
import { gsap } from 'gsap'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, useTemplateRef, watch } from 'vue'
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { isNavigationFailure, NavigationFailureType, useRoute, useRouter } from 'vue-router'
|
||||
import AppBrandMark from '@/components/AppBrandMark.vue'
|
||||
import AppLanguageMenu from '@/components/AppLanguageMenu.vue'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
@@ -38,17 +34,13 @@ import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
import { useThemeStore } from '@/stores/modules/theme'
|
||||
import { pluginPageLocation, shortPluginInstanceId } from '@/utils/plugin'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
mobile?: boolean
|
||||
}>(),
|
||||
{
|
||||
collapsed: false,
|
||||
mobile: false,
|
||||
},
|
||||
)
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
collapse?: boolean
|
||||
mobile?: boolean
|
||||
}>(), {
|
||||
collapse: false,
|
||||
mobile: false,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigate: []
|
||||
@@ -57,7 +49,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
const sidebar = useTemplateRef<InstanceType<typeof ZSidebar>>('sidebar')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -68,137 +60,87 @@ const { pageViews: pluginPageViews } = storeToRefs(pluginViewsStore)
|
||||
const { version, hasUpdate } = storeToRefs(systemUpdateStore)
|
||||
const { effectiveTheme } = storeToRefs(themeStore)
|
||||
const { toggleTheme } = themeStore
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
|
||||
const pluginMenuId = useId()
|
||||
const pluginGroupExpanded = shallowRef(route.path.startsWith('/plugins'))
|
||||
const isCollapsed = computed(() => !props.mobile && props.collapse)
|
||||
|
||||
const navItems = computed(() => ([
|
||||
{
|
||||
label: t('navigation.overview'),
|
||||
icon: LayoutDashboard,
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
label: t('navigation.accounts'),
|
||||
icon: Users,
|
||||
path: '/accounts',
|
||||
},
|
||||
{
|
||||
label: t('navigation.proxies'),
|
||||
icon: Network,
|
||||
path: '/proxies',
|
||||
},
|
||||
{
|
||||
label: t('navigation.groups'),
|
||||
icon: FolderTree,
|
||||
path: '/groups',
|
||||
},
|
||||
{
|
||||
label: t('navigation.apiKeys'),
|
||||
icon: KeyRound,
|
||||
path: '/keys',
|
||||
},
|
||||
{
|
||||
label: t('navigation.usage'),
|
||||
icon: ChartNoAxesColumn,
|
||||
path: '/usage',
|
||||
},
|
||||
{
|
||||
label: t('navigation.plugins'),
|
||||
icon: Puzzle,
|
||||
path: '/plugins',
|
||||
},
|
||||
{
|
||||
label: t('navigation.appearance'),
|
||||
icon: Palette,
|
||||
path: '/theme',
|
||||
},
|
||||
{
|
||||
label: t('navigation.settings'),
|
||||
icon: Settings,
|
||||
path: '/settings',
|
||||
},
|
||||
]))
|
||||
const pluginNavIndex = navItems.value.findIndex(item => item.path === '/plugins')
|
||||
const pluginMenuVisible = computed(() => pluginGroupExpanded.value && !isCollapsed.value)
|
||||
const pluginViewNameCounts = computed(() => pluginPageViews.value.reduce((counts, view) => {
|
||||
const title = view.pages[0]?.title ?? view.name
|
||||
counts.set(title, (counts.get(title) ?? 0) + 1)
|
||||
return counts
|
||||
}, new Map<string, number>()))
|
||||
const pluginChildrenHeight = computed(() => pluginMenuVisible.value
|
||||
? (pluginPageViews.value.length + 1) * 44
|
||||
: 0)
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === '/')
|
||||
return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
const activeNavIndex = computed(() => {
|
||||
const index = navItems.value.findIndex(item => isActive(item.path))
|
||||
return Math.max(0, index)
|
||||
})
|
||||
const activePluginIndex = computed(() => route.name === 'plugins'
|
||||
? 0
|
||||
: pluginPageViews.value.findIndex(isPluginViewActive) + 1)
|
||||
const activeNavIndicatorStyle = computed(() => {
|
||||
const inPluginMenu = isActive('/plugins') && pluginMenuVisible.value
|
||||
const offset = inPluginMenu
|
||||
? 50 + activePluginIndex.value * 44
|
||||
: activeNavIndex.value > pluginNavIndex
|
||||
? pluginChildrenHeight.value
|
||||
: 0
|
||||
// 主菜单与子菜单共享选中框,位置和尺寸在同一次过渡中变化
|
||||
return {
|
||||
transform: `translate3d(${inPluginMenu ? 24 : 0}px, ${activeNavIndex.value * 58 + offset}px, 0)`,
|
||||
width: inPluginMenu ? 'calc(100% - 24px)' : '100%',
|
||||
height: inPluginMenu ? '40px' : '46px',
|
||||
}
|
||||
})
|
||||
const navFeedbackMuted = shallowRef(false)
|
||||
const { start: restoreNavFeedback, stop: stopNavFeedbackRestore } = useTimeoutFn(
|
||||
() => {
|
||||
navFeedbackMuted.value = false
|
||||
const navItems = computed<SidebarItem[]>(() => [
|
||||
{
|
||||
index: '/',
|
||||
label: t('navigation.overview'),
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
300,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
function muteNavFeedbackDuringMove() {
|
||||
navFeedbackMuted.value = true
|
||||
stopNavFeedbackRestore()
|
||||
restoreNavFeedback()
|
||||
}
|
||||
|
||||
function navigate(path: string) {
|
||||
muteNavFeedbackDuringMove()
|
||||
void router.push(path)
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
function togglePluginGroup() {
|
||||
if (isCollapsed.value) {
|
||||
navigate('/plugins')
|
||||
return
|
||||
{
|
||||
index: '/accounts',
|
||||
label: t('navigation.accounts'),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
index: '/proxies',
|
||||
label: t('navigation.proxies'),
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
index: '/groups',
|
||||
label: t('navigation.groups'),
|
||||
icon: FolderTree,
|
||||
},
|
||||
{
|
||||
index: '/keys',
|
||||
label: t('navigation.apiKeys'),
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
index: '/usage',
|
||||
label: t('navigation.usage'),
|
||||
icon: ChartNoAxesColumn,
|
||||
},
|
||||
{
|
||||
index: 'plugins',
|
||||
label: t('navigation.plugins'),
|
||||
icon: Puzzle,
|
||||
children: [
|
||||
{
|
||||
index: '/plugins',
|
||||
label: t('navigation.pluginManagement'),
|
||||
icon: BringToFront,
|
||||
},
|
||||
...pluginPageViews.value.map(view => ({
|
||||
index: `plugin:${view.target.instanceId}`,
|
||||
label: pluginViewLabel(view),
|
||||
icon: Blocks,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
index: '/theme',
|
||||
label: t('navigation.appearance'),
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
index: '/settings',
|
||||
label: t('navigation.settings'),
|
||||
icon: Settings,
|
||||
},
|
||||
])
|
||||
const activeIndex = computed(() => {
|
||||
if (route.path.startsWith('/plugins')) {
|
||||
if (route.name === 'plugins')
|
||||
return '/plugins'
|
||||
const view = pluginPageViews.value.find(isPluginViewActive)
|
||||
return view
|
||||
? `plugin:${view.target.instanceId}`
|
||||
: 'plugins'
|
||||
}
|
||||
pluginGroupExpanded.value = !pluginGroupExpanded.value
|
||||
}
|
||||
|
||||
function navigatePluginView(view: PluginManagementView) {
|
||||
const page = view.pages[0]
|
||||
if (page)
|
||||
navigate(router.resolve(pluginPageLocation(view, page)).fullPath)
|
||||
}
|
||||
return navItems.value.find(item => item.index !== '/' && route.path.startsWith(item.index))?.index ?? '/'
|
||||
})
|
||||
|
||||
function isPluginViewActive(view: PluginManagementView) {
|
||||
const value = route.params.instanceId
|
||||
const routeInstanceId = Array.isArray(value)
|
||||
? value[0]
|
||||
: value
|
||||
return route.name === 'plugin-page' && routeInstanceId === view.target.instanceId
|
||||
return route.name === 'plugin-page' && route.params.instanceId === view.target.instanceId
|
||||
}
|
||||
|
||||
function pluginViewLabel(view: PluginManagementView) {
|
||||
@@ -211,6 +153,24 @@ function pluginViewLabel(view: PluginManagementView) {
|
||||
: view.name}`
|
||||
}
|
||||
|
||||
async function handleSelect(index: string) {
|
||||
// 路由守卫可能取消跳转,菜单保持当前页,直到实际路由改变
|
||||
sidebar.value?.updateActiveIndex(activeIndex.value)
|
||||
let target: RouteLocationRaw = index === 'plugins'
|
||||
? '/plugins'
|
||||
: index
|
||||
if (index.startsWith('plugin:')) {
|
||||
const view = pluginPageViews.value.find(item => `plugin:${item.target.instanceId}` === index)
|
||||
const page = view?.pages[0]
|
||||
if (!view || !page)
|
||||
return
|
||||
target = pluginPageLocation(view, page)
|
||||
}
|
||||
const failure = await router.push(target)
|
||||
if (!failure || isNavigationFailure(failure, NavigationFailureType.duplicated))
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
function openSystemUpdate() {
|
||||
emit('openSystemUpdate')
|
||||
}
|
||||
@@ -222,401 +182,65 @@ async function handleLogout() {
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
const sidebarEl = ref<HTMLElement | null>(null)
|
||||
const brandLabelEl = ref<HTMLElement | null>(null)
|
||||
const navSignalEl = useTemplateRef<HTMLElement>('navSignal')
|
||||
const collapsedSidebarWidth = 88
|
||||
const expandedSidebarWidth = 251
|
||||
const sidebarWidth = computed(() => (isCollapsed.value ? collapsedSidebarWidth : expandedSidebarWidth))
|
||||
const brandLabelVisible = shallowRef(!isCollapsed.value)
|
||||
const themeToggleLabel = computed(() => (effectiveTheme.value === 'dark'
|
||||
const themeToggleLabel = computed(() => effectiveTheme.value === 'dark'
|
||||
? t('appearance.switchToLight')
|
||||
: t('appearance.switchToDark')))
|
||||
: t('appearance.switchToDark'))
|
||||
const versionText = computed(() => version.value?.version.trim() ?? '')
|
||||
const hasVersionLabel = computed(() => versionText.value.length > 0)
|
||||
const versionLabel = computed(() => `v${versionText.value}`)
|
||||
const updateButtonLabel = computed(() => (hasUpdate.value
|
||||
const updateButtonLabel = computed(() => hasUpdate.value
|
||||
? t('navigation.update.available')
|
||||
: t('navigation.update.open')))
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return preferredMotion.value === 'reduce'
|
||||
}
|
||||
|
||||
function animateSidebarLabels(collapsed: boolean) {
|
||||
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
|
||||
|
||||
if (!labels?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
gsap.set(labels, {
|
||||
opacity: collapsed ? 0 : 1,
|
||||
x: collapsed
|
||||
? -6
|
||||
: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
gsap.to(labels, {
|
||||
opacity: collapsed ? 0 : 1,
|
||||
x: collapsed
|
||||
? -6
|
||||
: 0,
|
||||
duration: collapsed ? 0.16 : 0.2,
|
||||
ease: collapsed ? 'power2.in' : 'power3.out',
|
||||
stagger: collapsed ? 0 : 0.018,
|
||||
overwrite: true,
|
||||
})
|
||||
}
|
||||
|
||||
function hideBrandLabel() {
|
||||
const label = brandLabelEl.value
|
||||
|
||||
if (label) {
|
||||
gsap.killTweensOf(label)
|
||||
gsap.set(label, {
|
||||
opacity: 0,
|
||||
x: -6,
|
||||
})
|
||||
}
|
||||
|
||||
brandLabelVisible.value = false
|
||||
}
|
||||
|
||||
function animateBrandLabelEnter() {
|
||||
const label = brandLabelEl.value
|
||||
|
||||
if (!label) {
|
||||
return
|
||||
}
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
gsap.set(label, {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
gsap.fromTo(
|
||||
label,
|
||||
{
|
||||
opacity: 0,
|
||||
x: -6,
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
duration: 0.2,
|
||||
ease: 'power3.out',
|
||||
overwrite: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function animateSidebarWidth(collapsed: boolean) {
|
||||
if (!sidebarEl.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetWidth = collapsed ? collapsedSidebarWidth : expandedSidebarWidth
|
||||
const currentWidth = sidebarEl.value.getBoundingClientRect().width
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
gsap.set(sidebarEl.value, {
|
||||
width: targetWidth,
|
||||
flexBasis: targetWidth,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
gsap.set(sidebarEl.value, {
|
||||
width: currentWidth,
|
||||
flexBasis: currentWidth,
|
||||
})
|
||||
|
||||
gsap.to(sidebarEl.value, {
|
||||
width: targetWidth,
|
||||
flexBasis: targetWidth,
|
||||
duration: 0.34,
|
||||
ease: 'power3.out',
|
||||
overwrite: true,
|
||||
})
|
||||
}
|
||||
|
||||
function animateNavSignal() {
|
||||
const signal = navSignalEl.value
|
||||
if (!signal)
|
||||
return
|
||||
|
||||
gsap.killTweensOf(signal)
|
||||
if (prefersReducedMotion()) {
|
||||
gsap.set(signal, { opacity: 0, xPercent: -70 })
|
||||
return
|
||||
}
|
||||
|
||||
gsap.fromTo(
|
||||
signal,
|
||||
{ opacity: 0.48, xPercent: -70 },
|
||||
{
|
||||
opacity: 0,
|
||||
xPercent: 120,
|
||||
duration: 0.52,
|
||||
ease: 'power2.out',
|
||||
overwrite: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
: t('navigation.update.open'))
|
||||
|
||||
onMounted(() => {
|
||||
void pluginViewsStore.ensureLoaded().catch(() => undefined)
|
||||
gsap.set(sidebarEl.value, {
|
||||
width: sidebarWidth.value,
|
||||
flexBasis: sidebarWidth.value,
|
||||
})
|
||||
if (isCollapsed.value) {
|
||||
hideBrandLabel()
|
||||
}
|
||||
else {
|
||||
gsap.set(brandLabelEl.value, {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
})
|
||||
}
|
||||
animateSidebarLabels(isCollapsed.value)
|
||||
gsap.set(navSignalEl.value, { opacity: 0, xPercent: -70 })
|
||||
})
|
||||
|
||||
watch(
|
||||
() => isCollapsed.value,
|
||||
async (collapsed) => {
|
||||
if (collapsed) {
|
||||
hideBrandLabel()
|
||||
}
|
||||
else {
|
||||
brandLabelVisible.value = true
|
||||
}
|
||||
animateSidebarLabels(Boolean(collapsed))
|
||||
animateSidebarWidth(Boolean(collapsed))
|
||||
await nextTick()
|
||||
if (!collapsed) {
|
||||
animateBrandLabelEnter()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
async (path, previousPath) => {
|
||||
if (path === previousPath)
|
||||
return
|
||||
if (path.startsWith('/plugins'))
|
||||
pluginGroupExpanded.value = true
|
||||
muteNavFeedbackDuringMove()
|
||||
await nextTick()
|
||||
animateNavSignal()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopNavFeedbackRestore()
|
||||
const targets = [
|
||||
sidebarEl.value,
|
||||
brandLabelEl.value,
|
||||
navSignalEl.value,
|
||||
].filter((target): target is HTMLElement =>
|
||||
Boolean(target),
|
||||
)
|
||||
gsap.killTweensOf(targets)
|
||||
|
||||
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
|
||||
if (labels?.length) {
|
||||
gsap.killTweensOf(labels)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
ref="sidebarEl"
|
||||
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--z-layout-sider-bg) shadow-z-layout-sider"
|
||||
:class="[
|
||||
mobile ? 'flex' : 'hidden min-[961px]:flex',
|
||||
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
|
||||
]"
|
||||
<ZSidebar
|
||||
ref="sidebar"
|
||||
class="z-20"
|
||||
:class="mobile ? 'flex' : 'hidden min-[961px]:flex'"
|
||||
:items="navItems"
|
||||
:default-active="activeIndex"
|
||||
:collapse="isCollapsed"
|
||||
:navigation-label="t('navigation.mainLabel')"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<div
|
||||
class="mx-4 mt-6 grid h-12 shrink-0 grid-cols-[44px_minmax(0,1fr)] items-center"
|
||||
:class="isCollapsed ? 'w-11 justify-start' : 'self-stretch gap-3'"
|
||||
>
|
||||
<template #brand-icon>
|
||||
<ZMotionIcon
|
||||
variant="brand"
|
||||
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-z"
|
||||
>
|
||||
<AppBrandMark class="block size-11 select-none" />
|
||||
</ZMotionIcon>
|
||||
<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-z-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-z-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-z-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-z-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-z-bg-container"
|
||||
:class="[
|
||||
hasUpdate
|
||||
? 'bg-z-success-container text-z-success-on-container hover:bg-z-success-container-hover'
|
||||
: 'bg-z-fill-quaternary text-z-text-quaternary hover:bg-z-fill-tertiary hover:text-z-text-secondary',
|
||||
]"
|
||||
:title="updateButtonLabel"
|
||||
@click="openSystemUpdate"
|
||||
>
|
||||
<span>{{ versionLabel }}</span>
|
||||
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-z-success" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<template #brand>
|
||||
<strong class="text-base leading-[1.1] font-heavy text-z-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-z-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-z-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-z-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-z-bg-container"
|
||||
:class="[
|
||||
hasUpdate
|
||||
? 'bg-z-success-container text-z-success-on-container hover:bg-z-success-container-hover'
|
||||
: 'bg-z-fill-quaternary text-z-text-quaternary hover:bg-z-fill-tertiary hover:text-z-text-secondary',
|
||||
]"
|
||||
:aria-label="updateButtonLabel"
|
||||
@click="openSystemUpdate"
|
||||
>
|
||||
<span>{{ versionLabel }}</span>
|
||||
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-z-success" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ZScrollbar class="my-6 w-full flex-1">
|
||||
<div class="px-4">
|
||||
<!-- 焦点环绘制在按钮内侧,避免被滚动区和折叠菜单裁切 -->
|
||||
<nav class="relative grid gap-3" :class="isCollapsed ? 'mx-auto w-11.5' : 'w-full'" :aria-label="t('navigation.mainLabel')">
|
||||
<span
|
||||
class="pointer-events-none absolute left-0 top-0 overflow-hidden rounded-z bg-z-menu-item-selected-bg transition-[transform,width,height] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||
:style="activeNavIndicatorStyle"
|
||||
>
|
||||
<span
|
||||
ref="navSignal"
|
||||
class="absolute inset-y-0 left-0 w-2/3 [background:linear-gradient(90deg,transparent,color-mix(in_srgb,var(--z-color-info)_9%,transparent),transparent)]"
|
||||
/>
|
||||
</span>
|
||||
<template v-for="item in navItems" :key="item.label">
|
||||
<div v-if="item.path === '/plugins'" class="relative z-10 grid min-w-0">
|
||||
<ZPopover trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="w-full">
|
||||
<template #reference="{ close }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-11.5 cursor-pointer items-center rounded-z border-0 bg-transparent text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline"
|
||||
:class="[
|
||||
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isActive(item.path)
|
||||
? navFeedbackMuted
|
||||
? 'font-bold text-z-text transition-none'
|
||||
: 'font-bold text-z-text transition-colors duration-200'
|
||||
: navFeedbackMuted
|
||||
? 'font-semibold text-z-text-secondary transition-none'
|
||||
: 'font-semibold text-z-text-secondary transition-colors duration-200 hover:bg-z-fill-quaternary hover:text-z-text',
|
||||
]"
|
||||
:aria-expanded="isCollapsed ? undefined : pluginGroupExpanded"
|
||||
:aria-controls="isCollapsed ? undefined : pluginMenuId"
|
||||
:aria-label="isCollapsed ? item.label : undefined"
|
||||
@click="close(); togglePluginGroup()"
|
||||
>
|
||||
<component :is="item.icon" class="shrink-0" :size="20" />
|
||||
<span
|
||||
class="sidebar-label min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left transition-[opacity,transform] duration-200"
|
||||
:class="isCollapsed ? 'hidden' : 'w-auto'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!isCollapsed"
|
||||
class="sidebar-label inline-flex size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-4 transition-transform duration-200 motion-reduce:transition-none"
|
||||
:class="pluginGroupExpanded ? undefined : '-rotate-90'"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<span class="block px-3 py-2 text-z-sm whitespace-nowrap">{{ item.label }}</span>
|
||||
</ZPopover>
|
||||
|
||||
<div
|
||||
:id="pluginMenuId"
|
||||
class="grid transition-[grid-template-rows] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||
:class="pluginMenuVisible ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||
:inert="!pluginMenuVisible"
|
||||
:aria-label="t('navigation.pluginsLabel')"
|
||||
>
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div class="relative ml-3 grid gap-1 pt-1 pl-3">
|
||||
<button
|
||||
type="button"
|
||||
class="relative inline-flex h-10 min-w-0 cursor-pointer items-center gap-2 rounded-z border-0 bg-transparent px-3 text-left text-z-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline motion-reduce:transition-none"
|
||||
:class="route.name === 'plugins'
|
||||
? 'font-emphasis text-z-text'
|
||||
: navFeedbackMuted ? 'font-semibold text-z-text-secondary' : 'font-semibold text-z-text-secondary hover:bg-z-fill-quaternary hover:text-z-text'"
|
||||
:aria-current="route.name === 'plugins' ? 'page' : undefined"
|
||||
@click="navigate('/plugins')"
|
||||
>
|
||||
<BringToFront class="block size-4 shrink-0" />
|
||||
<span class="sidebar-label truncate leading-none">{{ t('navigation.pluginManagement') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="view in pluginPageViews"
|
||||
:key="view.target.instanceId"
|
||||
type="button"
|
||||
class="relative inline-flex h-10 min-w-0 cursor-pointer items-center gap-2 rounded-z border-0 bg-transparent px-3 text-left text-z-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline motion-reduce:transition-none"
|
||||
:class="isPluginViewActive(view)
|
||||
? 'font-emphasis text-z-text'
|
||||
: navFeedbackMuted ? 'font-semibold text-z-text-secondary' : 'font-semibold text-z-text-secondary hover:bg-z-fill-quaternary hover:text-z-text'"
|
||||
:aria-current="isPluginViewActive(view) ? 'page' : undefined"
|
||||
@click="navigatePluginView(view)"
|
||||
>
|
||||
<Blocks class="block size-4 shrink-0" />
|
||||
<span class="sidebar-label truncate leading-none">{{ pluginViewLabel(view) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZPopover v-else trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="relative z-10 w-full">
|
||||
<template #reference="{ close }">
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-z border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline"
|
||||
:class="[
|
||||
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isActive(item.path)
|
||||
? navFeedbackMuted
|
||||
? 'bg-transparent font-bold text-z-text transition-none'
|
||||
: 'bg-transparent font-bold text-z-text transition-colors duration-200'
|
||||
: navFeedbackMuted
|
||||
? 'bg-transparent font-semibold text-z-text-secondary transition-none'
|
||||
: 'bg-transparent font-semibold text-z-text-secondary transition-colors duration-200 hover:bg-z-fill-quaternary hover:text-z-text',
|
||||
]"
|
||||
:aria-label="isCollapsed ? item.label : undefined"
|
||||
@click="close(); navigate(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="shrink-0" :size="20" />
|
||||
<span
|
||||
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
|
||||
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<span class="block px-3 py-2 text-z-sm whitespace-nowrap">{{ item.label }}</span>
|
||||
</ZPopover>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</ZScrollbar>
|
||||
|
||||
<div class="mx-4 mb-6 shrink-0" :class="isCollapsed ? 'w-11' : 'self-stretch'">
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
class="bg-z-fill-quaternary"
|
||||
:class="isCollapsed ? 'grid gap-1 rounded-z p-1' : 'grid h-11 grid-cols-[4.25rem_auto] items-center justify-between rounded-z-lg px-2'"
|
||||
:class="isCollapsed ? 'grid gap-1 rounded-z p-1' : 'grid min-h-11 grid-cols-[4.25rem_auto] items-center justify-between rounded-z-lg px-2'"
|
||||
>
|
||||
<span
|
||||
v-if="!isCollapsed"
|
||||
@@ -626,7 +250,7 @@ onBeforeUnmount(() => {
|
||||
{{ t('navigation.online') }}
|
||||
</span>
|
||||
|
||||
<div class="flex shrink-0 items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<div :class="isCollapsed ? 'grid justify-items-center gap-1' : 'flex shrink-0 items-center gap-1 [&_button]:max-w-8'">
|
||||
<ZIconButton
|
||||
v-if="isCollapsed && hasUpdate"
|
||||
type="success" variant="plain"
|
||||
@@ -674,6 +298,6 @@ onBeforeUnmount(() => {
|
||||
</ZIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
</ZSidebar>
|
||||
</template>
|
||||
|
||||
@@ -318,7 +318,7 @@ watch(
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3.5 gap-y-3 rounded-z bg-z-warning-container px-4 py-5 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<span class="grid size-9 place-items-center rounded-z bg-z-warning-container-active text-z-warning-on-container" aria-hidden="true">
|
||||
<component :is="updateInfo.retryAt ? Hourglass : TriangleAlert" class="size-[18px]" />
|
||||
<component :is="updateInfo.retryAt ? Hourglass : TriangleAlert" class="size-4.5" />
|
||||
</span>
|
||||
<p class="m-0 min-w-0 wrap-anywhere text-z font-bold leading-tight text-z-warning-on-container">
|
||||
{{ updateInfo.warning }}
|
||||
@@ -393,7 +393,7 @@ watch(
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-z-fill-quaternary px-2 text-z-xs leading-none font-bold text-z-text-secondary"
|
||||
:title="updateStreamError || presentation.streamStatusLabel"
|
||||
:title="updateStreamError || undefined"
|
||||
>
|
||||
<i
|
||||
class="size-1.5 rounded-full"
|
||||
|
||||
@@ -69,7 +69,7 @@ watch(
|
||||
<template>
|
||||
<div class="relative flex h-dvh overflow-hidden bg-z-bg-layout" :inert="mobileSidebarOpen">
|
||||
<AppSidebar
|
||||
:collapsed="sidebarCollapsed"
|
||||
:collapse="sidebarCollapsed"
|
||||
@toggle="toggleSidebar"
|
||||
|
||||
@open-system-update="systemUpdateOpen = true"
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ThemeColorId,
|
||||
ThemeCustomization,
|
||||
ThemeMode,
|
||||
ThemePresetId,
|
||||
} from '@codex-proxy/ui/theme'
|
||||
|
||||
import { normalizeHexColor } from '@codex-proxy/ui'
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
createTheme,
|
||||
defaultThemeColorId,
|
||||
defaultThemeMode,
|
||||
defaultThemePresetId,
|
||||
defaultThemePrimaryColor,
|
||||
isThemeColorId,
|
||||
isThemeMode,
|
||||
isThemePresetId,
|
||||
normalizeThemeCustomization,
|
||||
resolveThemeScheme,
|
||||
} from '@codex-proxy/ui/theme'
|
||||
@@ -30,6 +33,7 @@ interface ThemeTransitionOrigin {
|
||||
}
|
||||
|
||||
interface ThemeConfigurationInput {
|
||||
preset: ThemePresetId
|
||||
mode: ThemeMode
|
||||
color: ThemeColorId
|
||||
customColor: string
|
||||
@@ -49,6 +53,7 @@ export const useThemeStore = defineStore(
|
||||
'theme',
|
||||
() => {
|
||||
const themeMode = shallowRef<ThemeMode>(defaultThemeMode)
|
||||
const themePreset = shallowRef<ThemePresetId>(defaultThemePresetId)
|
||||
const themeColor = shallowRef<ThemeColorId>(defaultThemeColorId)
|
||||
const customThemeColor = shallowRef(defaultThemePrimaryColor)
|
||||
const themeCustomization = ref<ThemeCustomization>({})
|
||||
@@ -64,6 +69,7 @@ export const useThemeStore = defineStore(
|
||||
const resolvedTheme = computed(() =>
|
||||
createTheme({
|
||||
scheme: effectiveTheme.value,
|
||||
preset: themePreset.value,
|
||||
colorId: themeColor.value,
|
||||
primaryColor: customThemeColor.value,
|
||||
customization: themeCustomization.value,
|
||||
@@ -87,6 +93,8 @@ export const useThemeStore = defineStore(
|
||||
}
|
||||
|
||||
function normalizePersistedTheme(): void {
|
||||
if (!isThemePresetId(themePreset.value))
|
||||
themePreset.value = defaultThemePresetId
|
||||
if (!isThemeMode(themeMode.value))
|
||||
themeMode.value = defaultThemeMode
|
||||
if (!isThemeColorId(themeColor.value))
|
||||
@@ -237,10 +245,11 @@ export const useThemeStore = defineStore(
|
||||
event?: MouseEvent,
|
||||
): boolean {
|
||||
const customColor = normalizeHexColor(configuration.customColor)
|
||||
if (!isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
|
||||
if (!isThemePresetId(configuration.preset) || !isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
|
||||
return false
|
||||
|
||||
requestThemeTransition(event)
|
||||
themePreset.value = configuration.preset
|
||||
themeMode.value = configuration.mode
|
||||
themeColor.value = configuration.color
|
||||
customThemeColor.value = customColor
|
||||
@@ -251,6 +260,7 @@ export const useThemeStore = defineStore(
|
||||
function resetTheme(event?: MouseEvent): void {
|
||||
const unchanged
|
||||
= themeMode.value === defaultThemeMode
|
||||
&& themePreset.value === defaultThemePresetId
|
||||
&& themeColor.value === defaultThemeColorId
|
||||
&& customThemeColor.value === defaultThemePrimaryColor
|
||||
&& Object.keys(themeCustomization.value).length === 0
|
||||
@@ -260,6 +270,7 @@ export const useThemeStore = defineStore(
|
||||
}
|
||||
requestThemeTransition(event)
|
||||
themeMode.value = defaultThemeMode
|
||||
themePreset.value = defaultThemePresetId
|
||||
themeColor.value = defaultThemeColorId
|
||||
customThemeColor.value = defaultThemePrimaryColor
|
||||
themeCustomization.value = {}
|
||||
@@ -288,6 +299,7 @@ export const useThemeStore = defineStore(
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
themePreset,
|
||||
themeColor,
|
||||
customThemeColor,
|
||||
themeCustomization,
|
||||
@@ -308,6 +320,7 @@ export const useThemeStore = defineStore(
|
||||
key: 'codex-proxy-rs-theme',
|
||||
pick: [
|
||||
'themeMode',
|
||||
'themePreset',
|
||||
'themeColor',
|
||||
'customThemeColor',
|
||||
'themeCustomization',
|
||||
@@ -317,5 +330,5 @@ export const useThemeStore = defineStore(
|
||||
)
|
||||
|
||||
function themeSignature(theme: Theme): string {
|
||||
return `${theme.scheme}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
|
||||
return `${theme.preset}:${theme.scheme}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ const label = computed(() => t('accounts.capacity.concurrentRequestsLimit', { va
|
||||
<ZButton
|
||||
variant="text" size="small" :type="full ? 'warning' : 'default'"
|
||||
:aria-label="t('accounts.capacity.viewConcurrencyDetails', { label })"
|
||||
:title="t('accounts.capacity.viewDetails', { label })"
|
||||
class="relative top-0.75 -m-0.5 h-auto! px-0.5! py-0.5! whitespace-nowrap font-mono text-[9px]! font-normal! tabular-nums hover:bg-z-bg-text-hover!"
|
||||
@click.stop="$emit('showDetails')"
|
||||
>
|
||||
|
||||
@@ -257,7 +257,6 @@ watch(search, () => {
|
||||
:aria-label="t('accounts.modelAccess.catalogHelpTitle')"
|
||||
:aria-describedby="open ? id : undefined"
|
||||
:aria-expanded="open"
|
||||
:title="undefined"
|
||||
@keydown="handleCatalogHelpKeydown($event, open, close)"
|
||||
>
|
||||
<CircleAlert class="size-[1em] text-z-xs" aria-hidden="true" />
|
||||
|
||||
@@ -62,7 +62,6 @@ const copyWithToast = useCopyText()
|
||||
<ZIconButton
|
||||
variant="solid"
|
||||
size="small"
|
||||
:title="t('accounts.onboarding.copyLink')"
|
||||
:aria-label="t('accounts.onboarding.copyLink')"
|
||||
:disabled="disabled"
|
||||
@click="copyWithToast(authUrl, { successText: t('accounts.onboarding.authorizationLinkCopied') })"
|
||||
|
||||
@@ -70,7 +70,6 @@ function handleRetentionKeydown(event: KeyboardEvent) {
|
||||
<template #reference>
|
||||
<ZIconButton
|
||||
variant="link"
|
||||
:title="undefined"
|
||||
:aria-label="t('accounts.importTasks.taskRetentionInstructions')"
|
||||
size="small"
|
||||
:aria-expanded="retentionOpen"
|
||||
|
||||
@@ -117,7 +117,6 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
<template #reference>
|
||||
<ZIconButton
|
||||
variant="link"
|
||||
:title="undefined"
|
||||
:aria-label="t('accounts.quotaForecast.forecastDescription')"
|
||||
size="small"
|
||||
:aria-expanded="explanationOpen"
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ watch(() => props.accountId, () => {
|
||||
</h3>
|
||||
<ZPopover trigger="hover" placement="bottom-start" role="tooltip">
|
||||
<template #reference="{ open, id }">
|
||||
<ZIconButton variant="link" :title="undefined" size="small" class="size-6! p-0!" :aria-label="t('accounts.quotaReset.automaticUsageRules')" :aria-describedby="open ? id : undefined">
|
||||
<ZIconButton variant="link" size="small" class="size-6! p-0!" :aria-label="t('accounts.quotaReset.automaticUsageRules')" :aria-describedby="open ? id : undefined">
|
||||
<CircleHelp class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -213,7 +213,7 @@ const {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 w-full flex-col xl:h-full xl:overflow-hidden">
|
||||
<div class="flex min-h-0 w-full flex-col xl:h-[calc(100dvh-3rem)] xl:flex-none!">
|
||||
<ZPageHeader
|
||||
:title="t('navigation.accounts')"
|
||||
:description="t('accounts.page.description')"
|
||||
@@ -222,7 +222,7 @@ const {
|
||||
<AccountOverviewCards :summary="accountSummary" />
|
||||
|
||||
<ZCard
|
||||
class="mt-4 flex flex-col xl:h-[calc(100dvh-250px)] xl:min-h-125"
|
||||
class="mt-4 flex flex-col xl:min-h-80 xl:flex-1"
|
||||
>
|
||||
<template #header>
|
||||
<AccountFilters
|
||||
|
||||
@@ -163,7 +163,7 @@ const authProtocol = computed(() => {
|
||||
const runtimeEnvironment = computed(() => {
|
||||
const target = profile.value?.target
|
||||
if (!target)
|
||||
return { primary: '—', details: [] as string[], title: '—' }
|
||||
return { primary: '—', details: [] as string[] }
|
||||
|
||||
const present = (value: string) => value !== '—' && value.toLowerCase() !== 'unknown'
|
||||
const primary = [target.osType, target.osVersion].filter(present).join(' ')
|
||||
@@ -171,7 +171,6 @@ const runtimeEnvironment = computed(() => {
|
||||
return {
|
||||
primary: primary || '—',
|
||||
details,
|
||||
title: [primary, ...details].filter(Boolean).join(' · '),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -238,13 +237,12 @@ watch(
|
||||
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1.5">
|
||||
<strong
|
||||
:aria-label="profile.version"
|
||||
class="inline-flex max-w-full min-w-0 items-baseline gap-2 font-mono leading-none tabular-nums"
|
||||
:title="profile.version"
|
||||
class="inline-flex max-w-full min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1 font-mono leading-none tabular-nums"
|
||||
>
|
||||
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-z-text">
|
||||
{{ versionParts.release }}
|
||||
</span>
|
||||
<span v-if="versionParts.prerelease" class="truncate text-z-sm font-bold text-z-text-secondary">
|
||||
<span v-if="versionParts.prerelease" class="text-z-sm font-bold wrap-anywhere text-z-text-secondary">
|
||||
{{ versionParts.prerelease }}
|
||||
</span>
|
||||
</strong>
|
||||
@@ -263,10 +261,7 @@ watch(
|
||||
<ShieldCheck class="size-3.25 text-z-text-tertiary" />
|
||||
{{ t('dashboard.clientIdentity.authProtocol') }}
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text"
|
||||
:title="authProtocol"
|
||||
>
|
||||
<dd class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text">
|
||||
{{ authProtocol }}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -276,10 +271,7 @@ watch(
|
||||
<Monitor class="size-3.25 text-z-text-tertiary" />
|
||||
{{ profile.provider === 'openai' ? t('dashboard.clientIdentity.emulatedEnvironment') : t('dashboard.clientIdentity.environment') }}
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text"
|
||||
:title="runtimeEnvironment.title"
|
||||
>
|
||||
<dd class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text">
|
||||
{{ runtimeEnvironment.primary }}
|
||||
<span v-if="runtimeEnvironment.details.length" class="text-z-xs font-emphasis text-z-text-secondary">
|
||||
<template v-for="detail in runtimeEnvironment.details" :key="detail"> · {{ detail }} </template>
|
||||
@@ -292,10 +284,7 @@ watch(
|
||||
<Terminal class="size-3.25 text-z-text-tertiary" />
|
||||
{{ attribute.label }}
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-2 mb-0 font-mono text-[16px] leading-snug font-heavy wrap-anywhere tabular-nums text-z-text"
|
||||
:title="attribute.value"
|
||||
>
|
||||
<dd class="mt-2 mb-0 font-mono text-[16px] leading-snug font-heavy wrap-anywhere tabular-nums text-z-text">
|
||||
{{ attribute.value }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ const metrics = computed(() => [
|
||||
</ZMotionIcon>
|
||||
<span class="sr-only">{{ t('usage.summary.consumeTokens') }}</span>
|
||||
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<strong class="min-w-0 font-mono text-[32px] leading-[1.05] font-heavy wrap-anywhere tabular-nums text-z-text" :title="formatCompactNumber(summary.totalTokens)">
|
||||
<strong class="min-w-0 font-mono text-[32px] leading-[1.05] font-heavy wrap-anywhere tabular-nums text-z-text">
|
||||
<AnimatedMetricValue :value="formatInteger(summary.totalTokens)" :raw-value="summary.totalTokens" :formatter="formatInteger" />
|
||||
</strong>
|
||||
<span class="shrink-0 font-mono text-z-sm leading-none whitespace-nowrap text-z-text-tertiary">≈ {{ formatCompactNumber(summary.totalTokens) }}</span>
|
||||
|
||||
@@ -22,7 +22,6 @@ const label = computed(() => effectiveTheme.value === 'dark'
|
||||
type="button"
|
||||
class="relative inline-grid h-9 w-20 shrink-0 cursor-pointer grid-cols-2 place-items-center rounded-[20px] border-0 bg-z-bg-text-hover p-0 outline-none focus-visible:ring-2 focus-visible:ring-z-control-outline"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
@click="themeStore.toggleTheme($event)"
|
||||
>
|
||||
<Sun
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { PluginManagementView } from '@/api'
|
||||
|
||||
import { ZButton, ZEmpty, ZTag } from '@codex-proxy/ui'
|
||||
import { ZButton, ZCard, ZEmpty, ZTag } from '@codex-proxy/ui'
|
||||
|
||||
import { Blocks, ExternalLink } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
@@ -40,10 +40,12 @@ function openView(view: PluginManagementView) {
|
||||
/>
|
||||
|
||||
<div v-else class="grid content-start gap-3 overflow-y-auto sm:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
<ZCard
|
||||
v-for="view in pageViews"
|
||||
:key="view.target.instanceId"
|
||||
class="flex min-w-0 flex-col gap-3 rounded-z-card bg-z-bg-container p-4 shadow-z-card"
|
||||
as="article"
|
||||
padding="compact"
|
||||
class="flex min-w-0 flex-col gap-3"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span class="inline-flex size-9 shrink-0 items-center justify-center rounded-z bg-z-primary-container text-z-primary-on-container">
|
||||
@@ -72,7 +74,7 @@ function openView(view: PluginManagementView) {
|
||||
</template>
|
||||
{{ t('plugins.extensions.openPage') }}
|
||||
</ZButton>
|
||||
</article>
|
||||
</ZCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ZButton, ZDialog } from '@codex-proxy/ui'
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useCopyText } from '@/composables/useCopyText'
|
||||
|
||||
const props = defineProps<{ link: URL }>()
|
||||
defineEmits<{ closed: [] }>()
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
const copyText = useCopyText()
|
||||
const open = shallowRef(false)
|
||||
|
||||
// 确认框按请求挂载,挂载后打开才会触发入场动画
|
||||
onMounted(() => {
|
||||
open.value = true
|
||||
})
|
||||
|
||||
function openLink() {
|
||||
window.open(props.link.href, '_blank', 'noopener,noreferrer')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZDialog
|
||||
v-model="open"
|
||||
:title="t('plugins.frame.openExternalLink')"
|
||||
width="28rem"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #description>
|
||||
<bdi dir="ltr" class="break-all">{{ link.host }}</bdi>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ZButton @click="open = false">
|
||||
{{ t('common.actions.cancel') }}
|
||||
</ZButton>
|
||||
<ZButton @click="copyText(link.href)">
|
||||
{{ t('plugins.frame.copyLink') }}
|
||||
</ZButton>
|
||||
<ZButton type="primary" @click="openLink">
|
||||
{{ t('plugins.frame.openLink') }}
|
||||
</ZButton>
|
||||
</template>
|
||||
</ZDialog>
|
||||
</template>
|
||||
@@ -26,7 +26,6 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
:aria-label="label"
|
||||
size="small"
|
||||
class="size-5!"
|
||||
:title="undefined"
|
||||
:aria-expanded="open"
|
||||
:aria-describedby="open ? descriptionId : undefined"
|
||||
@keydown="handleKeydown"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEventListener, useResizeObserver } from '@vueuse/core'
|
||||
import { computed, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useThemeStore } from '@/stores/modules/theme'
|
||||
import { usePluginFrameSession } from '../composables/usePluginFrameSession'
|
||||
import PluginExternalLinkDialog from './PluginExternalLinkDialog.vue'
|
||||
import PluginPageLoading from './PluginPageLoading.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -20,7 +21,7 @@ const themeStore = useThemeStore()
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('container')
|
||||
const iframeRef = useTemplateRef<HTMLIFrameElement>('iframe')
|
||||
const viewportHeight = shallowRef(0)
|
||||
const { srcdoc, loading, loadError, contentHeight, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
|
||||
const { srcdoc, loading, loadError, contentHeight, externalLink, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
|
||||
view: () => props.view,
|
||||
page: () => props.page,
|
||||
iframe: iframeRef,
|
||||
@@ -73,5 +74,6 @@ onMounted(pushLayout)
|
||||
referrerpolicy="no-referrer"
|
||||
@load="handleFrameLoad"
|
||||
/>
|
||||
<PluginExternalLinkDialog v-if="externalLink" :link="externalLink" @closed="externalLink = undefined" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,7 @@ export function usePluginFrameSession(options: {
|
||||
ZNotification.error({ message, deduplicate: true })
|
||||
})
|
||||
const contentHeight = shallowRef(0)
|
||||
const externalLink = shallowRef<URL>()
|
||||
const session = shallowRef<FrameSession>()
|
||||
let loadController: AbortController | undefined
|
||||
let previousViewportHeight = -1
|
||||
@@ -62,6 +63,7 @@ export function usePluginFrameSession(options: {
|
||||
loadController = undefined
|
||||
const current = session.value
|
||||
session.value = undefined
|
||||
externalLink.value = undefined
|
||||
srcdoc.value = ''
|
||||
contentHeight.value = 0
|
||||
previousViewportHeight = -1
|
||||
@@ -290,6 +292,20 @@ export function usePluginFrameSession(options: {
|
||||
}, '*', transfer)
|
||||
}
|
||||
|
||||
function requestExternalLink(value: unknown) {
|
||||
if (externalLink.value)
|
||||
throw new Error(t('plugins.frame.externalLinkPending'))
|
||||
// 短暂激活只能用于限制提示,打开地址仍须管理员在宿主内确认
|
||||
if (!navigator.userActivation?.isActive || document.activeElement !== options.iframe.value)
|
||||
throw new Error(t('plugins.frame.externalLinkRequiresClick'))
|
||||
const url = typeof value === 'string'
|
||||
? URL.parse(value)
|
||||
: null
|
||||
if (!url || url.protocol !== 'https:' || url.username || url.password)
|
||||
throw new Error(t('plugins.frame.invalidExternalLink'))
|
||||
externalLink.value = url
|
||||
}
|
||||
|
||||
async function handleBridgeMessage(event: MessageEvent) {
|
||||
const current = session.value
|
||||
const source = event.source
|
||||
@@ -324,7 +340,7 @@ export function usePluginFrameSession(options: {
|
||||
current.models.cancel(frame, event.data.id)
|
||||
return
|
||||
}
|
||||
if (!['request', 'callback-ticket'].includes(event.data.type))
|
||||
if (!['request', 'callback-ticket', 'open-external'].includes(event.data.type))
|
||||
return
|
||||
if (current.requestIds.has(event.data.id) || current.models.has(event.data.id)) {
|
||||
postResult(current, frame, event.data.id, false, undefined, t('plugins.frame.duplicateManagementRequest'))
|
||||
@@ -336,9 +352,16 @@ export function usePluginFrameSession(options: {
|
||||
}
|
||||
current.requestIds.add(event.data.id)
|
||||
try {
|
||||
if (event.data.type === 'request')
|
||||
if (event.data.type === 'request') {
|
||||
await handleRouteRequest(current, frame, event.data)
|
||||
else await handleCallbackTicket(current, frame, event.data)
|
||||
}
|
||||
else if (event.data.type === 'callback-ticket') {
|
||||
await handleCallbackTicket(current, frame, event.data)
|
||||
}
|
||||
else {
|
||||
requestExternalLink(event.data.payload)
|
||||
postResult(current, frame, event.data.id, true)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (session.value !== current || current.controller.signal.aborted)
|
||||
@@ -412,6 +435,7 @@ export function usePluginFrameSession(options: {
|
||||
loading,
|
||||
loadError,
|
||||
contentHeight,
|
||||
externalLink,
|
||||
frameKey,
|
||||
handleFrameLoad,
|
||||
pushLayout,
|
||||
|
||||
@@ -9,7 +9,7 @@ export const MAXIMUM_MODEL_IN_FLIGHT = 4
|
||||
export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000
|
||||
export const MODEL_RESPONSE_IDLE_MS = 30 * 1000
|
||||
|
||||
export type PluginPageMessageType = 'request' | 'callback-ticket' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel'
|
||||
export type PluginPageMessageType = 'request' | 'callback-ticket' | 'open-external' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel'
|
||||
export type PluginModelMessageType = 'models-response' | 'models-chunk' | 'models-complete' | 'models-error'
|
||||
|
||||
export interface PluginBridgeMessage {
|
||||
@@ -63,6 +63,7 @@ export interface PluginHostBridge {
|
||||
readonly theme: 'light' | 'dark'
|
||||
request: (input: PluginManagementRequest) => Promise<PluginManagementReply>
|
||||
callbackTicket: (input: { path: string, ttlSeconds: number }) => Promise<PluginCallbackTicket>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
resourceUrl: (path: string) => string
|
||||
readonly models: Readonly<{ responses: (input: PluginModelRequest) => Promise<Response> }>
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) {
|
||||
isDisposed: () => disposed,
|
||||
})
|
||||
|
||||
function invoke<T>(kind: 'request' | 'callback-ticket', payload: unknown, transfer: Transferable[] = []): Promise<T> {
|
||||
function invoke<T>(kind: 'request' | 'callback-ticket' | 'open-external', payload: unknown, transfer: Transferable[] = []): Promise<T> {
|
||||
if (disposed)
|
||||
return Promise.reject(new Error('Plugin page is closed'))
|
||||
if (pending.size >= maximumInFlight)
|
||||
@@ -254,6 +254,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) {
|
||||
get theme() { return currentTheme },
|
||||
request,
|
||||
callbackTicket,
|
||||
openExternal: url => invoke<void>('open-external', url),
|
||||
models: Object.freeze({ responses: modelBridge.responses }),
|
||||
resourceUrl(path: string) {
|
||||
const key = String(path)
|
||||
|
||||
@@ -23,7 +23,7 @@ const invalid = computed(() => props.rows.filter(row => row.error))
|
||||
<template #label-extra>
|
||||
<ZPopover class="-my-1" trigger="hover-click" placement="bottom-start">
|
||||
<template #reference="{ open }">
|
||||
<ZIconButton variant="link" :aria-label="t('proxies.batchCreate.formatDescription')" :title="undefined" :aria-expanded="open" class="size-6!">
|
||||
<ZIconButton variant="link" :aria-label="t('proxies.batchCreate.formatDescription')" :aria-expanded="open" class="size-6!">
|
||||
<CircleHelp class="size-3.5" aria-hidden="true" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -37,7 +37,7 @@ const affinityOptions = computed(() => ([
|
||||
<template #label-extra>
|
||||
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
|
||||
<template #reference="{ open }">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.affinity.accountAffinityInstructions')" :title="undefined" :aria-expanded="open" class="size-6!">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.affinity.accountAffinityInstructions')" :aria-expanded="open" class="size-6!">
|
||||
<CircleAlert class="size-3.5" aria-hidden="true" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,7 @@ const model = defineModel<string>('model', { required: true })
|
||||
{{ t('settings.warmup.title') }}
|
||||
<ZPopover trigger="hover-click" placement="top-start">
|
||||
<template #reference="{ open }">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.warmup.helpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.warmup.helpTitle')" :aria-expanded="open" class="size-6!">
|
||||
<CircleAlert class="size-3.5" aria-hidden="true" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -50,7 +50,7 @@ const queueFailureOptions = computed(() => [
|
||||
<template #label-extra>
|
||||
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
|
||||
<template #reference="{ open }">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.concurrency.autoReview.helpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.concurrency.autoReview.helpTitle')" :aria-expanded="open" class="size-6!">
|
||||
<CircleAlert class="size-3.5" aria-hidden="true" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
@@ -79,7 +79,7 @@ const queueFailureOptions = computed(() => [
|
||||
<template #label-extra>
|
||||
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
|
||||
<template #reference="{ open }">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.concurrency.retryAfter.helpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.concurrency.retryAfter.helpTitle')" :aria-expanded="open" class="size-6!">
|
||||
<CircleAlert class="size-3.5" aria-hidden="true" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
@@ -105,7 +105,7 @@ const queueFailureOptions = computed(() => [
|
||||
<template #label-extra>
|
||||
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
|
||||
<template #reference="{ open }">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.concurrency.queueFailureHelpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.concurrency.queueFailureHelpTitle')" :aria-expanded="open" class="size-6!">
|
||||
<CircleAlert class="size-3.5" aria-hidden="true" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -112,7 +112,7 @@ function confirm() {
|
||||
{{ field.label }}
|
||||
<ZPopover trigger="hover-click" placement="top-start">
|
||||
<template #reference="{ open: helpOpen }">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.scheduling.about', { label: field.label })" :title="undefined" :aria-expanded="helpOpen" class="size-6!">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.scheduling.about', { label: field.label })" :aria-expanded="helpOpen" class="size-6!">
|
||||
<CircleHelp class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -138,7 +138,7 @@ function canDelete(record: BackupRecord): boolean {
|
||||
<span>{{ t('settings.backup.backupRecords') }}</span>
|
||||
<ZPopover trigger="hover-click" placement="bottom-start">
|
||||
<template #reference="{ open }">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.backup.backupScopeDescription')" :title="undefined" :aria-expanded="open" class="size-6!">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.backup.backupScopeDescription')" :aria-expanded="open" class="size-6!">
|
||||
<CircleHelp class="size-3.5" aria-hidden="true" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ const minCodexCliVersion = defineModel<string>('minCodexCliVersion', { required:
|
||||
<ZIconButton
|
||||
variant="link"
|
||||
:aria-label="t('settings.clientVersion.openGuide')"
|
||||
:title="undefined"
|
||||
class="size-6!"
|
||||
@click="emit('help')"
|
||||
>
|
||||
|
||||
@@ -103,7 +103,6 @@ function removeRule(id: string) {
|
||||
<template #reference="{ open }">
|
||||
<ZIconButton
|
||||
variant="link"
|
||||
:title="undefined"
|
||||
size="small"
|
||||
:aria-label="t('settings.errorProtection.matchingHelpTitle')"
|
||||
:aria-expanded="open"
|
||||
|
||||
@@ -113,7 +113,7 @@ function remove(rule: PrivacyRule) {
|
||||
<ZSelect v-model="model.onError" size="small" class="w-34" :options="failureOptions" :aria-label="t('settings.privacy.ruleExecutionFailed')" :disabled="disabled" />
|
||||
<ZPopover trigger="hover-click" placement="top-end">
|
||||
<template #reference="{ open: helpOpen }">
|
||||
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.ruleExecutionInstructions')" :title="undefined" :aria-expanded="helpOpen">
|
||||
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.ruleExecutionInstructions')" :aria-expanded="helpOpen">
|
||||
<CircleHelp class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -62,7 +62,7 @@ watch(() => step.value.action, (action) => {
|
||||
<template #label-extra>
|
||||
<ZPopover trigger="hover-click">
|
||||
<template #reference="{ open: helpOpen }">
|
||||
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.fieldPathSyntax')" :title="undefined" class="-my-2" :aria-expanded="helpOpen">
|
||||
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.fieldPathSyntax')" class="-my-2" :aria-expanded="helpOpen">
|
||||
<CircleHelp class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
@@ -84,7 +84,7 @@ watch(() => step.value.action, (action) => {
|
||||
<template #label-extra>
|
||||
<ZPopover trigger="hover-click">
|
||||
<template #reference="{ open: helpOpen }">
|
||||
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.regularSyntaxDescription')" :title="undefined" class="-my-2" :aria-expanded="helpOpen">
|
||||
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.regularSyntaxDescription')" class="-my-2" :aria-expanded="helpOpen">
|
||||
<CircleHelp class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -114,7 +114,7 @@ function moveWithKeyboard(index: number, offset: -1 | 1) {
|
||||
</h3>
|
||||
<ZPopover trigger="hover-click">
|
||||
<template #reference="{ open: helpOpen }">
|
||||
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.helpTitle')" :title="undefined" class="size-5!" :aria-expanded="helpOpen">
|
||||
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.helpTitle')" class="size-5!" :aria-expanded="helpOpen">
|
||||
<CircleHelp class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -76,7 +76,7 @@ const providerOptions = computed(() => props.providers.map(value => ({
|
||||
<div class="flex items-center gap-1">
|
||||
<ZPopover v-model="syncInfoOpen" placement="bottom-end" trigger="hover-click">
|
||||
<template #reference>
|
||||
<ZIconButton variant="link" :title="undefined" :aria-label="t('settings.pricing.synchronizationInstructions')" size="small" :aria-expanded="syncInfoOpen" :aria-controls="syncInfoOpen ? syncInfoId : undefined">
|
||||
<ZIconButton variant="link" :aria-label="t('settings.pricing.synchronizationInstructions')" size="small" :aria-expanded="syncInfoOpen" :aria-controls="syncInfoOpen ? syncInfoId : undefined">
|
||||
<CircleAlert class="size-3.5" />
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +18,6 @@ const infoId = useId()
|
||||
variant="link"
|
||||
size="small"
|
||||
:aria-label="t('settings.pricing.unitHelp')"
|
||||
:title="undefined"
|
||||
:aria-expanded="infoOpen"
|
||||
:aria-controls="infoOpen ? infoId : undefined"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { PricingRow } from './model'
|
||||
|
||||
import type { ModelPricing, PricingChange } from '@/api'
|
||||
import { ZConfirmDialog, ZPagination } from '@codex-proxy/ui'
|
||||
import { ZCard, ZConfirmDialog, ZPagination } from '@codex-proxy/ui'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PricingBatchModal from './PricingBatchModal.vue'
|
||||
@@ -52,7 +52,7 @@ function requestDelete(row: PricingRow) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex min-h-0 min-w-0 flex-col rounded-z-card bg-z-bg-container p-3 shadow-z-card sm:p-4" :aria-label="t('settings.page.tabs.pricing')">
|
||||
<ZCard padding="none" class="flex min-h-0 min-w-0 flex-col p-3 sm:p-4" :aria-label="t('settings.page.tabs.pricing')">
|
||||
<PricingToolbar
|
||||
v-model:search="search"
|
||||
v-model:provider="provider"
|
||||
@@ -77,5 +77,5 @@ function requestDelete(row: PricingRow) {
|
||||
</ZConfirmDialog>
|
||||
<PricingBatchModal v-model="batchOpen" :rows="targets" :reset="batchReset" :saving="saving" @confirm="saveBatch" />
|
||||
<PricingSyncModal v-model="syncOpen" :preview="preview" :catalog="syncCatalog" :saving="saving" @confirm="confirmSync" />
|
||||
</section>
|
||||
</ZCard>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZButton, ZInput, ZPageHeader, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
|
||||
import { ZButton, ZCard, ZInput, ZPageHeader, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
|
||||
import { Check, Eye, Pencil, RotateCcw, Save, Search, Undo2 } from '@lucide/vue'
|
||||
|
||||
import { computed, shallowRef } from 'vue'
|
||||
@@ -28,7 +28,9 @@ const {
|
||||
previewStyle,
|
||||
modificationCount,
|
||||
dirty,
|
||||
selectPreset,
|
||||
selectMaterial,
|
||||
applyProfile,
|
||||
resetPrimaryColor,
|
||||
setPrimaryColor,
|
||||
setMode,
|
||||
setSeedColor,
|
||||
@@ -54,13 +56,6 @@ const scopeOptions = computed(() => ([
|
||||
},
|
||||
]))
|
||||
|
||||
function switchScope(value: string) {
|
||||
scope.value = value === 'component'
|
||||
? 'component'
|
||||
: 'global'
|
||||
mobilePreviewOpen.value = false
|
||||
}
|
||||
|
||||
function saveTheme(event: MouseEvent) {
|
||||
if (!save(event)) {
|
||||
ZNotification.error({
|
||||
@@ -92,14 +87,7 @@ function saveTheme(event: MouseEvent) {
|
||||
|
||||
<template #actions>
|
||||
<div class="flex max-w-[100cqw] flex-wrap items-center justify-end gap-2">
|
||||
<ZSegmented
|
||||
:model-value="scope"
|
||||
:aria-label="t('appearance.settings.themeEditingLevel')"
|
||||
:options="scopeOptions"
|
||||
size="small"
|
||||
@update:model-value="switchScope"
|
||||
/>
|
||||
<ZButton size="small" class="min-[768px]:hidden" @click="mobilePreviewOpen = !mobilePreviewOpen">
|
||||
<ZButton size="small" class="md:hidden" @click="mobilePreviewOpen = !mobilePreviewOpen">
|
||||
<template #icon>
|
||||
<Pencil v-if="mobilePreviewOpen" class="size-3.5" />
|
||||
<Eye v-else class="size-3.5" />
|
||||
@@ -130,30 +118,38 @@ function saveTheme(event: MouseEvent) {
|
||||
</ZPageHeader>
|
||||
|
||||
<section
|
||||
class="theme-workbench isolate mt-5 overflow-hidden rounded-z-card bg-z-bg-layout shadow-z-card xl:flex xl:min-h-0 xl:flex-1 xl:flex-col"
|
||||
class="theme-workbench isolate mt-5 overflow-hidden xl:flex xl:min-h-0 xl:flex-1 xl:flex-col"
|
||||
:aria-label="t('appearance.settings.themeEditor')"
|
||||
>
|
||||
<div
|
||||
class="theme-workbench-body grid min-h-180 gap-3 xl:min-h-0 xl:flex-1 xl:overflow-hidden xl:grid-cols-[370px_minmax(0,1fr)]"
|
||||
>
|
||||
<aside
|
||||
class="h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3 overflow-hidden rounded-z-lg bg-z-bg-container pt-3 pr-0 pb-3 pl-3 shadow-z-secondary min-[768px]:grid"
|
||||
<ZCard
|
||||
as="aside"
|
||||
padding="none"
|
||||
class="h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3 py-3 md:grid"
|
||||
:class="mobilePreviewOpen ? 'hidden' : 'grid'"
|
||||
:aria-label="t('appearance.settings.themeTokenEditingPanel')"
|
||||
>
|
||||
<ZInput
|
||||
v-model="query"
|
||||
class="pr-3"
|
||||
:aria-label="t('appearance.settings.searchToken')"
|
||||
:placeholder="t('appearance.settings.searchPlaceholder')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4" />
|
||||
</template>
|
||||
</ZInput>
|
||||
<div class="grid gap-3 px-3">
|
||||
<ZSegmented
|
||||
v-model="scope"
|
||||
:aria-label="t('appearance.settings.themeEditingLevel')"
|
||||
:options="scopeOptions"
|
||||
/>
|
||||
<ZInput
|
||||
v-model="query"
|
||||
:aria-label="t('appearance.settings.searchToken')"
|
||||
:placeholder="t('appearance.settings.searchPlaceholder')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4" />
|
||||
</template>
|
||||
</ZInput>
|
||||
</div>
|
||||
|
||||
<ZScrollbar class="h-full min-h-0">
|
||||
<div class="pr-4">
|
||||
<div class="px-3">
|
||||
<ThemeGlobalTokenPanel
|
||||
v-if="scope === 'global'"
|
||||
v-model:category="globalCategory"
|
||||
@@ -161,7 +157,9 @@ function saveTheme(event: MouseEvent) {
|
||||
:resolved="resolvedPreview"
|
||||
:query="query"
|
||||
@mode="setMode"
|
||||
@preset="selectPreset"
|
||||
@material="selectMaterial"
|
||||
@profile="applyProfile"
|
||||
@reset-primary="resetPrimaryColor"
|
||||
@primary="setPrimaryColor"
|
||||
@seed-color="setSeedColor"
|
||||
@seed-number="setSeedNumber"
|
||||
@@ -180,9 +178,9 @@ function saveTheme(event: MouseEvent) {
|
||||
/>
|
||||
</div>
|
||||
</ZScrollbar>
|
||||
</aside>
|
||||
</ZCard>
|
||||
|
||||
<div class="min-h-0 min-[768px]:block" :class="mobilePreviewOpen ? 'block' : 'hidden'">
|
||||
<div class="min-h-0 md:block" :class="mobilePreviewOpen ? 'block' : 'hidden'">
|
||||
<ThemeEditorPreview v-model="preview" class="h-full" :theme="previewTheme" :style="previewStyle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,12 +374,6 @@ function numericTokenValue(name: ThemeTokenName) {
|
||||
</section>
|
||||
|
||||
<section class="grid gap-2" :aria-label="`${activeComponent.label} Component Token`">
|
||||
<div class="rounded-z-lg bg-z-bg-container px-3 py-3 shadow-z-tertiary">
|
||||
<p class="m-0 text-[10px] leading-normal font-emphasis text-z-text-secondary">
|
||||
{{ t('appearance.componentTokens.description', { label: activeComponent.label }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-for="token in visibleTokens" :key="token.name">
|
||||
<ThemeColorTokenField
|
||||
v-if="token.kind === 'color'"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ThemeScheme } from '@codex-proxy/ui/theme'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
import type { ThemeEditorPreview } from '../composables/useThemeEditor'
|
||||
import { ZSegmented } from '@codex-proxy/ui'
|
||||
import { ZCard, ZSegmented } from '@codex-proxy/ui'
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, shallowRef } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -67,13 +67,13 @@ const previewOptions = computed(() => ([
|
||||
|
||||
<template>
|
||||
<section class="grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3 overflow-hidden" :aria-label="t('appearance.preview.themeRealTimePreview')">
|
||||
<header class="shrink-0 flex flex-wrap items-center justify-between gap-3 rounded-z-lg bg-z-bg-container px-3 py-2.5 shadow-z-tertiary">
|
||||
<ZCard as="header" padding="none" class="shrink-0 flex flex-wrap items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div>
|
||||
<strong class="block text-z-sm font-heavy text-z-text">{{ t('appearance.preview.livePreview') }}</strong>
|
||||
<span class="mt-0.5 block text-[9px] font-emphasis text-z-text-quaternary">{{ t('appearance.preview.description') }}</span>
|
||||
</div>
|
||||
<ZSegmented v-model="preview" :aria-label="t('appearance.preview.previewType')" size="small" :options="previewOptions" />
|
||||
</header>
|
||||
</ZCard>
|
||||
|
||||
<ThemePreviewCanvas
|
||||
class="h-full min-h-0"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { Theme, ThemeColorPreset, ThemeColorPresetId, ThemeMode, ThemeSeedOverrides } from '@codex-proxy/ui/theme'
|
||||
import type { Theme, ThemeColorPresetId, ThemeMode, ThemePresetId, ThemeSeedOverrides } from '@codex-proxy/ui/theme'
|
||||
|
||||
import type { ThemeEditorDraft, ThemeEditorGlobalCategory } from '../composables/useThemeEditor'
|
||||
import { ZSegmented } from '@codex-proxy/ui'
|
||||
|
||||
import { createTheme, themeColorPresets } from '@codex-proxy/ui/theme'
|
||||
import { Check, ChevronDown, Laptop, Moon, Sun } from '@lucide/vue'
|
||||
import { createThemeSeedTokens, themeColorPresets, themePresets, themeProfiles } from '@codex-proxy/ui/theme'
|
||||
import { Check, ChevronDown, Laptop, Moon, PanelsTopLeft, Sparkles, Sun } from '@lucide/vue'
|
||||
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { uiLocale } from '@/i18n/ui'
|
||||
|
||||
import sceneArtwork from './theme-scenes.svg?no-inline'
|
||||
import ThemeColorTokenField from './ThemeColorTokenField.vue'
|
||||
import ThemeNumberTokenField from './ThemeNumberTokenField.vue'
|
||||
|
||||
@@ -22,7 +24,9 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
mode: [value: ThemeMode]
|
||||
preset: [value: ThemeColorPresetId]
|
||||
material: [value: ThemePresetId]
|
||||
resetPrimary: []
|
||||
profile: [value: ThemeColorPresetId]
|
||||
primary: [value: string]
|
||||
seedColor: [
|
||||
key: Exclude<
|
||||
@@ -43,11 +47,41 @@ const { t } = useI18n({ useScope: 'global' })
|
||||
const category = defineModel<ThemeEditorGlobalCategory>('category', { required: true })
|
||||
const derivedOpen = shallowRef(false)
|
||||
|
||||
const localizedPresets = computed(() => themeColorPresets.map(preset => ({
|
||||
...preset,
|
||||
...uiLocale.value.theme[preset.id],
|
||||
const profiles = themeColorPresets.map((preset) => {
|
||||
const options = { ...themeProfiles[preset.id], colorId: preset.id }
|
||||
const light = createThemeSeedTokens({ ...options, scheme: 'light' })
|
||||
return {
|
||||
...preset,
|
||||
paper: light.colorBgBase,
|
||||
accent: light.colorWarning,
|
||||
ink: createThemeSeedTokens({ ...options, scheme: 'dark' }).colorBgBase,
|
||||
}
|
||||
})
|
||||
const localizedPresets = computed(() => profiles.map(p => ({
|
||||
...p,
|
||||
...uiLocale.value.theme[p.id],
|
||||
})))
|
||||
const activeProfile = computed(() => {
|
||||
const { color, preset, customization } = props.draft
|
||||
if (color !== 'custom' && themeProfiles[color].preset === preset && isEqual(themeProfiles[color].customization, customization))
|
||||
return color
|
||||
return undefined
|
||||
})
|
||||
const colorPresets = themeColorPresets.map(preset => preset.seed)
|
||||
const themePresetDetails = computed(() => ({
|
||||
default: {
|
||||
label: t('appearance.themePresets.default.label'),
|
||||
icon: PanelsTopLeft,
|
||||
},
|
||||
soft: {
|
||||
label: t('appearance.themePresets.soft.label'),
|
||||
icon: Sparkles,
|
||||
},
|
||||
}))
|
||||
const themePresetOptions = computed(() => themePresets.map(preset => ({
|
||||
...themePresetDetails.value[preset.id],
|
||||
value: preset.id,
|
||||
})))
|
||||
const categoryOptions = computed(() => ([
|
||||
{
|
||||
label: t('common.fields.color'),
|
||||
@@ -79,17 +113,6 @@ const modeOptions = computed(() => ([
|
||||
icon: Moon,
|
||||
},
|
||||
]))
|
||||
const presetSwatchesById = new Map(
|
||||
themeColorPresets.map(preset => [
|
||||
preset.id,
|
||||
[
|
||||
createTheme({ scheme: 'light', colorId: preset.id }).seedTokens.colorBgBase,
|
||||
preset.seed,
|
||||
createTheme({ scheme: 'dark', colorId: preset.id }).seedTokens.colorBgBase,
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
const semanticColorFields = computed(() =>
|
||||
[
|
||||
{
|
||||
@@ -158,10 +181,6 @@ function matches(value: string) {
|
||||
function seedOverridden(key: keyof ThemeSeedOverrides) {
|
||||
return Object.hasOwn(props.draft.customization.seed ?? {}, key)
|
||||
}
|
||||
|
||||
function presetSwatches(preset: ThemeColorPreset) {
|
||||
return presetSwatchesById.get(preset.id) ?? [preset.seed]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -177,50 +196,64 @@ function presetSwatches(preset: ThemeColorPreset) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section v-if="matches(t('appearance.themePresets.title')) || localizedPresets.some(p => matches(p.label))" class="grid gap-2">
|
||||
<h3 class="m-0 px-1 text-z font-heavy text-z-text">
|
||||
{{ t('appearance.themePresets.title') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-2" role="radiogroup" :aria-label="t('appearance.themePresets.title')">
|
||||
<button
|
||||
v-for="preset in localizedPresets"
|
||||
:key="preset.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="relative flex min-h-20 flex-col items-stretch justify-between gap-2 rounded-z-lg bg-z-fill-quaternary p-2.5 text-left outline-none transition-[background-color,box-shadow] duration-150 hover:bg-z-bg-text-hover focus-visible:ring-2 focus-visible:ring-z-control-outline"
|
||||
:class="activeProfile === preset.id ? 'bg-z-control-item-bg-active shadow-z-tertiary' : undefined"
|
||||
:aria-checked="activeProfile === preset.id"
|
||||
:aria-label="`${preset.label}: ${preset.description}`"
|
||||
:title="preset.description"
|
||||
@click="emit('profile', preset.id)"
|
||||
>
|
||||
<svg
|
||||
class="block h-11 w-full overflow-hidden rounded-z-sm"
|
||||
:style="{ 'color': preset.seed, '--scene-paper': preset.paper, '--scene-ink': preset.ink, '--scene-accent': preset.accent }"
|
||||
viewBox="0 0 160 44"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use :href="`${sceneArtwork}#${preset.id}`" width="160" height="44" />
|
||||
</svg>
|
||||
<span class="min-w-0 pr-4">
|
||||
<strong class="block text-z-xs font-heavy text-z-text">{{ preset.label }}</strong>
|
||||
<span class="mt-0.5 block truncate text-[9px] font-emphasis text-z-text-quaternary">
|
||||
{{ preset.description }}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
v-if="activeProfile === preset.id"
|
||||
class="absolute right-2 bottom-2 size-3 text-z-primary-text"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="matches(t('appearance.themePresets.material'))" class="grid gap-2">
|
||||
<h3 class="m-0 px-1 text-z font-heavy text-z-text">
|
||||
{{ t('appearance.themePresets.material') }}
|
||||
</h3>
|
||||
<ZSegmented
|
||||
:model-value="draft.preset"
|
||||
:aria-label="t('appearance.themePresets.material')"
|
||||
:options="themePresetOptions"
|
||||
class="w-full"
|
||||
@update:model-value="emit('material', $event as ThemePresetId)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ZSegmented v-model="category" :aria-label="t('appearance.globalTokens.globalTokenClassification')" :options="categoryOptions" class="w-full" />
|
||||
|
||||
<template v-if="category === 'color'">
|
||||
<section v-if="matches(t('appearance.globalTokens.brandColorLabel'))" class="grid gap-2" aria-labelledby="theme-preset-title">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<h3 id="theme-preset-title" class="m-0 text-z font-heavy text-z-text">
|
||||
{{ t('appearance.globalTokens.brandColor') }}
|
||||
</h3>
|
||||
<code class="font-mono text-[10px] text-z-text-quaternary">colorPrimary</code>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2" role="radiogroup" :aria-label="t('appearance.globalTokens.presetThemes')">
|
||||
<button
|
||||
v-for="preset in localizedPresets"
|
||||
:key="preset.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="relative flex min-h-20 flex-col items-stretch justify-between gap-2 rounded-z-lg bg-z-fill-quaternary p-2.5 text-left outline-none transition-[background-color,box-shadow] duration-150 hover:bg-z-bg-text-hover focus-visible:ring-2 focus-visible:ring-z-control-outline"
|
||||
:class="draft.color === preset.id ? 'bg-z-control-item-bg-active shadow-z-tertiary' : undefined"
|
||||
:aria-checked="draft.color === preset.id"
|
||||
:aria-label="`${preset.label}: ${preset.description}`"
|
||||
:title="preset.description"
|
||||
@click="emit('preset', preset.id)"
|
||||
>
|
||||
<span class="flex h-4 overflow-hidden rounded-full shadow-z-tertiary">
|
||||
<span
|
||||
v-for="swatch in presetSwatches(preset)"
|
||||
:key="swatch"
|
||||
class="flex-1"
|
||||
:style="{ backgroundColor: swatch }"
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 pr-4">
|
||||
<strong class="block text-z-xs font-heavy text-z-text">{{ preset.label }}</strong>
|
||||
<span class="mt-0.5 block truncate text-[9px] font-emphasis text-z-text-quaternary">
|
||||
{{ preset.description }}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
v-if="draft.color === preset.id"
|
||||
class="absolute right-2 bottom-2 size-3 text-z-primary-text"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<section v-if="matches(t('appearance.globalTokens.brandColorLabel'))" class="grid gap-2">
|
||||
<ThemeColorTokenField
|
||||
:label="t('appearance.globalTokens.brandMainColor')"
|
||||
token="colorPrimary"
|
||||
@@ -229,7 +262,7 @@ function presetSwatches(preset: ThemeColorPreset) {
|
||||
:presets="colorPresets"
|
||||
:overridden="draft.color === 'custom'"
|
||||
@change="emit('primary', $event)"
|
||||
@reset="emit('preset', 'relay-blue')"
|
||||
@reset="emit('resetPrimary')"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -323,11 +356,6 @@ function presetSwatches(preset: ThemeColorPreset) {
|
||||
</section>
|
||||
|
||||
<section v-else class="grid gap-2" :aria-label="t('appearance.globalTokens.styleToken')">
|
||||
<div
|
||||
class="rounded-z-lg bg-z-primary-container px-3 py-3 text-[10px] leading-normal font-emphasis text-z-primary-on-container"
|
||||
>
|
||||
{{ t('appearance.globalTokens.surfaceDescription') }}
|
||||
</div>
|
||||
<ThemeNumberTokenField
|
||||
:label="t('appearance.globalTokens.universalFillet')"
|
||||
token="borderRadius"
|
||||
|
||||
@@ -45,7 +45,7 @@ const boardStyle = computed<CSSProperties>(() => ({
|
||||
<template>
|
||||
<div
|
||||
ref="viewport"
|
||||
class="@container/preview relative min-h-0 overflow-hidden rounded-z-lg bg-z-bg-layout bg-[radial-gradient(circle,color-mix(in_srgb,var(--z-color-text-quaternary)_24%,transparent)_1px,transparent_1px)] bg-size-[18px_18px] shadow-z-secondary select-none"
|
||||
class="@container/preview relative min-h-0 overflow-hidden rounded-z-lg bg-z-bg-layout bg-[radial-gradient(circle,color-mix(in_srgb,var(--z-color-text-quaternary)_24%,transparent)_1px,transparent_1px)] bg-size-[18px_18px] select-none"
|
||||
:class="dragging ? 'cursor-grabbing' : 'cursor-grab'"
|
||||
role="application"
|
||||
tabindex="0"
|
||||
@@ -85,7 +85,7 @@ const boardStyle = computed<CSSProperties>(() => ({
|
||||
<ZButton
|
||||
size="small" variant="text"
|
||||
class="min-w-14 px-2! font-mono tabular-nums"
|
||||
:title="t('appearance.preview.canvas.recovery')"
|
||||
:aria-label="t('appearance.preview.canvas.recovery')"
|
||||
@click="resetScale"
|
||||
>
|
||||
{{ scalePercent }}%
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="relay-blue" viewBox="0 0 160 44" preserveAspectRatio="xMidYMid slice">
|
||||
<path fill="var(--scene-ink)" d="M0 0h160v44H0z"/>
|
||||
<path fill="currentColor" opacity=".3" d="M0 34q25-4 56-1t58-1q25-2 46 0v12H0z"/>
|
||||
<path fill="none" stroke="currentColor" opacity=".7" d="m14 16 12-8 13 6 15-7"/>
|
||||
<g fill="var(--scene-paper)">
|
||||
<circle cx="14" cy="16" r="1.1"/><circle cx="26" cy="8" r="1.6"/><circle cx="39" cy="14" r="1.2"/>
|
||||
<path d="m54 3 1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/>
|
||||
<circle cx="85" cy="11" r=".8"/><circle cx="142" cy="15" r="1"/>
|
||||
<path d="M107 16v13h-8z"/>
|
||||
</g>
|
||||
<path fill="currentColor" d="m109 20 5 9h-5zm-13 11h21l-4 3h-12z"/>
|
||||
<path fill="none" stroke="currentColor" opacity=".65" stroke-linecap="round" d="m85 36 14-.5m5 3 6-.5"/>
|
||||
</symbol>
|
||||
<symbol id="deep-teal" viewBox="0 0 160 44" preserveAspectRatio="xMidYMid slice">
|
||||
<path fill="var(--scene-paper)" d="M0 0h160v44H0z"/>
|
||||
<g fill="currentColor">
|
||||
<path opacity=".18" d="m0 18 12-5 9 1 15-9 7 2 6-3 14 12 8-3 13 6 16-9 8 2 12-6 17 12 9-3 14 4v5H0z"/>
|
||||
<path opacity=".38" d="m0 22 16-6 7 1 10-8 4 2 4-3 10 9 9 2 8 5-33-2-16 3-19 1z"/>
|
||||
<path opacity=".61" d="m0 26 9-7 4 1 8-9 4 2 6-6 3 6 7 3 2 3 9 3-17-2-14 4z"/>
|
||||
</g>
|
||||
<path fill="var(--scene-ink)" d="m0 32 10-3 9 1 8-3 10 1 11 4 14 2-25 1-17-2-20 4z"/>
|
||||
<g fill="none" stroke="var(--scene-ink)" stroke-linecap="round">
|
||||
<path stroke-width="1.1" d="m21 29-1-12m1 8-4-4m3 1 4-5m-8 12-2-8m2 5 2-4"/>
|
||||
<path opacity=".36" d="m3 39 26-2m10 0 8 .4m58-3 15 .2"/>
|
||||
</g>
|
||||
<path fill="var(--scene-ink)" d="M100 30q5 2 16 0l-3 2h-9z"/>
|
||||
<path fill="none" stroke="var(--scene-ink)" stroke-linecap="round" d="m110 30-1-2-2 2m2-1 6 4"/>
|
||||
</symbol>
|
||||
<symbol id="signal-violet" viewBox="0 0 160 44" preserveAspectRatio="xMidYMid slice">
|
||||
<path fill="var(--scene-paper)" d="M0 0h160v44H0z"/>
|
||||
<path fill="currentColor" d="M0 5l57 10v29H0zM74 18l26 5v21H74zM119 24l13 2v18h-13z"/>
|
||||
<path fill="var(--scene-ink)" opacity=".57" d="M57 15l10-4v33H57zM100 23l5-2v23h-5zM132 26l4-1v19h-4z"/>
|
||||
<path fill="currentColor" opacity=".28" d="M0 44l160-13v13z"/>
|
||||
<circle cx="143" cy="10" r="4" fill="currentColor"/>
|
||||
</symbol>
|
||||
<symbol id="graphite" viewBox="0 0 160 44" preserveAspectRatio="xMidYMid slice">
|
||||
<path fill="var(--scene-paper)" d="M0 0h160v44H0z"/>
|
||||
<path fill="currentColor" d="M0 33h160v11H0zM26 21h5v16h-5zm21 0h5v16h-5zm22 0h5v16h-5zm23 0h5v16h-5zm22 0h5v16h-5zm20 0h5v16h-5z"/>
|
||||
<path fill="var(--scene-ink)" d="M22 18h120l-5 5H28z"/>
|
||||
<path fill="var(--scene-accent)" d="M12 14q12 6 28-1 8-3 15-11h55q7 8 15 11 14 6 27 1-4 7-19 7H32q-15 0-20-7z"/>
|
||||
<path fill="none" stroke="var(--scene-ink)" stroke-width="1.4" d="M12 15q8 7 24 6h94q15 1 22-6M54 2h57"/>
|
||||
<path fill="none" stroke="var(--scene-paper)" opacity=".5" d="m56 5-10 10m20-10-5 10m15-10-2 10m13-10 1 10m10-10 4 10m6-10 8 10"/>
|
||||
<path fill="var(--scene-accent)" d="M0 33h160v2H0z"/>
|
||||
<path fill="var(--scene-ink)" opacity=".25" d="M0 40h160v4H0z"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -4,6 +4,7 @@ import type {
|
||||
ThemeComponentOverrides,
|
||||
ThemeCustomization,
|
||||
ThemeMode,
|
||||
ThemePresetId,
|
||||
ThemeScheme,
|
||||
ThemeSeedOverrides,
|
||||
ThemeTokenName,
|
||||
@@ -15,10 +16,13 @@ import {
|
||||
createThemeVars,
|
||||
defaultThemeColorId,
|
||||
defaultThemeMode,
|
||||
defaultThemePresetId,
|
||||
defaultThemePrimaryColor,
|
||||
isThemeColorId,
|
||||
isThemeMode,
|
||||
isThemePresetId,
|
||||
normalizeThemeCustomization,
|
||||
themeProfiles,
|
||||
} from '@codex-proxy/ui/theme'
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -37,6 +41,7 @@ export type ThemeEditorComponent
|
||||
| 'layout'
|
||||
|
||||
export interface ThemeEditorDraft {
|
||||
preset: ThemePresetId
|
||||
mode: ThemeMode
|
||||
color: ThemeColorId
|
||||
customColor: string
|
||||
@@ -51,6 +56,7 @@ export function useThemeEditor() {
|
||||
const themeStore = useThemeStore()
|
||||
const {
|
||||
themeMode,
|
||||
themePreset,
|
||||
themeColor,
|
||||
customThemeColor,
|
||||
themeCustomization,
|
||||
@@ -68,6 +74,7 @@ export function useThemeEditor() {
|
||||
|
||||
const resolvedPreview = computed(() => createTheme({
|
||||
scheme: previewTheme.value,
|
||||
preset: draft.value.preset,
|
||||
colorId: draft.value.color,
|
||||
primaryColor: draft.value.customColor,
|
||||
customization: draft.value.customization,
|
||||
@@ -82,6 +89,7 @@ export function useThemeEditor() {
|
||||
watch(
|
||||
[
|
||||
themeMode,
|
||||
themePreset,
|
||||
themeColor,
|
||||
customThemeColor,
|
||||
themeCustomization,
|
||||
@@ -104,6 +112,7 @@ export function useThemeEditor() {
|
||||
|
||||
function createStoreDraft(): ThemeEditorDraft {
|
||||
return {
|
||||
preset: themePreset.value,
|
||||
mode: themeMode.value,
|
||||
color: themeColor.value,
|
||||
customColor: customThemeColor.value,
|
||||
@@ -111,13 +120,25 @@ export function useThemeEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreset(color: ThemeColorPresetId) {
|
||||
function selectMaterial(preset: ThemePresetId) {
|
||||
draft.value = { ...draft.value, preset }
|
||||
}
|
||||
|
||||
function applyProfile(color: ThemeColorPresetId) {
|
||||
const { preset, customization } = themeProfiles[color]
|
||||
draft.value = {
|
||||
...draft.value,
|
||||
color,
|
||||
preset,
|
||||
customColor: defaultThemePrimaryColor,
|
||||
customization: normalizeThemeCustomization(customization),
|
||||
}
|
||||
}
|
||||
|
||||
function resetPrimaryColor() {
|
||||
draft.value = { ...draft.value, color: defaultThemeColorId, customColor: defaultThemePrimaryColor }
|
||||
}
|
||||
|
||||
function setPrimaryColor(color: string) {
|
||||
const normalized = normalizeHexColor(color)
|
||||
if (!normalized)
|
||||
@@ -217,6 +238,7 @@ export function useThemeEditor() {
|
||||
|
||||
function restoreDefaults() {
|
||||
draft.value = {
|
||||
preset: defaultThemePresetId,
|
||||
mode: defaultThemeMode,
|
||||
color: defaultThemeColorId,
|
||||
customColor: defaultThemePrimaryColor,
|
||||
@@ -272,7 +294,9 @@ export function useThemeEditor() {
|
||||
previewStyle,
|
||||
modificationCount,
|
||||
dirty,
|
||||
selectPreset,
|
||||
selectMaterial,
|
||||
applyProfile,
|
||||
resetPrimaryColor,
|
||||
setPrimaryColor,
|
||||
setMode,
|
||||
setSeedColor,
|
||||
@@ -290,9 +314,10 @@ export function useThemeEditor() {
|
||||
|
||||
function normalizeDraft(value: ThemeEditorDraft): ThemeEditorDraft | null {
|
||||
const customColor = normalizeHexColor(value.customColor)
|
||||
if (!isThemeMode(value.mode) || !isThemeColorId(value.color) || !customColor)
|
||||
if (!isThemePresetId(value.preset) || !isThemeMode(value.mode) || !isThemeColorId(value.color) || !customColor)
|
||||
return null
|
||||
return {
|
||||
preset: value.preset,
|
||||
mode: value.mode,
|
||||
color: value.color,
|
||||
customColor,
|
||||
@@ -319,6 +344,8 @@ function cloneDraft(value: ThemeEditorDraft): ThemeEditorDraft {
|
||||
|
||||
function countDraftChanges(saved: ThemeEditorDraft, draft: ThemeEditorDraft) {
|
||||
let count = 0
|
||||
if (saved.preset !== draft.preset)
|
||||
count += 1
|
||||
if (saved.mode !== draft.mode)
|
||||
count += 1
|
||||
if (saved.color !== draft.color || saved.customColor !== draft.customColor)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user