From 2bef63b0b9c0ffb9bc7c018edaf24b846ac78b29 Mon Sep 17 00:00:00 2001 From: Gaoshou101 <188431084+Gaoshou101@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:06:46 +0800 Subject: [PATCH 1/4] =?UTF-8?q?build:=20Linux=20=E5=8C=85=E6=94=B9?= =?UTF-8?q?=E5=9C=A8=20manylinux=5F2=5F28=20=E6=9E=84=E5=BB=BA=EF=BC=8Cgli?= =?UTF-8?q?bc=20=E8=A6=81=E6=B1=82=E9=99=8D=E8=87=B3=202.28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - linux-amd64/arm64 在固定摘要的 manylinux_2_28 容器中用 runner 的官方 Go 工具链构建;objdump 校验符号版本不超过 2.28。 - 新增 glibc 2.28 用户态内的 dlopen 与 CPA v8.0.23 e2e、count_tokens、 思考后缀联测。 - package.py 按 GLIBC_FLOOR 记录 glibc_minimum,另存 glibc_symbol_version_max; checksums.py 增加 --max-glibc 发布门槛,并补充单元测试。 --- .github/workflows/build.yml | 54 ++++++++++++++++++++++++++-- CHANGELOG.md | 6 ++++ scripts/checksums.py | 9 +++++ scripts/package.py | 12 ++++++- tests/test_cpamp_panel_validation.py | 2 ++ 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d5c6abb..6fd6c21 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,12 +92,18 @@ jobs: fail-fast: false matrix: include: + # Linux libraries are built in manylinux_2_28 so they load on glibc 2.28+ hosts, not only + # on the runner's glibc. Pinned by digest; bump deliberately. - runner: ubuntu-24.04 target: linux-amd64 extension: so + manylinux: quay.io/pypa/manylinux_2_28_x86_64:2026.11.06-1@sha256:ae830e2ecbf4568743f8b760920cd7483538375b6b242a9fb02b5b2ad0245f68 + cpa-archive: CLIProxyAPI_8.0.23_linux_amd64.tar.gz - runner: ubuntu-24.04-arm target: linux-arm64 extension: so + manylinux: quay.io/pypa/manylinux_2_28_aarch64:2026.11.06-1@sha256:6c4f9b6d9453bb47b21b4c1e4f04f3c662584f60f4ed8455f4561a336ff19414 + cpa-archive: CLIProxyAPI_8.0.23_linux_aarch64.tar.gz - runner: windows-2022 target: windows-amd64 extension: dll @@ -128,9 +134,28 @@ jobs: REPOSITORY: https://github.com/${{ github.repository }} # Only read by the darwin toolchain; v0.2.11 and earlier shipped with minos 14.0. MACOSX_DEPLOYMENT_TARGET: '14.0' + MANYLINUX_IMAGE: ${{ matrix.manylinux }} + # Supported Linux glibc minimum: the build fails above it and package.py records it in BUILD.json. + GLIBC_FLOOR: ${{ matrix.manylinux != '' && '2.28' || '' }} run: | mkdir -p dist - go build -trimpath -buildvcs=false -buildmode=c-shared -ldflags="-s -w -X commandcodebridge/internal/bridge.Repository=$REPOSITORY" -o dist/commandcodebridge.${{ matrix.extension }} ./cmd/commandcodebridge + ldflags="-s -w -X commandcodebridge/internal/bridge.Repository=$REPOSITORY" + if [ -n "$MANYLINUX_IMAGE" ]; then + # Reuse the runner's official (statically linked) Go toolchain and caches inside the container. + goroot="$(go env GOROOT)"; gomodcache="$(go env GOMODCACHE)"; gocache="$(go env GOCACHE)" + mkdir -p "$gomodcache" "$gocache" + docker run --rm -u "$(id -u):$(id -g)" -v "$GITHUB_WORKSPACE:/src" -w /src -v "$goroot:/goroot:ro" \ + -v "$gomodcache:/gomodcache" -v "$gocache:/gocache" \ + -e HOME=/tmp -e GOMODCACHE=/gomodcache -e GOCACHE=/gocache -e GOTOOLCHAIN=local -e CGO_ENABLED=1 \ + "$MANYLINUX_IMAGE" /goroot/bin/go build -trimpath -buildvcs=false -buildmode=c-shared -ldflags="$ldflags" -o dist/commandcodebridge.so ./cmd/commandcodebridge + required="$(objdump -T dist/commandcodebridge.so | grep -o 'GLIBC_[0-9.]*' | sed 's/^GLIBC_//' | sort -V | tail -1)" + echo "linux library requires glibc $required" + if [ "$(printf '%s\n%s\n' "$required" "$GLIBC_FLOOR" | sort -V | tail -1)" != "$GLIBC_FLOOR" ]; then + echo "::error::linux library requires glibc $required > $GLIBC_FLOOR"; exit 1 + fi + else + go build -trimpath -buildvcs=false -buildmode=c-shared -ldflags="$ldflags" -o dist/commandcodebridge.${{ matrix.extension }} ./cmd/commandcodebridge + fi if [ "${{ matrix.target }}" = darwin-arm64 ]; then minos="$(otool -l dist/commandcodebridge.dylib | awk '/LC_BUILD_VERSION/ {found=1} found && $1 == "minos" {print $2; exit}')" echo "darwin dylib minos: $minos" @@ -140,6 +165,29 @@ jobs: if [ "${{ matrix.target }}" = windows-amd64 ]; then helper="$helper.exe"; fi CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags="-s -w" -o "$helper" ./cmd/commandcodebridge-login python scripts/package.py dist/commandcodebridge.${{ matrix.extension }} --target ${{ matrix.target }} + - name: Load and run on glibc 2.28 + if: matrix.manylinux != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MANYLINUX_IMAGE: ${{ matrix.manylinux }} + CPA_ARCHIVE: ${{ matrix.cpa-archive }} + run: | + task_dir="$RUNNER_TEMP/cpa-glibc228" + mkdir -p "$task_dir" + gh release download v8.0.23 --repo router-for-me/CLIProxyAPI --pattern "$CPA_ARCHIVE" --pattern checksums.txt --dir "$task_dir" + (cd "$task_dir" && sha256sum --check --ignore-missing checksums.txt && tar -xzf "$CPA_ARCHIVE") + # The pinned CPA release only needs glibc 2.3.2, so the whole host runs inside the old userland. + docker run --rm -u "$(id -u):$(id -g)" -v "$GITHUB_WORKSPACE:/src" -w /src -v "$task_dir:/cpa:ro" \ + -e HOME=/tmp -e CPA_BINARY=/cpa/cli-proxy-api -e BRIDGE_LIBRARY=/src/dist/commandcodebridge.so \ + "$MANYLINUX_IMAGE" bash -euc ' + ldd --version | head -1 + py=/opt/python/cp312-cp312/bin/python3 + $py -c "import ctypes, os; ctypes.CDLL(os.environ[\"BRIDGE_LIBRARY\"]); print(\"dlopen OK\")" + $py tests/cpa_e2e.py + $py tests/cpa_count_tokens.py + $py tests/cpa_thinking_suffix.py + ' - name: Windows native host integration if: matrix.target == 'windows-amd64' shell: pwsh @@ -358,7 +406,7 @@ jobs: pattern: commandcodebridge-* path: dist merge-multiple: true - - run: python scripts/checksums.py dist --include-cpamp + - run: python scripts/checksums.py dist --include-cpamp --max-glibc 2.28 - uses: actions/upload-artifact@v4 with: name: commandcodebridge-checksums @@ -387,7 +435,7 @@ jobs: env: RELEASE_TAG: ${{ github.ref_name }} run: | - python scripts/checksums.py dist --include-cpamp + python scripts/checksums.py dist --include-cpamp --max-glibc 2.28 python - <<'PY' import json, os, pathlib, re, zipfile version = re.search(r'const Version = "([^"]+)"', pathlib.Path('internal/bridge/types.go').read_text()).group(1) diff --git a/CHANGELOG.md b/CHANGELOG.md index c11d9b4..3bfebc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Changed + +- Linux 动态库改在固定摘要的 manylinux_2_28 容器中构建,最低 glibc 从 2.34 降至 **2.28**(RHEL/AlmaLinux 8、Debian 10、Ubuntu 20.04 及更新版本)。CI 在 glibc 2.28 用户态中加载动态库,并运行 CPA v8.0.23 宿主联测;构建与发布校验会拒绝要求更高 glibc 的 Linux 包。`BUILD.json` 的 `glibc_minimum` 记录受支持的最低版本,`glibc_symbol_version_max` 记录符号版本的实际上限。 + ## [0.2.12] - 2026-11-07 ### Fixed diff --git a/scripts/checksums.py b/scripts/checksums.py index 767e0bb..45e5add 100644 --- a/scripts/checksums.py +++ b/scripts/checksums.py @@ -15,7 +15,12 @@ parser = argparse.ArgumentParser() parser.add_argument("directory", type=pathlib.Path) parser.add_argument("--include-cpamp", action="store_true", help="Also require the CPAMP frontend from the same integration source") +parser.add_argument("--max-glibc", help="Require every Linux package to record a glibc minimum no higher than this version") args = parser.parse_args() + + +def glibc_key(value): + return tuple(int(part) for part in value.split(".")) archives = sorted(args.directory.rglob("commandcodebridge_*.zip")) if len(archives) != len(TARGETS) + int(args.include_cpamp): parser.error("expected four platform packages" + (" and one CPAMP frontend" if args.include_cpamp else "")) @@ -66,6 +71,10 @@ goos, goarch = target.split("-") if archive.name != f"commandcodebridge_{build['version']}_{goos}_{goarch}.zip": parser.error("archive does not use CPA's required filename") + if goos == "linux" and args.max_glibc: + minimum = build.get("glibc_minimum") + if not minimum or glibc_key(minimum) > glibc_key(args.max_glibc): + parser.error(f"{archive.name} requires glibc {minimum or 'unknown'}, above {args.max_glibc}") library = f"commandcodebridge.{TARGETS[target]}" if hashlib.sha256(bundle.read(library)).hexdigest() != build["library_sha256"]: parser.error("library checksum mismatch") diff --git a/scripts/package.py b/scripts/package.py index 1068ae3..bc1619e 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -3,6 +3,7 @@ import gzip import hashlib import json +import os import pathlib import re import shutil @@ -64,11 +65,20 @@ def git_value(*arguments): "login_helper_sha256": hashlib.sha256(helper.read_bytes()).hexdigest(), "native_panel_sha256": hashlib.sha256(panel_page).hexdigest(), } +def glibc_key(value): + return tuple(int(part) for part in value.split(".")) + + if args.target.startswith("linux-") and shutil.which("readelf"): symbols = subprocess.run(["readelf", "--version-info", str(args.library)], capture_output=True, text=True, check=True).stdout versions = set(re.findall(r"GLIBC_([0-9]+(?:\.[0-9]+)+)", symbols)) if versions: - manifest["glibc_minimum"] = max(versions, key=lambda value: tuple(int(part) for part in value.split("."))) + symbol_maximum = max(versions, key=glibc_key) + # The release is built and tested in a manylinux userland. Older glibc may resolve + # every versioned symbol yet remains untested, so the floor is the supported minimum. + floor = os.environ.get("GLIBC_FLOOR", "").strip() + manifest["glibc_minimum"] = max(symbol_maximum, floor, key=glibc_key) if floor else symbol_maximum + manifest["glibc_symbol_version_max"] = symbol_maximum with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle: bundle.write(args.library, args.library.name) bundle.write(helper, helper.name) diff --git a/tests/test_cpamp_panel_validation.py b/tests/test_cpamp_panel_validation.py index aaec86f..0cd6ed3 100644 --- a/tests/test_cpamp_panel_validation.py +++ b/tests/test_cpamp_panel_validation.py @@ -168,6 +168,8 @@ def test_native_packages_release_gate_and_shipped_verifier(self): (folder / ('commandcodebridge-login.exe' if target.startswith('windows') else 'commandcodebridge-login')).write_bytes(b'fixture helper') self.assert_result(self.script('package.py', library, '--target', target), True) self.assert_result(self.script('checksums.py', output, '--include-cpamp'), True) + # The fixture library is either uninspected or needs far more than glibc 0.1. + self.assertIn('above 0.1', self.assert_result(self.script('checksums.py', output, '--include-cpamp', '--max-glibc', '0.1'), False)) # Exercise the verifier and apply baseline loader from an extracted ZIP. extracted = self.root / 'extracted' with zipfile.ZipFile(self.archive) as bundle: From b858ed05f468c6ec652187331a8c48613aa6e371 Mon Sep 17 00:00:00 2001 From: Gaoshou101 <188431084+Gaoshou101@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:58:52 +0800 Subject: [PATCH 2/4] =?UTF-8?q?perf:=20=E8=BF=90=E8=A1=8C=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=94=B9=E4=B8=BA=E7=B4=A7=E5=87=91=20JSON=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E6=96=87=E4=BB=B6=E5=A4=A7=E5=B0=8F=E9=99=90?= =?UTF-8?q?=E5=88=B6=E6=8C=81=E7=BB=AD=E9=87=8D=E5=86=99=E9=A2=91=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requests/statistics/usage 快照与检查点改用 json.Marshal,一次编码同时 写主文件与检查点。 - 后台写入按上次写入大小把同一文件的重写间隔折算为约 1 MiB/s,下限 250ms、上限 10s;shutdown 与显式 flushStatistics 仍立即写入全部待写文件。 - 新增间隔计算、大文件节流期间保持待写、紧凑编码的单元测试。 --- CHANGELOG.md | 1 + docs/DEPLOYMENT.md | 2 +- internal/bridge/persistence.go | 58 +++++++++++++++-- internal/bridge/persistence_rate_test.go | 81 ++++++++++++++++++++++++ internal/bridge/runtime_recovery.go | 18 ++++-- internal/bridge/service.go | 15 +++-- 6 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 internal/bridge/persistence_rate_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bfebc9..f9d33d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed +- 运行数据文件(`requests.json`、`statistics.json`、`usage.json` 及检查点)改为紧凑 JSON,结构不变;同一文件持续变更时,两次重写的间隔按上次写入大小折算为约 1 MiB/s(最长 10 秒)。小文件仍为 250ms,正常卸载立即写入全部待写文件。合成测量中,90 天 × 2 模型的统计文件从 9.8 MB 缩小到 7.5 MB,持续流量下写入从约 39 MB/s 降至约 1 MB/s;1,000 条请求记录从约 3.9 MB/s 降至约 1 MB/s。代价是强制结束进程时大文件可能丢失最多约 10 秒的变更。 - Linux 动态库改在固定摘要的 manylinux_2_28 容器中构建,最低 glibc 从 2.34 降至 **2.28**(RHEL/AlmaLinux 8、Debian 10、Ubuntu 20.04 及更新版本)。CI 在 glibc 2.28 用户态中加载动态库,并运行 CPA v8.0.23 宿主联测;构建与发布校验会拒绝要求更高 glibc 的 Linux 包。`BUILD.json` 的 `glibc_minimum` 记录受支持的最低版本,`glibc_symbol_version_max` 记录符号版本的实际上限。 ## [0.2.12] - 2026-11-07 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 9604681..d045d21 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -216,7 +216,7 @@ Windows 使用 `Get-FileHash -Algorithm SHA256`,macOS 使用 `shasum -a 256` ### 文件与写入 -`settings.json`、凭证仍走原配置/宿主流程。`requests.json`、`statistics.json`、`usage.json` 保留原数组/字典结构,分别标脏、250ms 批量刷盘;只有变更的文件写入。`runtime-format.json` 记录格式版本及最近 20 条恢复警告。未知格式、设置损坏或不可读均明确报错,保留原文件。 +`settings.json`、凭证仍走原配置/宿主流程。`requests.json`、`statistics.json`、`usage.json` 保留原数组/字典结构(紧凑 JSON),分别标脏、250ms 批量刷盘;只有变更的文件写入。持续变更时,同一文件两次重写的间隔按上次写入大小折算为约 1 MiB/s,最长 10 秒:小文件仍为 250ms,数 MB 的统计文件约数秒写一次;正常卸载会立即写入全部待写文件。强制结束进程或断电时,大文件可能丢失这一间隔内的变更。`runtime-format.json` 记录格式版本及最近 20 条恢复警告。未知格式、设置损坏或不可读均明确报错,保留原文件。 每个运行文件的 `.last-good` 检查点最多每分钟随变更刷新一次。恢复可能丢失检查点之后的数据,独立文件不构成跨文件事务,也不承诺断电 fsync 耐久性。状态/设置页显示最近成功刷盘、待写文件与起始时间,恢复警告重启后仍保留。无有效检查点时,仅重建损坏的观察文件,并提示该历史范围未知;配置与其他有效历史保留。 diff --git a/internal/bridge/persistence.go b/internal/bridge/persistence.go index 66ce8ed..2f5fbf6 100644 --- a/internal/bridge/persistence.go +++ b/internal/bridge/persistence.go @@ -8,6 +8,33 @@ import ( const statisticsFlushInterval = 250 * time.Millisecond +// Under sustained traffic each runtime file is rewritten in full on every +// tick, so a large statistics or request log would cost several megabytes per +// second of disk writes. Consecutive rewrites of one file are spaced so that +// its sustained write rate stays near runtimeWriteBytesPerSecond. Small files +// keep the 250ms window, the first change after a quiet period is written on +// the next tick, and shutdown always flushes every pending file. +const ( + runtimeWriteBytesPerSecond = 1 << 20 + runtimeWriteMaxInterval = 10 * time.Second +) + +// runtimeWriteInterval is the minimum spacing between rewrites of a runtime +// file whose last successful snapshot was size bytes. +func runtimeWriteInterval(size int) time.Duration { + interval := time.Duration(int64(size) * int64(time.Second) / runtimeWriteBytesPerSecond) + switch { + case interval < statisticsFlushInterval: + return statisticsFlushInterval + case interval > runtimeWriteMaxInterval: + return runtimeWriteMaxInterval + default: + return interval + } +} + +var runtimeFileNames = map[runtimeFiles]string{dirtyLogs: "requests.json", dirtyStatistics: "statistics.json", dirtyUsage: "usage.json"} + type runtimeFiles uint8 const ( @@ -48,7 +75,7 @@ func (s *Service) startPersistenceLocked() { for { select { case <-ticker.C: - s.flushStatistics() + s.flushRuntimeFiles(false) case <-stop: s.flushStatistics() return @@ -57,10 +84,17 @@ func (s *Service) startPersistenceLocked() { }() } +// flushStatistics writes every pending runtime file immediately. It is used at +// shutdown and by tests; the background worker uses the rate-limited path. +func (s *Service) flushStatistics() { + s.flushRuntimeFiles(true) +} + // persistMu serializes disk snapshots with reset so an older in-flight snapshot // cannot overwrite a freshly cleared counter. Serialization and IO run without // the service lock; new requests and management reads can proceed concurrently. -func (s *Service) flushStatistics() { +// Unless force is set, files rewritten too recently stay pending for a later tick. +func (s *Service) flushRuntimeFiles(force bool) { s.persistMu.Lock() defer s.persistMu.Unlock() s.mu.Lock() @@ -68,8 +102,16 @@ func (s *Service) flushStatistics() { s.mu.Unlock() return } - s.compactStatisticsLocked(time.Now().UTC()) + now := time.Now() + s.compactStatisticsLocked(now.UTC()) dirty := s.persistDirty + if !force { + for flag, name := range runtimeFileNames { + if dirty&flag != 0 && now.Sub(s.persistLastWrite[name]) < runtimeWriteInterval(s.persistLastSize[name]) { + dirty &^= flag + } + } + } if dirty == 0 { s.mu.Unlock() return @@ -95,12 +137,12 @@ func (s *Service) flushStatistics() { values["usage.json"] = usage } dir := s.cfg.DataDir - s.persistDirty = 0 + s.persistDirty &^= dirty s.persistWriting = true s.mu.Unlock() failed := runtimeFiles(0) for name, value := range values { - err := s.writeRuntimeSnapshot(dir, name, value, false) + size, err := s.writeRuntimeSnapshot(dir, name, value, false) s.mu.Lock() if s.persistErrors == nil { s.persistErrors = map[string]bool{} @@ -108,12 +150,18 @@ func (s *Service) flushStatistics() { if s.persistWrites == nil { s.persistWrites = map[string]uint64{} } + if s.persistLastWrite == nil { + s.persistLastWrite = map[string]time.Time{} + s.persistLastSize = map[string]int{} + } if err != nil { s.persistErrors[name] = true failed |= map[string]runtimeFiles{"requests.json": dirtyLogs, "statistics.json": dirtyStatistics, "usage.json": dirtyUsage}[name] } else { delete(s.persistErrors, name) s.persistWrites[name]++ + s.persistLastWrite[name] = time.Now() + s.persistLastSize[name] = size } s.mu.Unlock() } diff --git a/internal/bridge/persistence_rate_test.go b/internal/bridge/persistence_rate_test.go new file mode 100644 index 0000000..445c3d5 --- /dev/null +++ b/internal/bridge/persistence_rate_test.go @@ -0,0 +1,81 @@ +package bridge + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRuntimeWriteInterval(t *testing.T) { + for _, item := range []struct { + size int + want time.Duration + }{ + {0, statisticsFlushInterval}, + {64 << 10, statisticsFlushInterval}, + {512 << 10, 500 * time.Millisecond}, + {6 << 20, 6 * time.Second}, + {100 << 20, runtimeWriteMaxInterval}, + } { + if got := runtimeWriteInterval(item.size); got != item.want { + t.Fatalf("runtimeWriteInterval(%d) = %v, want %v", item.size, got, item.want) + } + } +} + +func TestBackgroundFlushPacesLargeRuntimeFiles(t *testing.T) { + s, _, _ := setup(t, func(string, map[string]any) mockReply { return streamReply(chatStream("ok")) }) + // A first write initializes the pacing state for every runtime file. + s.record(LogEntry{ID: id(), Model: "model", Status: 200}) + s.flushStatistics() + // Pretend statistics.json was just written at 6MiB; requests.json stays small. + s.mu.Lock() + s.persistLastWrite["statistics.json"] = time.Now() + s.persistLastSize["statistics.json"] = 6 << 20 + s.persistLastWrite["requests.json"] = time.Now().Add(-time.Second) + before := s.persistWrites["statistics.json"] + s.mu.Unlock() + s.record(LogEntry{ID: id(), Model: "model", Status: 200}) + s.flushRuntimeFiles(false) + s.mu.RLock() + pending, written, logs := s.persistDirty, s.persistWrites["statistics.json"], s.persistWrites["requests.json"] + s.mu.RUnlock() + if pending&dirtyStatistics == 0 || pending&dirtyLogs != 0 { + t.Fatalf("large statistics should stay pending and logs should be written: dirty=%b", pending) + } + if written != before || logs == 0 { + t.Fatalf("unexpected writes: statistics %d -> %d, requests %d", before, written, logs) + } + // Shutdown and explicit flushes ignore pacing. + s.flushStatistics() + s.mu.RLock() + pending, written = s.persistDirty, s.persistWrites["statistics.json"] + s.mu.RUnlock() + if pending != 0 || written != before+1 { + t.Fatalf("forced flush left files pending: dirty=%b writes=%d", pending, written) + } +} + +func TestRuntimeSnapshotsAreCompactJSON(t *testing.T) { + s, _, _ := setup(t, func(string, map[string]any) mockReply { return streamReply(chatStream("ok")) }) + s.record(LogEntry{ID: "compact", Model: "model", Status: 200}) + s.flushStatistics() + for _, name := range []string{"requests.json", "statistics.json"} { + data, err := os.ReadFile(filepath.Join(s.cfg.DataDir, name)) + if err != nil || !json.Valid(data) { + t.Fatalf("invalid %s: %v", name, err) + } + if bytes.Contains(data, []byte("\n")) { + t.Fatalf("%s is not compact", name) + } + s.mu.RLock() + size := s.persistLastSize[name] + s.mu.RUnlock() + if size != len(data) { + t.Fatalf("%s recorded size %d, file has %d bytes", name, size, len(data)) + } + } +} diff --git a/internal/bridge/runtime_recovery.go b/internal/bridge/runtime_recovery.go index fd741cf..77006e0 100644 --- a/internal/bridge/runtime_recovery.go +++ b/internal/bridge/runtime_recovery.go @@ -151,18 +151,24 @@ func decodeRuntime(raw []byte, target any) bool { // Checkpoints are refreshed at most once a minute rather than doubling every // request-log write. Forced checkpoints are used when explicitly resetting. -func (s *Service) writeRuntimeSnapshot(dir, name string, value any, force bool) error { - if err := atomicJSON(filepath.Join(dir, name), value); err != nil { - return err +// Runtime snapshots are compact JSON with the same structure as before; the +// encoded size is returned so the writer can pace rewrites of large files. +func (s *Service) writeRuntimeSnapshot(dir, name string, value any, force bool) (int, error) { + data, err := json.Marshal(value) + if err != nil { + return 0, err + } + if err := atomicWrite(filepath.Join(dir, name), data); err != nil { + return 0, err } if s.checkpointAt == nil { s.checkpointAt = map[string]time.Time{} } if force || time.Since(s.checkpointAt[name]) >= time.Minute { - if err := atomicJSON(filepath.Join(dir, name+".last-good"), value); err != nil { - return err + if err := atomicWrite(filepath.Join(dir, name+".last-good"), data); err != nil { + return 0, err } s.checkpointAt[name] = time.Now() } - return nil + return len(data), nil } diff --git a/internal/bridge/service.go b/internal/bridge/service.go index 44c0a92..1f11d45 100644 --- a/internal/bridge/service.go +++ b/internal/bridge/service.go @@ -41,6 +41,8 @@ type Service struct { checkpointAt map[string]time.Time persistErrors map[string]bool persistWrites map[string]uint64 + persistLastWrite map[string]time.Time + persistLastSize map[string]int storageWarnings []runtimeRecovery nextCompaction time.Time persistStop, persistDone chan struct{} @@ -80,15 +82,20 @@ func id() string { return hex.EncodeToString(b) } func atomicJSON(path string, v any) error { - if e := os.MkdirAll(filepath.Dir(path), 0700); e != nil { - return e - } b, e := json.MarshalIndent(v, "", " ") if e != nil { return e } + return atomicWrite(path, b) +} + +// atomicWrite replaces path through a private temporary file and rename. +func atomicWrite(path string, b []byte) error { + if e := os.MkdirAll(filepath.Dir(path), 0700); e != nil { + return e + } tmp := path + ".tmp-" + id() - if e = os.WriteFile(tmp, b, 0600); e != nil { + if e := os.WriteFile(tmp, b, 0600); e != nil { return e } defer os.Remove(tmp) From ab34339c14889cb052dd4635d0cd05c6e640d7a7 Mon Sep 17 00:00:00 2001 From: Gaoshou101 <188431084+Gaoshou101@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:08:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E5=8F=AF=E9=80=89=E7=9A=84?= =?UTF-8?q?=E9=A6=96=E5=AD=97=E8=8A=82=E8=B6=85=E6=97=B6=E4=B8=8E=E6=B5=81?= =?UTF-8?q?=E7=A9=BA=E9=97=B2=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 first_byte_timeout_seconds(请求开始到首段上游数据,含等待响应头) 与 stream_idle_timeout_seconds(此后两段数据的最大间隔);0 为关闭(默认), 启用时 5-1800 秒,均在 timeout_seconds 内生效,控制台与 YAML 可配置。 - read() 改为单个 watchdog 统一处理总超时、首字节与空闲超时,触发时关闭上游 并返回 504 upstream_first_byte_timeout / upstream_stream_idle_timeout。 - 新增单元测试与 cpa_reliability.py 宿主联测(含连接释放与日志错误类型)。 --- CHANGELOG.md | 4 + config.example.yaml | 4 + docs/DEPLOYMENT.md | 3 +- internal/bridge/executor.go | 86 ++++++++++++-- internal/bridge/management.go | 2 +- internal/bridge/types.go | 22 ++++ internal/bridge/ui/console.js | 4 +- internal/bridge/ui/index.html | 2 +- internal/bridge/upstream_timeouts_test.go | 132 ++++++++++++++++++++++ tests/cpa_reliability.py | 32 +++++- 10 files changed, 270 insertions(+), 21 deletions(-) create mode 100644 internal/bridge/upstream_timeouts_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d33d7..d2ba500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- 可选的上游停滞保护:`first_byte_timeout_seconds` 限制从请求开始到首段上游响应数据的时间(含等待响应头),`stream_idle_timeout_seconds` 限制此后两段数据之间的间隔。两者都在 `timeout_seconds` 之内生效,0 为关闭(默认),启用时取 5–1800 秒;可在控制台设置页或 YAML 配置,保存即时生效。触发时返回 504,日志错误类型为 `upstream_first_byte_timeout` 或 `upstream_stream_idle_timeout`,并关闭上游连接。真实上游下长推理模型的首字节与段间间隔尚未实测,因此默认不启用。 + ### Changed - 运行数据文件(`requests.json`、`statistics.json`、`usage.json` 及检查点)改为紧凑 JSON,结构不变;同一文件持续变更时,两次重写的间隔按上次写入大小折算为约 1 MiB/s(最长 10 秒)。小文件仍为 250ms,正常卸载立即写入全部待写文件。合成测量中,90 天 × 2 模型的统计文件从 9.8 MB 缩小到 7.5 MB,持续流量下写入从约 39 MB/s 降至约 1 MB/s;1,000 条请求记录从约 3.9 MB/s 降至约 1 MB/s。代价是强制结束进程时大文件可能丢失最多约 10 秒的变更。 diff --git a/config.example.yaml b/config.example.yaml index d0ab568..07e19df 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,6 +14,10 @@ plugins: cpamp_panel_path: "" # Browser OAuth uses a local POST receiver and CPAMP result submission. timeout_seconds: 600 + # Optional stall guards inside timeout_seconds (0 = off, otherwise 5-1800): + # time to the first upstream response data, and the longest gap between chunks. + first_byte_timeout_seconds: 0 + stream_idle_timeout_seconds: 0 usage_refresh_seconds: 60 log_retention: 1000 max_response_bytes: 16777216 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index d045d21..95d349d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -35,7 +35,7 @@ v0.2.8 的四平台插件内嵌新版 CPAMP 页面,支持官方套餐月限额 | 操作 | 生效方式 | | --- | --- | -| 控制台修改超时、日志保留、用量刷新、响应大小、ZDR、模型或面板探测路径 | 保存后即时生效,不重启 CPA | +| 控制台修改超时(含首字节与流空闲超时)、日志保留、用量刷新、响应大小、ZDR、模型或面板探测路径 | 保存后即时生效,不重启 CPA | | CPA 配置页修改插件的 CPAMP 路径 | 宿主重载调用 `plugin.reconfigure`;显式非空路径覆盖旧保存值 | | 更新已启用的外部原生面板 | 点击「更新原生面板」,原路径原子替换;强刷浏览器,不重启 Manager Server | | 通过支持热升级的 CPA 插件商店升级动态库 | 宿主选择新的版本化文件并切换插件;确认运行版本,无需重启 CPA | @@ -191,6 +191,7 @@ Windows 使用 `Get-FileHash -Algorithm SHA256`,macOS 使用 `shasum -a 256` | 无法加载库 | CPA 是否含插件支持;CPU/系统/运行库是否匹配;文件是否可读 | | 模型可见但返回 403 | 目录不保证套餐权限,检查 `MODEL_NOT_IN_PLAN` | | 接口直连失败或超时 | 确认 CPA 全局代理;插件不继承 Windows 系统代理 | +| 上游长时间无响应才报 504 | 可在设置页启用「首字节超时」和「流空闲超时」(`first_byte_timeout_seconds`、`stream_idle_timeout_seconds`,0 为关闭,启用时 5–1800 秒)。日志的错误类型分别为 `upstream_first_byte_timeout`、`upstream_stream_idle_timeout`;默认关闭,长推理模型在真实上游中的首字节与段间间隔尚未实测,取值宜宽松 | | 用量缺失 | 查看 `unavailable` / `status`;查询失败保留旧值 | | 日志写入失败 | 检查 data_dir 的磁盘空间、权限及 `storage_error` | | 重启后设置未按 YAML 改变 | 已存 settings.json 优先;采用明确的迁移方案 | diff --git a/internal/bridge/executor.go b/internal/bridge/executor.go index 7466473..0c337dc 100644 --- a/internal/bridge/executor.go +++ b/internal/bridge/executor.go @@ -15,6 +15,9 @@ type upstreamStream struct { Headers http.Header `json:"headers"` StreamID string `json:"stream_id"` deadline time.Time + // Optional progress guards; zero values disable them. + firstByteDeadline time.Time + idleTimeout time.Duration } type readChunk struct { Payload []byte `json:"payload"` @@ -23,6 +26,12 @@ type readChunk struct { } func (s *Service) openUpstream(payload any, deadline time.Time) (upstreamStream, error) { + return s.openUpstreamUntil(payload, deadline, fail(504, "upstream request timed out")) +} + +// openUpstreamUntil waits for response headers until deadline and reports +// timeoutErr when it passes. +func (s *Service) openUpstreamUntil(payload any, deadline time.Time, timeoutErr error) (upstreamStream, error) { if deadline.IsZero() { deadline = time.Now().Add(time.Duration(s.config().TimeoutSeconds) * time.Second) } @@ -74,7 +83,7 @@ func (s *Service) openUpstream(payload any, deadline time.Time) (upstreamStream, } return r.up, nil case <-timer.C: - return upstreamStream{}, fail(504, "upstream request timed out") + return upstreamStream{}, timeoutErr case <-s.stopCh: return upstreamStream{}, fail(503, "plugin is shutting down") } @@ -94,24 +103,65 @@ func (s *Service) read(up upstreamStream, fn func([]byte) error) error { if deadline.IsZero() { deadline = time.Now().Add(time.Duration(s.config().TimeoutSeconds) * time.Second) } + // A watchdog closes the host stream when the overall deadline, the first + // byte deadline or the idle limit passes; the blocked read then returns. var mu sync.Mutex - timedOut := false - done := make(chan struct{}) - timer := time.AfterFunc(time.Until(deadline), func() { defer close(done); mu.Lock(); timedOut = true; mu.Unlock(); s.closeUpstream(up.StreamID) }) - defer func() { - if !timer.Stop() { - <-done + var expired error + progress := make(chan struct{}, 1) + finished := make(chan struct{}) + watching := make(chan struct{}) + go func() { + defer close(watching) + expire := func(err error) { + mu.Lock() + expired = err + mu.Unlock() + s.closeUpstream(up.StreamID) } + overall := time.NewTimer(time.Until(deadline)) + defer overall.Stop() + guard := time.NewTimer(time.Hour) + guard.Stop() + defer guard.Stop() + var guardC <-chan time.Time + var guardErr error + if !up.firstByteDeadline.IsZero() && up.firstByteDeadline.Before(deadline) { + guard.Reset(time.Until(up.firstByteDeadline)) + guardC, guardErr = guard.C, errFirstByteTimeout() + } + for { + select { + case <-overall.C: + expire(fail(504, "upstream request timed out")) + return + case <-guardC: + expire(guardErr) + return + case <-progress: + guard.Stop() + guardC = nil + if up.idleTimeout > 0 { + guard.Reset(up.idleTimeout) + guardC, guardErr = guard.C, errStreamIdleTimeout() + } + case <-finished: + return + } + } + }() + defer func() { + close(finished) + <-watching }() total := 0 for { var chunk readChunk e := s.call("host.http.stream_read", map[string]string{"stream_id": up.StreamID}, &chunk) mu.Lock() - timeout := timedOut + timeout := expired mu.Unlock() - if timeout { - return fail(504, "upstream request timed out") + if timeout != nil { + return timeout } if e != nil || chunk.Error != "" { return fail(502, "upstream stream interrupted") @@ -121,6 +171,10 @@ func (s *Service) read(up upstreamStream, fn func([]byte) error) error { return fail(502, "upstream response exceeds configured limit") } if len(chunk.Payload) > 0 { + select { + case progress <- struct{}{}: + default: + } if e := fn(chunk.Payload); e != nil { return e } @@ -366,11 +420,21 @@ func (s *Service) execute(r ExecutorRequest, stream bool) (any, error) { } body["stream"] = true entry.UpstreamAttempted = true - up, e := s.openUpstream(map[string]any{"host_callback_id": r.HostCallbackID, "method": "POST", "url": executionConfig.BaseURL + path, "headers": upstreamHeaders(c, executionConfig.ZDR), "body": jsonBytes(body)}, start.Add(time.Duration(executionConfig.TimeoutSeconds)*time.Second)) + overall := start.Add(time.Duration(executionConfig.TimeoutSeconds) * time.Second) + headerDeadline, headerTimeout := overall, fail(504, "upstream request timed out") + var firstByteDeadline time.Time + if executionConfig.FirstByteTimeoutSeconds > 0 { + if at := start.Add(time.Duration(executionConfig.FirstByteTimeoutSeconds) * time.Second); at.Before(overall) { + firstByteDeadline, headerDeadline, headerTimeout = at, at, errFirstByteTimeout() + } + } + up, e := s.openUpstreamUntil(map[string]any{"host_callback_id": r.HostCallbackID, "method": "POST", "url": executionConfig.BaseURL + path, "headers": upstreamHeaders(c, executionConfig.ZDR), "body": jsonBytes(body)}, headerDeadline, headerTimeout) if e != nil { release(e) return early(e) } + up.deadline, up.firstByteDeadline = overall, firstByteDeadline + up.idleTimeout = time.Duration(executionConfig.IdleTimeoutSeconds) * time.Second if up.StatusCode < 200 || up.StatusCode >= 300 { _, e = s.readJSON(up) e = redactError(e, c.APIKey) diff --git a/internal/bridge/management.go b/internal/bridge/management.go index a543863..c9e4bed 100644 --- a/internal/bridge/management.go +++ b/internal/bridge/management.go @@ -186,7 +186,7 @@ func (s *Service) management(raw json.RawMessage) (any, error) { e = fail(400, "invalid settings JSON") } else { for k := range in { - if k != "timeout_seconds" && k != "log_retention" && k != "max_response_bytes" && k != "usage_refresh_seconds" && k != "statistics_retention_days" && k != "zdr" && k != "cpamp_install_dir" && k != "cpamp_config_path" && k != "cpamp_panel_path" { + if k != "timeout_seconds" && k != "first_byte_timeout_seconds" && k != "stream_idle_timeout_seconds" && k != "log_retention" && k != "max_response_bytes" && k != "usage_refresh_seconds" && k != "statistics_retention_days" && k != "zdr" && k != "cpamp_install_dir" && k != "cpamp_config_path" && k != "cpamp_panel_path" { e = fail(400, "unsupported editable setting") break } diff --git a/internal/bridge/types.go b/internal/bridge/types.go index fd81015..577e2a2 100644 --- a/internal/bridge/types.go +++ b/internal/bridge/types.go @@ -35,6 +35,16 @@ func fail(status int, message string) error { return &APIError{Status: status, Kind: "invalid_request", Message: message} } +// Upstream progress guards are reported separately from the overall request +// timeout so logs and clients can tell a stalled upstream from a slow one. +func errFirstByteTimeout() error { + return &APIError{Status: 504, Kind: "upstream_first_byte_timeout", Message: "upstream sent no response data within first_byte_timeout_seconds"} +} + +func errStreamIdleTimeout() error { + return &APIError{Status: 504, Kind: "upstream_stream_idle_timeout", Message: "upstream stream was idle longer than stream_idle_timeout_seconds"} +} + // CommandCode has no exact token counting endpoint, and the plugin never // fabricates a count. Report an endpoint-level gap rather than HTTP 501: CPA // treats a count_tokens 404 as availability-neutral on every supported host, @@ -102,6 +112,11 @@ type Model struct { ThinkingMode string `json:"thinking_mode,omitempty" yaml:"thinking_mode,omitempty"` Reasoning ReasoningMapping `json:"reasoning_mapping" yaml:"reasoning_mapping"` } + +// Config is the plugin configuration. FirstByteTimeoutSeconds limits the time +// from request start to the first upstream response data and IdleTimeoutSeconds +// (stream_idle_timeout_seconds) the gap between later chunks of an upstream +// stream; both run inside TimeoutSeconds and zero disables them. type Config struct { DataDir string `json:"data_dir" yaml:"data_dir"` BaseURL string `json:"base_url" yaml:"base_url"` @@ -111,6 +126,8 @@ type Config struct { CPAMPPanelPath string `json:"cpamp_panel_path" yaml:"cpamp_panel_path"` Models []Model `json:"models" yaml:"models"` TimeoutSeconds int `json:"timeout_seconds" yaml:"timeout_seconds"` + FirstByteTimeoutSeconds int `json:"first_byte_timeout_seconds" yaml:"first_byte_timeout_seconds"` + IdleTimeoutSeconds int `json:"stream_idle_timeout_seconds" yaml:"stream_idle_timeout_seconds"` LogRetention int `json:"log_retention" yaml:"log_retention"` MaxResponseBytes int `json:"max_response_bytes" yaml:"max_response_bytes"` UsageRefreshSeconds int `json:"usage_refresh_seconds" yaml:"usage_refresh_seconds"` @@ -145,6 +162,11 @@ func (c *Config) validate() error { } c.PublicBaseURL = strings.TrimRight(c.PublicBaseURL, "/") } + for _, guard := range []int{c.FirstByteTimeoutSeconds, c.IdleTimeoutSeconds} { + if guard != 0 && (guard < 5 || guard > 1800) { + return fail(400, "first_byte_timeout_seconds and stream_idle_timeout_seconds must be 0 or 5 to 1800") + } + } if c.DataDir == "" || c.TimeoutSeconds < 10 || c.TimeoutSeconds > 1800 || c.LogRetention < 10 || c.LogRetention > 10000 || c.MaxResponseBytes < 65536 || c.MaxResponseBytes > 64<<20 || c.UsageRefreshSeconds < 30 || c.UsageRefreshSeconds > 3600 { return fail(400, "invalid data directory, timeout, retention, response limit or refresh interval") } diff --git a/internal/bridge/ui/console.js b/internal/bridge/ui/console.js index f570995..7b91f22 100644 --- a/internal/bridge/ui/console.js +++ b/internal/bridge/ui/console.js @@ -17,7 +17,7 @@ function apiURL(path){return new URL(API_BASE+path,location.origin).href} async function api(path,options={}){if(state.blocked&&!options.verify)throw new Error('管理访问已暂停,请重新验证登录');const headers={...(window.CommandCodeBridgeAuthHeaders?.()||{}),...options.headers};if(state.key){delete headers.Authorization;delete headers['X-Management-Key'];headers[state.authType==='x-management-key'?'X-Management-Key':'Authorization']=state.authType==='x-management-key'?state.key:'Bearer '+state.key}if(options.body!==undefined)headers['Content-Type']='application/json';state.busy++;$('busy').textContent='正在更新';try{const response=await fetch(apiURL(path),{method:options.method||'GET',headers,body:options.body===undefined?undefined:JSON.stringify(options.body),signal:AbortSignal.timeout(options.timeout||650000)});const text=await response.text();let data;try{data=JSON.parse(text)}catch{data={error:text.slice(0,200)}}if(!response.ok){if((response.status===401||response.status===403)&&path!=='/models/test'){state.authenticated=false;state.blocked=true;resetDiagnosticViews();if(response.status===401){window.CommandCodeBridgeClearAuth?.(headers);state.key=''}openDialog('authDialog')}throw new Error(data.error||`HTTP ${response.status}`)}return data}finally{state.busy--;$('busy').textContent=state.busy?'正在更新':''}} async function confirmed(title,text){$('confirmTitle').textContent=title;$('confirmText').textContent=text;openDialog('confirmDialog');return new Promise(resolve=>{const dialog=$('confirmDialog');let settled=false;const onclose=()=>end(false);const end=value=>{if(settled)return;settled=true;dialog.removeEventListener('close',onclose);if(dialog.open)dialog.close();resolve(value)};dialog.addEventListener('close',onclose);$('confirmCancel').onclick=()=>end(false);$('confirmYes').onclick=()=>end(true);dialog.oncancel=e=>{e.preventDefault();end(false)}})} function period(){const range=$('statsRange').value;let from='',to='';if(range==='today'){const date=new Date();date.setHours(0,0,0,0);from=date.toISOString()}else if(range==='24h')from=new Date(Date.now()-86400000).toISOString();else if(range==='custom'){if($('statsFrom').value)from=new Date($('statsFrom').value).toISOString();if($('statsTo').value)to=new Date($('statsTo').value).toISOString()}const q=new URLSearchParams();if(from)q.set('from',from);if(to)q.set('to',to);return q.size?'?'+q.toString():''} -async function loadConfig(){const identity=diagnosticIdentity(),config=await api('/config');if(!state.authenticated||identity!==diagnosticIdentity())return;state.config=config;renderModels();const f=$('settingsForm');for(const k of ['timeout_seconds','log_retention','usage_refresh_seconds','statistics_retention_days'])f.elements[k].value=state.config[k];for(const k of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])f.elements[k].value=state.config[k]||'';f.elements.max_response_mb.value=state.config.max_response_bytes/1048576;f.elements.zdr.checked=state.config.zdr;$('dataDir').textContent=state.config.data_dir;const model=state.config.models[0]?.id||'你的模型名称';$('apiExample').textContent=JSON.stringify({model,messages:[{role:'user',content:'你好'}],stream:true},null,2);if(state.view==='settings')await Promise.all([loadCPAMPPanel(),loadCompatibility(),loadStorage()])} +async function loadConfig(){const identity=diagnosticIdentity(),config=await api('/config');if(!state.authenticated||identity!==diagnosticIdentity())return;state.config=config;renderModels();const f=$('settingsForm');for(const k of ['timeout_seconds','first_byte_timeout_seconds','stream_idle_timeout_seconds','log_retention','usage_refresh_seconds','statistics_retention_days'])f.elements[k].value=state.config[k]??0;for(const k of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])f.elements[k].value=state.config[k]||'';f.elements.max_response_mb.value=state.config.max_response_bytes/1048576;f.elements.zdr.checked=state.config.zdr;$('dataDir').textContent=state.config.data_dir;const model=state.config.models[0]?.id||'你的模型名称';$('apiExample').textContent=JSON.stringify({model,messages:[{role:'user',content:'你好'}],stream:true},null,2);if(state.view==='settings')await Promise.all([loadCPAMPPanel(),loadCompatibility(),loadStorage()])} function showCPAMPPanel(result){ state.cpampPanel=result; const served=result.served_page; @@ -242,7 +242,7 @@ $('authForm').onsubmit=async e=>{e.preventDefault();resetDiagnosticViews();state $('credentialForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget,cid=f.elements.id.value,body={label:f.elements.label.value,priority:Number(f.elements.priority.value),disabled:f.elements.disabled.checked};if(f.elements.api_key.value.trim())body.api_key=f.elements.api_key.value.trim();try{const saved=await api('/credentials'+(cid?'?id='+encodeURIComponent(cid):''),{method:cid?'PUT':'POST',body});f.elements.api_key.value='';$('credentialDialog').close();toast('凭据已保存 · '+(saved.registration?.message||'宿主登记待确认'));await Promise.all([loadCredentials(),loadDiagnostics()]).catch(globalError)}catch(e){dialogError('credentialDialog',e)}}; $('modelForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget;try{const rules=JSON.parse(f.elements.rules.value);if(!rules||Array.isArray(rules)||typeof rules!=='object')throw new Error('映射规则必须是 JSON 对象');const index=f.elements.index.value,old=index===''?{}:state.config.models[Number(index)],m={...old,id:f.elements.id.value.trim(),upstream_id:f.elements.upstream_id.value.trim(),name:f.elements.name.value.trim(),supported_endpoints:[f.elements.endpoint.value],thinking_mode:f.elements.endpoint.value==="/messages"?f.elements.thinking_mode.value:"",reasoning_mapping:{enabled:f.elements.reasoning_enabled.checked,rules}};const models=structuredClone(state.config.models);if(index==='')models.push(m);else models[Number(index)]=m;await saveModels(models);$('modelDialog').close();toast('模型已保存')}catch(e){dialogError('modelDialog',e)}}; $('testForm').onsubmit=async e=>{e.preventDefault();const identity=diagnosticIdentity(),f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;$('testResult').hidden=false;$('testResult').textContent='等待模型回复…';try{const result=await api('/models/test',{method:'POST',body:Object.fromEntries(new FormData(f))});if(!state.authenticated||identity!==diagnosticIdentity())return;$('testResult').textContent=JSON.stringify(result.response,null,2);toast('测试完成')}catch(e){if(state.authenticated&&identity===diagnosticIdentity()){$('testResult').textContent='';dialogError('testDialog',e)}}finally{b.disabled=false;if(state.authenticated&&identity===diagnosticIdentity())await loadModelEvidence().catch(globalError)}}; -$('settingsForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget;try{const retention=Number(f.elements.statistics_retention_days.value);if(retention>0&&retention!==state.config.statistics_retention_days&&!await confirmed('设置历史自动过期','超过 '+retention+' 天的统计将被删除,之后延长保留期也无法恢复。继续保存?'))return;await api('/config',{method:'PUT',body:{statistics_retention_days:retention,timeout_seconds:Number(f.elements.timeout_seconds.value),log_retention:Number(f.elements.log_retention.value),usage_refresh_seconds:Number(f.elements.usage_refresh_seconds.value),max_response_bytes:Number(f.elements.max_response_mb.value)*1048576,zdr:f.elements.zdr.checked,cpamp_install_dir:f.elements.cpamp_install_dir.value.trim(),cpamp_config_path:f.elements.cpamp_config_path.value.trim(),cpamp_panel_path:f.elements.cpamp_panel_path.value.trim()}});await loadConfig();toast('设置已保存并生效,无需重启')}catch(e){globalError(e)}}; +$('settingsForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget;try{const retention=Number(f.elements.statistics_retention_days.value);if(retention>0&&retention!==state.config.statistics_retention_days&&!await confirmed('设置历史自动过期','超过 '+retention+' 天的统计将被删除,之后延长保留期也无法恢复。继续保存?'))return;await api('/config',{method:'PUT',body:{statistics_retention_days:retention,timeout_seconds:Number(f.elements.timeout_seconds.value),first_byte_timeout_seconds:Number(f.elements.first_byte_timeout_seconds.value),stream_idle_timeout_seconds:Number(f.elements.stream_idle_timeout_seconds.value),log_retention:Number(f.elements.log_retention.value),usage_refresh_seconds:Number(f.elements.usage_refresh_seconds.value),max_response_bytes:Number(f.elements.max_response_mb.value)*1048576,zdr:f.elements.zdr.checked,cpamp_install_dir:f.elements.cpamp_install_dir.value.trim(),cpamp_config_path:f.elements.cpamp_config_path.value.trim(),cpamp_panel_path:f.elements.cpamp_panel_path.value.trim()}});await loadConfig();toast('设置已保存并生效,无需重启')}catch(e){globalError(e)}}; $('detectCPAMPPanel').onclick=async()=>{try{await saveCPAMPPaths();await Promise.all([loadCPAMPPanel(),loadCompatibility()])}catch(e){globalError(e)}}; $('installCPAMPPanel').onclick=async()=>{const button=$('installCPAMPPanel');button.disabled=true;try{await saveCPAMPPaths();await loadCPAMPPanel();const plan=state.cpampPanel;if(!plan?.installable)throw new Error(plan?.message||'尚未确认安装目标');if(!await confirmed(plan.hot_update_ready?'更新原生面板':'安装原生面板',[...(plan.planned_changes||[]),'操作完成后将检查实际服务页面。'].join('\n'))){button.disabled=false;return}const result=await api('/cpamp/panel/install',{method:'POST',body:{}});showCPAMPPanel(result);toast(result.message);await loadCompatibility().catch(globalError)}catch(e){globalError(e);button.disabled=false}}; for(const name of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])$('settingsForm').elements[name].oninput=()=>{$('installCPAMPPanel').disabled=!state.cpampPanel?.available}; diff --git a/internal/bridge/ui/index.html b/internal/bridge/ui/index.html index 735973a..f6760af 100644 --- a/internal/bridge/ui/index.html +++ b/internal/bridge/ui/index.html @@ -44,7 +44,7 @@