Compare commits
19 Commits
cc3359f773
...
eb46fa3bd3
| Author | SHA1 | Date | |
|---|---|---|---|
| eb46fa3bd3 | |||
| a82754eb40 | |||
| 08343ad576 | |||
| b00ee4c8dc | |||
| 63f7de7cb6 | |||
| a9448125f1 | |||
| f3de590e1b | |||
| 8fc72e87e4 | |||
| b70f49cbef | |||
| c3e622be5d | |||
| 5bd04b6a07 | |||
| a1eb4e244c | |||
| bbe21e14df | |||
| 0b3098ccf6 | |||
| e4789d0539 | |||
| 136912d0ef | |||
| f3b211f745 | |||
| 31f25b54ab | |||
| 5c4f60f2b7 |
138
.github/workflows/release-assets.yml
vendored
Normal file
138
.github/workflows/release-assets.yml
vendored
Normal file
@ -0,0 +1,138 @@
|
||||
name: release-assets
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: windows
|
||||
runs-on: windows-latest
|
||||
artifact_name: ocswitch-desktop-windows-amd64
|
||||
package: release/ocswitch-desktop-windows-amd64.zip
|
||||
output: build/bin/ocswitch-desktop.exe
|
||||
- name: macos
|
||||
runs-on: macos-13
|
||||
artifact_name: ocswitch-desktop-darwin-amd64
|
||||
package: release/ocswitch-desktop-darwin-amd64.zip
|
||||
output: build/bin/ocswitch-desktop.app
|
||||
- name: linux
|
||||
runs-on: ubuntu-22.04
|
||||
artifact_name: ocswitch-desktop-linux-amd64
|
||||
package: release/ocswitch-desktop-linux-amd64.zip
|
||||
output: build/bin/ocswitch-desktop
|
||||
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
run: npm install
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
|
||||
- name: Install Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev build-essential pkg-config
|
||||
|
||||
- name: Verify Wails output name
|
||||
shell: bash
|
||||
env:
|
||||
EXPECTED_OUTPUT: ${{ matrix.output }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
expected = pathlib.Path(os.environ["EXPECTED_OUTPUT"])
|
||||
print(f"expected output: {expected.as_posix()}")
|
||||
PY
|
||||
|
||||
- name: Build desktop app
|
||||
run: wails build -tags desktop_wails
|
||||
|
||||
- name: Package artifact
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_PATH: ${{ matrix.output }}
|
||||
DEST_PATH: ${{ matrix.package }}
|
||||
run: |
|
||||
mkdir -p release
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
import zipfile
|
||||
|
||||
source = pathlib.Path(os.environ["SOURCE_PATH"])
|
||||
dest = pathlib.Path(os.environ["DEST_PATH"])
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
if source.is_dir():
|
||||
for path in source.rglob("*"):
|
||||
if path.is_file():
|
||||
zf.write(path, arcname=f"{source.name}/{path.relative_to(source).as_posix()}")
|
||||
else:
|
||||
zf.write(source, arcname=source.name)
|
||||
|
||||
digest = hashlib.sha256(dest.read_bytes()).hexdigest()
|
||||
dest.with_suffix(dest.suffix + ".sha256").write_text(f"{digest} {dest.name}\n", encoding="ascii")
|
||||
PY
|
||||
|
||||
- name: List packaged files
|
||||
shell: bash
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import pathlib
|
||||
|
||||
for path in sorted(pathlib.Path("release").glob("**/*")):
|
||||
if path.is_file():
|
||||
print(path.as_posix())
|
||||
PY
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: |
|
||||
${{ matrix.package }}
|
||||
${{ matrix.package }}.sha256
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-assets/**
|
||||
22
.github/workflows/release-please.yml
vendored
Normal file
22
.github/workflows/release-please.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
name: release-please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
15
.github/workflows/release-precheck.md
vendored
Normal file
15
.github/workflows/release-precheck.md
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Release Workflow Precheck
|
||||
|
||||
1. Confirm release-please is enabled on `master`.
|
||||
2. Confirm conventional commits are used for release notes grouping.
|
||||
3. Confirm `release-please-config.json` targets the repo root and `CHANGELOG.md`.
|
||||
4. Confirm Wails output name matches `wails.json`:
|
||||
- `build/bin/ocswitch-desktop.exe` on Windows
|
||||
- `build/bin/ocswitch-desktop.app` on macOS
|
||||
- `build/bin/ocswitch-desktop` on Linux
|
||||
5. Confirm Linux runner has the GTK/WebKit packages needed by Wails.
|
||||
6. Confirm `npm install` is sufficient for frontend build in CI.
|
||||
7. Confirm release assets are uploaded only after the GitHub Release is published.
|
||||
8. Confirm each asset has a matching `.sha256` file.
|
||||
9. Confirm tag format is `v*`.
|
||||
10. Confirm the generated release contains the three platform archives.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,5 +2,6 @@
|
||||
/dist/
|
||||
*.tmp
|
||||
output
|
||||
build/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.4.3"
|
||||
"@opencode-ai/plugin": "1.4.6"
|
||||
}
|
||||
}
|
||||
3
.release-please-manifest.json
Normal file
3
.release-please-manifest.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.0.0"
|
||||
}
|
||||
78
.trellis/tasks/04-18-04-18-finish-windows-desktop/task.json
Normal file
78
.trellis/tasks/04-18-04-18-finish-windows-desktop/task.json
Normal file
@ -0,0 +1,78 @@
|
||||
{
|
||||
"id": "04-18-finish-windows-desktop",
|
||||
"name": "04-18-finish-windows-desktop",
|
||||
"title": "Finish Windows desktop integration",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": "feature",
|
||||
"scope": "desktop",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "OpenCode",
|
||||
"assignee": "OpenCode",
|
||||
"createdAt": "2026-04-18",
|
||||
"completedAt": "2026-04-20",
|
||||
"branch": "master",
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"current_phase": 6,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "brainstorm"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "research"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"action": "update-spec"
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"action": "record-session"
|
||||
}
|
||||
],
|
||||
"commit": "8fc72e8",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"build/windows/icon.ico",
|
||||
"frontend/assets.go",
|
||||
"frontend/src/App.tsx",
|
||||
"frontend/src/api.ts",
|
||||
"frontend/src/i18n/locales/en.json",
|
||||
"frontend/src/i18n/locales/zh-CN.json",
|
||||
"frontend/src/styles.css",
|
||||
"internal/desktop/app.go",
|
||||
"internal/desktop/runtime_wails.go",
|
||||
"internal/desktop/tray_wails.go",
|
||||
"internal/desktop/tray_language.go",
|
||||
"internal/desktop/tray_language_windows.go",
|
||||
"internal/desktop/wails.go"
|
||||
],
|
||||
"notes": "Finished the Windows desktop integration pass. Reworked tray startup onto a stable systray event loop with icon embedding, click-to-open behavior, localized menu labels, and Windows system-language fallback. Replaced desktop/window branding assets, wired repository opening through the system browser, and refreshed the providers and aliases list/detail panels into a denser, product-style UI with updated i18n strings.",
|
||||
"meta": {
|
||||
"tests": [
|
||||
"npm run build",
|
||||
"go test -tags desktop_wails ./internal/desktop/..."
|
||||
],
|
||||
"desktopAreas": [
|
||||
"windows tray",
|
||||
"window icon and branding",
|
||||
"providers panel",
|
||||
"aliases panel"
|
||||
]
|
||||
}
|
||||
}
|
||||
100
.trellis/tasks/04-18-windows-desktop-integrations/prd.md
Normal file
100
.trellis/tasks/04-18-windows-desktop-integrations/prd.md
Normal file
@ -0,0 +1,100 @@
|
||||
# Windows Desktop Integrations
|
||||
|
||||
## Summary
|
||||
|
||||
`ocswitch desktop` already has shared Go application services, a React control panel, a browser fallback shell, and a Wails window wrapper. What is still missing is the set of desktop-native integrations that make the Windows build behave like a real resident utility instead of only a packaged webview.
|
||||
|
||||
This task completes that gap with a minimal extension to the current architecture:
|
||||
|
||||
- fix the Wails frontend bridge so the GUI actually talks to Go bindings
|
||||
- add native tray integration for the Wails desktop build
|
||||
- add native notification delivery for desktop events
|
||||
- add launch-at-login support for Windows
|
||||
- preserve the existing browser fallback shell and shared service layer
|
||||
|
||||
## Product Goal
|
||||
|
||||
Provide a Windows-friendly desktop shell for `ocswitch` that can stay resident, expose quick tray controls, surface important events through native notifications, and reopen automatically at login without rewriting existing business logic.
|
||||
|
||||
## Current State
|
||||
|
||||
### Already done
|
||||
|
||||
- `internal/app` owns shared workflows for overview, proxy control, doctor, desktop preferences, and OpenCode sync.
|
||||
- `internal/desktop/http.go` already provides a browser fallback shell with the same workflows.
|
||||
- `frontend/src/App.tsx` already renders the main control panel for overview, desktop prefs, sync, doctor, providers, and aliases.
|
||||
- `internal/desktop/wails.go` already boots a Wails window and hides on close.
|
||||
|
||||
### Missing
|
||||
|
||||
- frontend Wails bridge still points at `window.go.main.App` even though generated bindings live at `window.go.desktop.App`
|
||||
- tray support is only a placeholder and does not expose resident controls
|
||||
- notifications are only a placeholder and do not use Wails runtime APIs
|
||||
- launch-at-login only supports Linux XDG autostart today
|
||||
|
||||
## Requirements
|
||||
|
||||
### Desktop integration
|
||||
|
||||
- Wails desktop build must expose a real system tray on Windows.
|
||||
- Tray menu must at least support opening the window, hiding the window, starting the proxy, stopping the proxy, and quitting the app.
|
||||
- Close-to-background behavior must continue to respect `minimizeToTray` preference.
|
||||
- Native notifications must be available when `notifications` preference is enabled.
|
||||
- Native launch-at-login must be available on Windows through a practical low-complexity mechanism.
|
||||
|
||||
### Reuse and layering
|
||||
|
||||
- Keep `internal/app` as the owner of shared workflows and state transitions.
|
||||
- Keep `internal/desktop` focused on shell integration only.
|
||||
- Do not duplicate proxy, config, or sync business rules inside tray or frontend code.
|
||||
- Browser fallback shell must remain operational.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Tray implementation
|
||||
|
||||
Wails v2 does not provide a stable public tray API, so tray support should use `github.com/getlantern/systray` inside the `desktop_wails` build.
|
||||
|
||||
Why this path:
|
||||
|
||||
- lowest implementation cost in current Go codebase
|
||||
- already present in module graph
|
||||
- works alongside Wails window lifecycle
|
||||
- avoids replacing the desktop shell framework
|
||||
|
||||
### Notification implementation
|
||||
|
||||
Use Wails runtime notification APIs for the desktop build. This keeps notifications native and avoids introducing another platform adapter.
|
||||
|
||||
Expected event coverage:
|
||||
|
||||
- proxy started
|
||||
- proxy stopped
|
||||
- OpenCode sync applied
|
||||
- notifications preference enabled
|
||||
|
||||
### Windows launch-at-login
|
||||
|
||||
Implement Windows startup integration by writing a `.cmd` launcher into the user Startup folder.
|
||||
|
||||
Why this path:
|
||||
|
||||
- much simpler than generating `.lnk`
|
||||
- good enough for a local utility
|
||||
- easy to inspect and delete
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. No migration from Wails to Fyne unless current approach proves unworkable.
|
||||
2. No tray support requirement for browser fallback mode.
|
||||
3. No redesign of the React control panel information architecture.
|
||||
4. No expansion into advanced desktop telemetry or background sync daemons.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Wails frontend can successfully call Go bindings through generated `desktop` namespace.
|
||||
2. Windows desktop build can be hidden and restored through tray controls.
|
||||
3. Tray can start and stop proxy without reopening main window.
|
||||
4. Enabling notifications allows native desktop alerts for key lifecycle events.
|
||||
5. Enabling launch at login creates Windows startup entry; disabling removes it.
|
||||
6. Existing browser fallback flow and current tests remain green.
|
||||
80
.trellis/tasks/04-18-windows-desktop-integrations/task.json
Normal file
80
.trellis/tasks/04-18-windows-desktop-integrations/task.json
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"id": "windows-desktop-integrations",
|
||||
"name": "windows-desktop-integrations",
|
||||
"title": "Finish Windows desktop integrations",
|
||||
"description": "Finish the desktop-shell implementation for Windows by wiring the Wails bridge correctly, adding tray controls, native notifications, and launch-at-login support without breaking the browser fallback shell.",
|
||||
"status": "completed",
|
||||
"dev_type": "feature",
|
||||
"scope": "desktop",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "OpenCode",
|
||||
"assignee": "OpenCode",
|
||||
"createdAt": "2026-04-18",
|
||||
"completedAt": "2026-04-18",
|
||||
"branch": "master",
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"current_phase": 6,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "brainstorm"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "research"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"action": "update-spec"
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"action": "record-session"
|
||||
}
|
||||
],
|
||||
"commit": "5c4f60f",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
".trellis/tasks/04-18-windows-desktop-integrations/prd.md",
|
||||
"internal/desktop/app.go",
|
||||
"internal/desktop/tray.go",
|
||||
"internal/desktop/tray_wails.go",
|
||||
"internal/desktop/notify.go",
|
||||
"internal/desktop/autostart.go",
|
||||
"internal/desktop/autostart_test.go",
|
||||
"internal/desktop/runtime_wails.go",
|
||||
"internal/desktop/runtime_fallback.go",
|
||||
"internal/fileutil/fileutil.go",
|
||||
"internal/fileutil/lock_unix.go",
|
||||
"internal/fileutil/lock_windows.go",
|
||||
"internal/config/config_test.go",
|
||||
"internal/config/file_lock_testhelper_unix_test.go",
|
||||
"internal/config/file_lock_testhelper_windows_test.go",
|
||||
"frontend/index.html",
|
||||
"frontend/src/api.ts",
|
||||
"frontend/src/env.d.ts",
|
||||
"frontend/tsconfig.json",
|
||||
"frontend/dist/index.html",
|
||||
"frontend/dist/assets/"
|
||||
],
|
||||
"notes": "Completed the Windows desktop integration pass on top of the existing desktop shell GUI. Fixed the Wails frontend bridge namespace mismatch, added a real Wails-only tray implementation with systray, wired native notifications through Wails runtime, added Windows Startup-folder launch-at-login support, and fixed file locking so Windows desktop/config flows can build and test. Verification passed for `npm run build`, `go test ./internal/desktop`, `go test -tags desktop_wails ./internal/desktop`, and `go test ./internal/config ./internal/opencode`.",
|
||||
"meta": {
|
||||
"wailsBridgeNamespace": "window.go.desktop.App",
|
||||
"trayImplementation": "github.com/getlantern/systray",
|
||||
"notificationImplementation": "wails runtime notifications",
|
||||
"windowsAutostart": "Startup folder .cmd launcher"
|
||||
}
|
||||
}
|
||||
26
.trellis/tasks/04-19-04-19-proxy-sse-minimal-fix/prd.md
Normal file
26
.trellis/tasks/04-19-04-19-proxy-sse-minimal-fix/prd.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Proxy SSE Minimal Fix
|
||||
|
||||
## Summary
|
||||
|
||||
The local Responses proxy can report `upstream_status=200` while still handing OpenCode a broken stream in two narrow cases inside `internal/proxy/server.go`:
|
||||
|
||||
- the upstream returns `200` with `text/event-stream` but closes before sending any bytes
|
||||
- the upstream sends one SSE chunk and then stays silent longer than `streamIdleTimeout`, causing the proxy to terminate a still-valid SSE stream
|
||||
|
||||
This task applies the smallest possible fix in the proxy layer only, without changing CLI, desktop, app, config, or public interfaces.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Keep the existing `firstByteTimeout` behavior.
|
||||
- Treat `readFirstChunk()` returning `io.EOF` with zero bytes as a retryable pre-first-byte failure.
|
||||
- Do not write downstream `200` for that empty first-read case.
|
||||
- Preserve existing failover behavior for transport errors and `429`/`5xx` responses.
|
||||
- After the first chunk succeeds, bypass `streamIdleTimeout` only for `text/event-stream` responses.
|
||||
- Keep non-SSE responses on the existing idle-timeout path.
|
||||
- Add regression coverage for empty-200-SSE failover and long-idle SSE continuation.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. No proxy refactor.
|
||||
2. No new config flags.
|
||||
3. No changes outside `internal/proxy/server.go` and `internal/proxy/server_test.go`.
|
||||
65
.trellis/tasks/04-19-04-19-proxy-sse-minimal-fix/task.json
Normal file
65
.trellis/tasks/04-19-04-19-proxy-sse-minimal-fix/task.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"id": "04-19-proxy-sse-minimal-fix",
|
||||
"name": "04-19-proxy-sse-minimal-fix",
|
||||
"title": "Fix proxy SSE minimal failure handling",
|
||||
"description": "Minimal proxy fix for empty 200 SSE failover and SSE idle timeout handling",
|
||||
"status": "completed",
|
||||
"dev_type": "feature",
|
||||
"scope": "proxy",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "OpenCode",
|
||||
"assignee": "OpenCode",
|
||||
"createdAt": "2026-04-19",
|
||||
"completedAt": "2026-04-19",
|
||||
"branch": "master",
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"current_phase": 6,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "brainstorm"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "research"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"action": "update-spec"
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"action": "record-session"
|
||||
}
|
||||
],
|
||||
"commit": "e4789d0",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
".trellis/tasks/04-19-04-19-proxy-sse-minimal-fix/prd.md",
|
||||
"internal/proxy/server.go",
|
||||
"internal/proxy/server_test.go"
|
||||
],
|
||||
"notes": "Applied a minimal SSE reliability fix in the local Responses proxy. Empty `200 text/event-stream` first reads are now treated as retryable pre-first-byte failures so failover can continue before any downstream status is written. After the first SSE chunk arrives, the proxy no longer applies `streamIdleTimeout` to later SSE reads, while non-SSE responses keep the existing timeout behavior. Added regression coverage for empty-200 SSE failover and long-idle SSE continuation, and verified with `go test ./internal/proxy`.",
|
||||
"meta": {
|
||||
"tests": [
|
||||
"go test ./internal/proxy"
|
||||
],
|
||||
"regressions": [
|
||||
"empty 200 SSE failover",
|
||||
"SSE bypasses idle timeout after first chunk"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
{
|
||||
"id": "desktop-gui-i18n-ux-refresh-impl",
|
||||
"name": "desktop-gui-i18n-ux-refresh-impl",
|
||||
"title": "Implement desktop GUI i18n and UX refresh",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": "feature",
|
||||
"scope": "desktop",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "OpenCode",
|
||||
"assignee": "OpenCode",
|
||||
"createdAt": "2026-04-19",
|
||||
"completedAt": "2026-04-19",
|
||||
"branch": "master",
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"current_phase": 6,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "brainstorm"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "research"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"action": "update-spec"
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"action": "record-session"
|
||||
}
|
||||
],
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "Completed the desktop GUI i18n and UX refresh. Extended desktop preferences with theme and language across Go and frontend, added i18next-based localization with en-US/zh-CN/system support, rebuilt the desktop shell into a tabbed Wails/browser-shared control panel, refreshed layout and theme tokens, updated the generated Wails desktop prefs models, documented desktop build and usage in the README files, and verified with `npm run build`, `go test ./internal/app ./internal/desktop`, and `wails build -tags desktop_wails`.",
|
||||
"meta": {
|
||||
"desktopTabs": [
|
||||
"overview",
|
||||
"providers",
|
||||
"aliases",
|
||||
"sync",
|
||||
"settings"
|
||||
],
|
||||
"languages": [
|
||||
"en-US",
|
||||
"zh-CN",
|
||||
"system"
|
||||
],
|
||||
"themes": [
|
||||
"light",
|
||||
"dark",
|
||||
"system"
|
||||
],
|
||||
"desktopExe": "build/bin/ocswitch-desktop.exe"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
{"file": ".opencode/commands/trellis/finish-work.md", "reason": "Finish work checklist"}
|
||||
{"file": ".opencode/commands/trellis/check.md", "reason": "Code quality check spec"}
|
||||
@ -0,0 +1,127 @@
|
||||
# GUI 文案收口规划
|
||||
|
||||
## 目的
|
||||
|
||||
这份文件不是最终翻译稿,而是为后续“人工精细翻译”做结构准备。
|
||||
|
||||
本轮建议先把所有可见文案按命名空间收口到 i18n 资源中,再由人工直接维护 `en.json` / `zh-CN.json`。
|
||||
|
||||
## 建议命名空间
|
||||
|
||||
### app
|
||||
|
||||
- 应用标题
|
||||
- 副标题
|
||||
- 版本 / shell / 配置路径说明
|
||||
|
||||
### nav
|
||||
|
||||
- 左侧 tab 名称
|
||||
- tab 简短描述
|
||||
|
||||
### overview
|
||||
|
||||
- 概览标题
|
||||
- 代理状态
|
||||
- 统计卡标题
|
||||
- 主操作按钮
|
||||
- 调试详情标题
|
||||
|
||||
### providers
|
||||
|
||||
- provider 列表空态
|
||||
- 创建 / 编辑表单标签
|
||||
- 导入区文案
|
||||
- 列表项操作按钮
|
||||
- 保存 / 删除 / 启停状态消息
|
||||
|
||||
### aliases
|
||||
|
||||
- alias 列表空态
|
||||
- alias 表单标签
|
||||
- target 绑定表单标签
|
||||
- target 行操作按钮
|
||||
- 绑定 / 解绑 / 启停状态消息
|
||||
|
||||
### sync
|
||||
|
||||
- sync 标题
|
||||
- target path / model / small_model 标签
|
||||
- preview / apply 按钮
|
||||
- preview 结果摘要
|
||||
- apply 结果摘要
|
||||
|
||||
### doctor
|
||||
|
||||
- doctor 标题
|
||||
- 检查中 / 成功 / 失败状态
|
||||
- 问题列表
|
||||
- 空态与说明文案
|
||||
|
||||
### settings
|
||||
|
||||
- 主题设置
|
||||
- 语言设置
|
||||
- 桌面行为设置
|
||||
- 关于信息
|
||||
|
||||
### actions
|
||||
|
||||
- refresh
|
||||
- save
|
||||
- reset
|
||||
- edit
|
||||
- delete
|
||||
- enable
|
||||
- disable
|
||||
- preview
|
||||
- apply
|
||||
- runDoctor
|
||||
|
||||
### messages
|
||||
|
||||
- refreshing
|
||||
- saving
|
||||
- saved
|
||||
- applying
|
||||
- previewing
|
||||
- importing
|
||||
- loading
|
||||
- noData
|
||||
|
||||
### errors
|
||||
|
||||
- requestFailed
|
||||
- bridgeUnavailable
|
||||
- invalidHeader
|
||||
|
||||
## 需要纳入翻译的文案类型
|
||||
|
||||
1. 页面标题与模块标题
|
||||
2. 按钮文本
|
||||
3. 表单标签
|
||||
4. placeholder
|
||||
5. 空态提示
|
||||
6. 成功 / 失败 / 进行中状态文案
|
||||
7. 折叠区标题与说明性文字
|
||||
|
||||
## 不建议翻译的内容
|
||||
|
||||
1. provider id
|
||||
2. alias 原始值
|
||||
3. model id
|
||||
4. 文件路径
|
||||
5. URL
|
||||
6. 原始技术错误详情
|
||||
|
||||
## 初始覆盖源文件
|
||||
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/api.ts`
|
||||
|
||||
## 实施建议
|
||||
|
||||
1. 先把现有硬编码文案替换为 key
|
||||
2. `en.json` 先完整收口
|
||||
3. `zh-CN.json` 先给出可用翻译
|
||||
4. 人工精修阶段只改 locale 文件,不再回头搜 JSX
|
||||
@ -0,0 +1 @@
|
||||
{"file": ".opencode/commands/trellis/check.md", "reason": "Code quality check spec"}
|
||||
@ -0,0 +1 @@
|
||||
{"file": ".trellis/workflow.md", "reason": "Project workflow and conventions"}
|
||||
563
.trellis/tasks/04-19-desktop-gui-i18n-ux-refresh/prd.md
Normal file
563
.trellis/tasks/04-19-desktop-gui-i18n-ux-refresh/prd.md
Normal file
@ -0,0 +1,563 @@
|
||||
# Desktop GUI i18n / UI-UX / Theme Refresh
|
||||
|
||||
## 概要
|
||||
|
||||
当前桌面 GUI 已具备可用的基础功能,但前端还停留在“单文件控制台”形态:
|
||||
|
||||
- `frontend/src/App.tsx` 单文件承载全部模块和交互,页面信息密度高但层次弱
|
||||
- 当前所有主内容都堆在一个长页面里,模块边界不清晰
|
||||
- 文案基本为英文硬编码,尚未建立 i18n 机制
|
||||
- `frontend/src/styles.css` 仅支持一套暗色视觉,缺少主题切换能力
|
||||
- 桌面偏好当前只包含 `launchAtLogin` / `minimizeToTray` / `notifications`
|
||||
|
||||
本任务的目标不是直接做完全部 GUI 重构,而是输出一版可以直接进入实现阶段的方案设计,并把范围、分层、数据契约、实施顺序和验收标准先固定下来。
|
||||
|
||||
## 产品目标
|
||||
|
||||
这轮 GUI 优化聚焦三个结果:
|
||||
|
||||
1. 建立正式的 i18n 基础设施,至少支持 `zh-CN` 与 `en-US`
|
||||
2. 将当前单页大杂烩改为左侧纵向 tab 的模块化信息架构
|
||||
3. 支持 `system / light / dark` 三档主题切换
|
||||
|
||||
同时满足以下使用体验要求:
|
||||
|
||||
- 桌面主窗口默认信息组织清晰,不需要依赖通篇向下滚动来完成主要操作
|
||||
- 尽量实现“页面不滚动、局部区域滚动”
|
||||
- 保持 Wails 桌面壳与浏览器 fallback 共用同一套前端
|
||||
- 不重写现有 Go 侧业务逻辑,只补齐桌面偏好与前端展示能力
|
||||
|
||||
## 当前事实与约束
|
||||
|
||||
### 现有前端事实
|
||||
|
||||
- 前端入口很轻:`frontend/src/main.tsx` 仅挂载 `App`
|
||||
- 当前无路由库、无全局状态库、无 i18n 依赖
|
||||
- 当前 API 通过 `frontend/src/api.ts` 在 Wails bridge 与 HTTP fallback 间切换
|
||||
- 当前页面结构全部写在 `frontend/src/App.tsx`,内容包含:概览、桌面偏好、OpenCode 同步、Doctor、Providers、Aliases
|
||||
- 当前视觉完全由 `frontend/src/styles.css` 管理,且根节点声明为暗色 `color-scheme: dark`
|
||||
|
||||
### 现有跨层契约事实
|
||||
|
||||
- `internal/config/config.go` 中 `config.Desktop` 只有三个布尔字段
|
||||
- `internal/app/types.go` 中 `DesktopPrefsView` / `DesktopPrefsInput` 同样只有三个布尔字段
|
||||
- `internal/app/service.go` 的 `SaveDesktopPrefs()` 目前直接把这三个字段写回配置文件
|
||||
- `internal/app/service_test.go` 与 `internal/desktop/http_test.go` 已覆盖桌面偏好保存路径,适合作为后续扩展 `theme` / `language` 的回归测试入口
|
||||
|
||||
### 现有窗口约束
|
||||
|
||||
- `internal/desktop/wails.go` 当前窗口尺寸为 `1280 x 880`
|
||||
- 最小尺寸为 `980 x 720`
|
||||
|
||||
这个尺寸对“左侧纵向 tab + 主内容双栏”是偏紧的,尤其是 Providers / Aliases 两个高密度模块,因此布局方案需要同时考虑:
|
||||
|
||||
- 默认窗口尺寸下尽量不出现整页纵向滚动
|
||||
- 窄窗口时允许局部折叠或内部滚动,但仍保持结构稳定
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不引入新的后端业务层或重写 `internal/app`、`internal/config`、`internal/opencode`、`internal/proxy` 的业务归属
|
||||
2. 不为了 tab 导航引入完整路由系统,除非 hash 方案无法满足需要
|
||||
3. 不在这一轮引入大型 UI 框架或设计系统迁移
|
||||
4. 不在这一轮扩展日志分析、历史记录、远程控制等新产品面
|
||||
5. 不在这一轮做复杂动画、图标系统或品牌视觉重塑
|
||||
|
||||
## 核心设计决策
|
||||
|
||||
### 1. i18n 方案
|
||||
|
||||
推荐使用:`i18next + react-i18next`
|
||||
|
||||
选择原因:
|
||||
|
||||
- 对 React 生态成熟,后续扩展语言成本低
|
||||
- 支持命名空间、fallback、插值、运行时切换
|
||||
- 比手写 context 字典更适合后续“人工精细翻译”与增量维护
|
||||
- 与当前前端体量相匹配,不需要引入更重的国际化平台
|
||||
|
||||
推荐落地结构:
|
||||
|
||||
```text
|
||||
frontend/src/i18n/
|
||||
index.ts
|
||||
locales/
|
||||
en.json
|
||||
zh-CN.json
|
||||
```
|
||||
|
||||
推荐规则:
|
||||
|
||||
- `en.json` 作为 source of truth
|
||||
- `zh-CN.json` 先提供可用翻译,后续再人工精修
|
||||
- 所有可见文案统一改为 key,不再直接在 JSX 中写死英文
|
||||
- 所有状态文案、按钮文案、空态文案、表单标签、placeholder、错误提示都纳入翻译
|
||||
- 不翻译配置路径、provider id、model id、URL、技术错误原文等数据性内容
|
||||
|
||||
推荐 key 命名方式:
|
||||
|
||||
```text
|
||||
app.title
|
||||
nav.overview
|
||||
overview.proxyRunning
|
||||
providers.form.baseUrl
|
||||
aliases.empty
|
||||
sync.previewChanges
|
||||
settings.theme.system
|
||||
messages.refreshing
|
||||
errors.bridgeUnavailable
|
||||
```
|
||||
|
||||
### 2. 语言偏好持久化
|
||||
|
||||
推荐把语言偏好纳入桌面偏好,而不是只放前端本地状态。
|
||||
|
||||
建议新增字段:
|
||||
|
||||
- `language`: `system | en-US | zh-CN`
|
||||
|
||||
对应变更位置:
|
||||
|
||||
- `internal/config/config.go` 的 `config.Desktop`
|
||||
- `internal/app/types.go` 的 `DesktopPrefsView` / `DesktopPrefsInput`
|
||||
- `internal/app/service.go` 的读写映射
|
||||
- `frontend/src/types.ts`
|
||||
|
||||
语言决议顺序:
|
||||
|
||||
1. 用户显式选择的 `language`
|
||||
2. 若为 `system`,使用 `navigator.language`
|
||||
3. 若系统语言不在支持列表,则回退到 `en-US`
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 桌面应用重启后保留用户偏好
|
||||
- 浏览器 fallback 与 Wails 行为一致
|
||||
- 后续新增日语等语言时不需要重构偏好模型
|
||||
|
||||
### 3. 主题方案
|
||||
|
||||
推荐继续使用原生 CSS,不引入 Tailwind 或 CSS-in-JS 重构。
|
||||
|
||||
建议新增字段:
|
||||
|
||||
- `theme`: `system | light | dark`
|
||||
|
||||
实现原则:
|
||||
|
||||
- 前端只维护一个“用户选择主题”字段
|
||||
- 运行时计算一个 `resolvedTheme`,结果为 `light` 或 `dark`
|
||||
- 把结果写到 `document.documentElement.dataset.theme`
|
||||
- 用 CSS 变量承载所有颜色 token
|
||||
|
||||
推荐样式组织:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: ...;
|
||||
--panel: ...;
|
||||
--text: ...;
|
||||
--muted: ...;
|
||||
--border: ...;
|
||||
--accent: ...;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] { ... }
|
||||
html[data-theme='light'] { ... }
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 现在的 `:root { color-scheme: dark; }` 需要改为按主题动态切换
|
||||
- 表单控件、滚动条、badge、panel、背景渐变都需要转为 token 化
|
||||
- 不要在各组件里散落 `if (theme === 'dark')` 之类分支,主题只通过 CSS token 生效
|
||||
|
||||
### 4. 导航与信息架构
|
||||
|
||||
推荐使用左侧纵向 tab,且 tab 状态优先使用 hash 管理,而不是新增路由依赖。
|
||||
|
||||
推荐 tab:
|
||||
|
||||
1. `overview` 概览
|
||||
2. `providers` 提供商
|
||||
3. `aliases` 别名路由
|
||||
4. `sync` 同步与体检
|
||||
5. `settings` 设置
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 这 5 个模块正好对应当前功能边界
|
||||
- hash 方案足够轻量,Wails 与 HTTP fallback 都可直接工作
|
||||
- 可支持 `#providers` 这类深链接,便于问题定位与后续文档引用
|
||||
|
||||
不建议保留当前结构:
|
||||
|
||||
- 概览、设置、同步、Doctor、Providers、Aliases 在单页堆叠
|
||||
- 大量 `pre` 原始 JSON 长块直接暴露在主流程中
|
||||
|
||||
### 5. 页面滚动策略
|
||||
|
||||
目标不是“任何情况下零滚动”,而是:
|
||||
|
||||
- 主页面整体尽量不产生纵向长滚动
|
||||
- 长列表和明细列表在局部 pane 内滚动
|
||||
- 表单和操作区域尽量固定在视口中
|
||||
|
||||
推荐布局原则:
|
||||
|
||||
- 应用根容器使用接近全高布局
|
||||
- 左侧 sidebar 固定宽度
|
||||
- 右侧内容区使用 tab 面板切换
|
||||
- Providers / Aliases 使用左右 split-pane
|
||||
- 列表区独立 `overflow: auto`
|
||||
- 原始 JSON / 诊断详情放入折叠区或次级详情区,不占主操作流
|
||||
|
||||
## 推荐信息架构
|
||||
|
||||
### Overview Tab
|
||||
|
||||
目标:进入应用后 5 秒内看懂当前状态,并完成最常见的启停操作。
|
||||
|
||||
推荐内容:
|
||||
|
||||
- 顶部状态卡:代理状态、provider 数、alias 数、可路由 alias 数
|
||||
- 主操作区:`Refresh`、`Start proxy`、`Stop proxy`、`Run doctor`
|
||||
- 环境信息:版本、shell、配置路径
|
||||
- 简版健康摘要:最近一次 doctor 状态或提示入口
|
||||
|
||||
不推荐继续保留:
|
||||
|
||||
- 默认展示完整 `overview` JSON 大块文本
|
||||
|
||||
建议:
|
||||
|
||||
- 原始 JSON 仅在“调试详情”折叠区显示
|
||||
|
||||
### Providers Tab
|
||||
|
||||
目标:高频管理 provider,避免当前“左边长表单 + 右边长列表 + 整页继续往下”的阅读负担。
|
||||
|
||||
推荐结构:
|
||||
|
||||
- 左侧:provider 列表、搜索、状态筛选、`新建 provider`
|
||||
- 右侧:当前选中 provider 的编辑表单
|
||||
- 右上或右下:导入 OpenCode provider 的辅助卡片
|
||||
|
||||
交互原则:
|
||||
|
||||
- “创建”与“编辑”共用同一表单
|
||||
- 点击列表项进入编辑态
|
||||
- 删除、启用、禁用作为列表项级操作
|
||||
- `Headers` 与 `Models` 属于细节信息,不要默认铺得过长
|
||||
|
||||
列表过长时:
|
||||
|
||||
- 仅让左侧列表内部滚动
|
||||
- 右侧表单固定显示
|
||||
|
||||
### Aliases Tab
|
||||
|
||||
目标:把“别名编辑”和“target 绑定管理”从当前混排状态拆开。
|
||||
|
||||
推荐结构:
|
||||
|
||||
- 左侧:alias 列表、状态摘要、`新建 alias`
|
||||
- 右侧上半区:alias 基本信息编辑
|
||||
- 右侧下半区:targets 表格或卡片列表
|
||||
|
||||
交互原则:
|
||||
|
||||
- `Bind target` 不再作为全局独立表单漂浮存在,而是明确附着到当前 alias
|
||||
- 当未选中 alias 时,右侧给出“请选择或新建 alias”空态
|
||||
- target 的启用/停用/解绑应靠近 target 行,不要再依赖跨区域操作
|
||||
|
||||
### Sync Tab
|
||||
|
||||
目标:把 OpenCode 同步与 Doctor 合并成“维护与诊断”模块,但在视觉上清楚分区。
|
||||
|
||||
推荐结构:
|
||||
|
||||
- 左列:OpenCode sync 表单与 preview / apply 结果
|
||||
- 右列:Doctor 摘要、问题列表、重新检查按钮
|
||||
|
||||
展示方式:
|
||||
|
||||
- preview / apply 结果先展示结构化摘要
|
||||
- 原始结果放在折叠详情中
|
||||
- doctor 结果优先展示 `OK / 有问题`、问题数量和核心问题列表
|
||||
|
||||
### Settings Tab
|
||||
|
||||
目标:把“桌面偏好”和“外观语言偏好”统一收口。
|
||||
|
||||
推荐内容:
|
||||
|
||||
- Appearance
|
||||
- Language
|
||||
- Desktop behavior
|
||||
- About / debug info
|
||||
|
||||
建议顺序:
|
||||
|
||||
1. 主题选择:`跟随系统 / 浅色 / 深色`
|
||||
2. 语言选择:`跟随系统 / 中文 / English`
|
||||
3. 桌面行为:开机启动、最小化到托盘、通知
|
||||
4. 应用信息:版本、shell、配置路径
|
||||
|
||||
这会把原本散落在首页的桌面偏好移到更符合用户心智的位置。
|
||||
|
||||
## 推荐前端结构调整
|
||||
|
||||
当前 `App.tsx` 已达到必须拆分的规模。推荐最小可控拆分如下:
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
App.tsx
|
||||
api.ts
|
||||
main.tsx
|
||||
types.ts
|
||||
styles.css
|
||||
i18n/
|
||||
index.ts
|
||||
locales/
|
||||
en.json
|
||||
zh-CN.json
|
||||
app/
|
||||
tabs.ts
|
||||
useDesktopPreferences.ts
|
||||
useResolvedTheme.ts
|
||||
features/
|
||||
overview/OverviewTab.tsx
|
||||
providers/ProvidersTab.tsx
|
||||
aliases/AliasesTab.tsx
|
||||
sync/SyncTab.tsx
|
||||
settings/SettingsTab.tsx
|
||||
shared/
|
||||
AppSidebar.tsx
|
||||
AppHeader.tsx
|
||||
Panel.tsx
|
||||
```
|
||||
|
||||
拆分原则:
|
||||
|
||||
- API 访问仍保留在 `api.ts`
|
||||
- 跨模块共享状态仍由 `App.tsx` 统一拥有
|
||||
- tab 组件只接收明确 props,不私自重复请求数据
|
||||
- theme / language 的解析逻辑抽到 hook 或 util,避免每个 tab 重复判断
|
||||
|
||||
## 跨层数据契约变更
|
||||
|
||||
### 配置层
|
||||
|
||||
`internal/config/config.go`
|
||||
|
||||
建议将:
|
||||
|
||||
```go
|
||||
type Desktop struct {
|
||||
LaunchAtLogin bool
|
||||
MinimizeToTray bool
|
||||
Notifications bool
|
||||
}
|
||||
```
|
||||
|
||||
扩展为:
|
||||
|
||||
```go
|
||||
type Desktop struct {
|
||||
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
|
||||
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
|
||||
Notifications bool `json:"notifications,omitempty"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
允许值建议:
|
||||
|
||||
- `Theme`: `system | light | dark`
|
||||
- `Language`: `system | en-US | zh-CN`
|
||||
|
||||
### 应用层 DTO
|
||||
|
||||
`internal/app/types.go`
|
||||
|
||||
- `DesktopPrefsView`
|
||||
- `DesktopPrefsInput`
|
||||
- 如需保证兼容性,可继续让 `DesktopPrefsSaveResult` 结构不变,只扩展 `prefs` 内容
|
||||
|
||||
### 应用层服务
|
||||
|
||||
`internal/app/service.go`
|
||||
|
||||
需要新增:
|
||||
|
||||
- 对 `theme` / `language` 的合法值归一化
|
||||
- 对未知值回退到 `system`
|
||||
- `desktopPrefsView()` 的新字段映射
|
||||
|
||||
建议不要把合法性校验放到前端单独维护,前后端都应该认同同一套枚举语义。
|
||||
|
||||
### 前端类型
|
||||
|
||||
`frontend/src/types.ts`
|
||||
|
||||
需要扩展:
|
||||
|
||||
- `DesktopPrefsView`
|
||||
- `DesktopPrefsSaveResult`
|
||||
|
||||
建议新增:
|
||||
|
||||
- `type ThemePreference = 'system' | 'light' | 'dark'`
|
||||
- `type LanguagePreference = 'system' | 'en-US' | 'zh-CN'`
|
||||
|
||||
## 实施顺序
|
||||
|
||||
推荐拆成 4 个小阶段,按风险从低到高推进。
|
||||
|
||||
### Phase 1: 补齐桌面偏好契约
|
||||
|
||||
目标:先把 `theme` / `language` 变成可保存、可读取、可测试的数据。
|
||||
|
||||
涉及:
|
||||
|
||||
- `internal/config/config.go`
|
||||
- `internal/app/types.go`
|
||||
- `internal/app/service.go`
|
||||
- `internal/app/service_test.go`
|
||||
- `internal/desktop/http_test.go`
|
||||
- `frontend/src/types.ts`
|
||||
|
||||
验收:
|
||||
|
||||
- 保存桌面偏好时可写入 `theme` / `language`
|
||||
- 旧配置不报错,缺省值按 `system` 处理
|
||||
- 现有桌面偏好测试继续通过,并增加新字段断言
|
||||
|
||||
### Phase 2: 搭 i18n 与主题基础设施
|
||||
|
||||
目标:先把底座搭起来,再替换可见文案。
|
||||
|
||||
涉及:
|
||||
|
||||
- 新增 i18n 初始化
|
||||
- 新增中英文资源文件
|
||||
- 把现有根样式改为 token 化
|
||||
- 新增主题解析与 DOM 同步逻辑
|
||||
- Settings 中先提供主题 / 语言切换控件
|
||||
|
||||
验收:
|
||||
|
||||
- 应用启动后可按配置或系统语言切换文案
|
||||
- 应用启动后可按配置或系统主题切换样式
|
||||
- Wails 与 HTTP fallback 行为一致
|
||||
|
||||
### Phase 3: 重构为左侧纵向 tab 布局
|
||||
|
||||
目标:把当前单页拆成清晰模块,不改变业务能力。
|
||||
|
||||
涉及:
|
||||
|
||||
- 提炼 `Sidebar` 与 tab metadata
|
||||
- 将当前大页面拆为 5 个 tab 内容区
|
||||
- 将 Provider / Alias 改为 split-pane 布局
|
||||
- 将 JSON 大块输出收进折叠区或调试区
|
||||
|
||||
验收:
|
||||
|
||||
- 默认视图下无需整页向下滚动才能完成主要操作
|
||||
- 大部分高频操作都能在当前 tab 内完成
|
||||
- hash 切换 tab 正常,可直接进入某个模块
|
||||
|
||||
### Phase 4: 文案收口与中文首轮翻译
|
||||
|
||||
目标:把所有硬编码文案收口到资源文件,并提供首轮中文。
|
||||
|
||||
涉及:
|
||||
|
||||
- 清理残余硬编码字符串
|
||||
- 生成文案清单供人工精修
|
||||
- 完成 `zh-CN` 首轮翻译
|
||||
|
||||
验收:
|
||||
|
||||
- 主界面无裸露英文硬编码
|
||||
- 翻译 key 命名稳定、按模块组织
|
||||
- 人工精修时只需要维护 locale 文件,不需要再搜 JSX
|
||||
|
||||
## 测试与验证建议
|
||||
|
||||
### Go 侧
|
||||
|
||||
- `go test ./internal/app`
|
||||
- `go test ./internal/desktop`
|
||||
|
||||
重点校验:
|
||||
|
||||
- 旧配置读取兼容
|
||||
- 新偏好字段保存正确
|
||||
- HTTP 接口序列化包含新字段
|
||||
|
||||
### 前端侧
|
||||
|
||||
当前仓库未引入测试框架,本轮至少应保留:
|
||||
|
||||
- `npm run build`
|
||||
|
||||
手动验证清单:
|
||||
|
||||
- 语言切换后 tab、表单、状态文案、空态是否同步变化
|
||||
- 主题切换后按钮、输入框、badge、panel、背景是否都正确
|
||||
- 窗口宽度在默认值与最小值附近时,布局是否稳定
|
||||
- hash 切换 tab 与刷新后定位是否符合预期
|
||||
- Wails / 浏览器 fallback 两种壳下切换行为是否一致
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
### 风险 1: `App.tsx` 一次性重构过大
|
||||
|
||||
缓解:
|
||||
|
||||
- 先抽 tab 壳与 shared 组件
|
||||
- 每拆一个 tab 就保留原有业务处理函数,不同时改业务与 UI
|
||||
|
||||
### 风险 2: i18n key 粒度不稳定,后续翻译返工
|
||||
|
||||
缓解:
|
||||
|
||||
- 先按模块命名空间统一 key
|
||||
- 避免把整句状态文案拼接在多个地方
|
||||
- 动态文案尽量通过插值模板统一
|
||||
|
||||
### 风险 3: 主题切换改动大,容易出现漏色
|
||||
|
||||
缓解:
|
||||
|
||||
- 先把当前样式中的颜色集中成 token
|
||||
- 搜索所有十六进制颜色和 `rgba()`,逐步收口
|
||||
|
||||
### 风险 4: 窄窗口下 tab 布局拥挤
|
||||
|
||||
缓解:
|
||||
|
||||
- 默认保持左侧 tab
|
||||
- 在低于阈值时允许 sidebar 收缩
|
||||
- 若验证后仍过于拥挤,再上调 `MinWidth`,建议目标区间为 `1100-1120`
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. `DesktopPrefs` 契约中包含 `theme` 与 `language`,并可在配置中持久化
|
||||
2. 前端引入正式 i18n 机制,至少可在 `en-US` 与 `zh-CN` 间切换
|
||||
3. 主界面切换为左侧纵向 tab,至少包含概览、提供商、别名路由、同步与体检、设置五个模块
|
||||
4. 主题支持 `system / light / dark`
|
||||
5. 主流程页面不再依赖整页长滚动;长列表改为局部 pane 内滚动
|
||||
6. 主要可见文案已从 JSX 抽离到翻译资源文件
|
||||
7. 构建与现有相关测试路径不回归
|
||||
|
||||
## 建议的下一步
|
||||
|
||||
1. 先实现 Phase 1,补齐 `theme` / `language` 跨层契约
|
||||
2. 再实现 Phase 2,搭建 i18n 与 CSS token 底座
|
||||
3. 最后进入 tab 布局重构,避免同时改数据层与视觉层
|
||||
|
||||
这是当前仓库里风险最低、可持续迭代的推进顺序。
|
||||
87
.trellis/tasks/04-19-desktop-gui-i18n-ux-refresh/task.json
Normal file
87
.trellis/tasks/04-19-desktop-gui-i18n-ux-refresh/task.json
Normal file
@ -0,0 +1,87 @@
|
||||
{
|
||||
"id": "desktop-gui-i18n-ux-refresh",
|
||||
"name": "desktop-gui-i18n-ux-refresh",
|
||||
"title": "Plan desktop GUI i18n and UX refresh",
|
||||
"description": "Produce an execution-ready design for desktop GUI i18n, left-side tab information architecture, and light/dark theme support based on the current Wails + React implementation.",
|
||||
"status": "completed",
|
||||
"dev_type": "docs",
|
||||
"scope": "desktop",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "OpenCode",
|
||||
"assignee": "OpenCode",
|
||||
"createdAt": "2026-04-19",
|
||||
"completedAt": "2026-04-19",
|
||||
"branch": "master",
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"current_phase": 6,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "brainstorm"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "research"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"action": "update-spec"
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"action": "record-session"
|
||||
}
|
||||
],
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
".trellis/tasks/04-19-desktop-gui-i18n-ux-refresh/prd.md",
|
||||
".trellis/tasks/04-19-desktop-gui-i18n-ux-refresh/copy-inventory.md",
|
||||
"frontend/src/App.tsx",
|
||||
"frontend/src/main.tsx",
|
||||
"frontend/src/api.ts",
|
||||
"frontend/src/types.ts",
|
||||
"frontend/src/styles.css",
|
||||
"internal/app/types.go",
|
||||
"internal/app/service.go",
|
||||
"internal/app/service_test.go",
|
||||
"internal/config/config.go",
|
||||
"internal/desktop/http_test.go",
|
||||
"internal/desktop/wails.go"
|
||||
],
|
||||
"notes": "完成桌面 GUI 后续改造方案设计,明确了 i18n、左侧纵向 tab 信息架构、system/light/dark 主题切换、DesktopPrefs 新增 theme/language 契约、阶段化实施顺序与测试策略。当前建议先实现偏好契约与 i18n/theme 底座,再进入页面拆 tab。",
|
||||
"meta": {
|
||||
"i18nFramework": "i18next + react-i18next",
|
||||
"navigationModel": "left-side vertical tabs with hash state",
|
||||
"themeModes": [
|
||||
"system",
|
||||
"light",
|
||||
"dark"
|
||||
],
|
||||
"languageModes": [
|
||||
"system",
|
||||
"en-US",
|
||||
"zh-CN"
|
||||
],
|
||||
"primaryTabs": [
|
||||
"overview",
|
||||
"providers",
|
||||
"aliases",
|
||||
"sync",
|
||||
"settings"
|
||||
]
|
||||
}
|
||||
}
|
||||
53
.trellis/tasks/04-20-04-20-readme-webview2-note/task.json
Normal file
53
.trellis/tasks/04-20-04-20-readme-webview2-note/task.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"id": "04-20-readme-webview2-note",
|
||||
"name": "04-20-readme-webview2-note",
|
||||
"title": "Add WebView2 runtime note to bilingual README",
|
||||
"description": "Add Windows desktop runtime note to README.md and README_EN.md",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P3",
|
||||
"creator": "OpenCode",
|
||||
"assignee": "OpenCode",
|
||||
"createdAt": "2026-04-20",
|
||||
"completedAt": "2026-04-20",
|
||||
"branch": "master",
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"current_phase": 6,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "brainstorm"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "research"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"action": "update-spec"
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"action": "record-session"
|
||||
}
|
||||
],
|
||||
"commit": "b00ee4c8dc2256a18dd155d5da619b48e0d722cc",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "Added bilingual README notes clarifying that the Windows desktop binary is distributed as a single-file artifact and may require WebView2 Runtime on a small subset of systems.",
|
||||
"meta": {}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
# Desktop Log, Network, And List-First Forms
|
||||
|
||||
## Summary
|
||||
|
||||
Desktop shell already contains provider and alias management pages with inline split-view editing, plus dedicated log and network tabs. Current provider and alias pages always reserve detail-pane space and automatically select first list item. This makes browsing feel heavy and wastes space when user only wants to inspect list.
|
||||
|
||||
This task keeps current data flow and form logic, but changes page behavior to a list-first layout:
|
||||
|
||||
- provider page shows only list by default
|
||||
- alias route page shows only list by default
|
||||
- clicking a list item opens edit panel
|
||||
- clicking new opens create form panel
|
||||
- import and bind target flows stay modal-based
|
||||
|
||||
## Product Goal
|
||||
|
||||
Make provider and alias route management feel lighter and more focused by separating browsing from editing, while preserving existing CRUD behavior and minimizing architectural churn.
|
||||
|
||||
## Current State
|
||||
|
||||
### Already done
|
||||
|
||||
- `frontend/src/App.tsx` contains provider and alias list, search, filter, create, edit, delete, import, and bind-target flows.
|
||||
- provider and alias forms already reuse local state and existing API calls.
|
||||
- desktop shell already uses modal overlays for provider import, alias target binding, and confirm dialogs.
|
||||
|
||||
### Problems
|
||||
|
||||
- provider and alias tabs default to split-view with always-visible detail panel.
|
||||
- first filtered item is auto-selected, so page never rests in pure list mode.
|
||||
- empty detail area still occupies layout width before user chooses an action.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Provider page
|
||||
|
||||
- Default state shows only provider list and list controls.
|
||||
- No provider detail panel should be visible until user clicks a provider row or `New provider`.
|
||||
- Clicking provider row opens edit panel for that provider.
|
||||
- Clicking `New provider` opens create panel with empty form.
|
||||
|
||||
### Alias route page
|
||||
|
||||
- Default state shows only alias list and list controls.
|
||||
- No alias detail panel should be visible until user clicks alias row or `New alias`.
|
||||
- Clicking alias row opens edit panel for that alias.
|
||||
- Clicking `New alias` opens create panel with empty form.
|
||||
|
||||
### Interaction and reuse
|
||||
|
||||
- Reuse existing provider and alias form logic as much as possible.
|
||||
- Reuse current modal/backdrop interaction patterns already used in desktop shell.
|
||||
- Keep import-provider modal and bind-target modal unchanged unless minor adjustments are required for consistency.
|
||||
- Keep existing backend/API contracts unchanged.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Detail presentation
|
||||
|
||||
Use conditional overlay detail panel instead of always-mounted split-view pane.
|
||||
|
||||
Why:
|
||||
|
||||
- smallest frontend-only change that matches requested behavior
|
||||
- consistent with existing modal/backdrop interaction already present in app
|
||||
- preserves current form structure, submit handlers, and confirmation flows
|
||||
|
||||
### Selection behavior
|
||||
|
||||
Do not auto-select first filtered provider or alias.
|
||||
|
||||
Why:
|
||||
|
||||
- page should remain in pure list mode until explicit user action
|
||||
- prevents accidental context switching when search/filter changes
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. No backend changes for providers, aliases, logs, or network.
|
||||
2. No redesign of import-provider or bind-target workflows.
|
||||
3. No new global state layer or routing changes.
|
||||
4. No visual redesign outside provider and alias page layout adjustments needed for list-first behavior.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Provider tab initially renders list without visible edit pane.
|
||||
2. Alias tab initially renders list without visible edit pane.
|
||||
3. Clicking provider row opens provider edit panel.
|
||||
4. Clicking alias row opens alias edit panel.
|
||||
5. Clicking provider create button opens provider create panel.
|
||||
6. Clicking alias create button opens alias create panel.
|
||||
7. Search/filter changes do not auto-open a detail panel.
|
||||
8. Existing save, delete, import, bind, and toggle actions still work.
|
||||
@ -0,0 +1,69 @@
|
||||
{
|
||||
"id": "desktop-log-network-views",
|
||||
"name": "desktop-log-network-views",
|
||||
"title": "Design and implement desktop log and network views",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": "feature",
|
||||
"scope": "desktop",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "OpenCode",
|
||||
"assignee": "OpenCode",
|
||||
"createdAt": "2026-04-19",
|
||||
"completedAt": "2026-04-20",
|
||||
"branch": "master",
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"current_phase": 6,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "brainstorm"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "research"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"action": "update-spec"
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"action": "record-session"
|
||||
}
|
||||
],
|
||||
"commit": "5bd04b6",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
".trellis/tasks/04-19-desktop-log-network-views/prd.md",
|
||||
"frontend/src/App.tsx",
|
||||
"frontend/src/styles.css"
|
||||
],
|
||||
"notes": "Completed desktop provider, alias, log, and network view refresh to use list-first layouts with on-demand detail drawers instead of default split panes. Added keyboard-accessible drawer close flows, automatic first-field focus, shell scroll locking while drawers are open, list-header status/close affordances, and independent log/network detail selection. Fixed a GUI theme regression by preventing routine data refresh after proxy start/stop from overwriting in-memory desktop preferences. Verified with `npm run build` in `frontend`.",
|
||||
"meta": {
|
||||
"tests": [
|
||||
"npm run build"
|
||||
],
|
||||
"uiPatterns": [
|
||||
"list-first detail drawers",
|
||||
"shared drawer close interactions",
|
||||
"drawer-only scrolling"
|
||||
],
|
||||
"regressions": [
|
||||
"proxy start/stop no longer resets theme presentation"
|
||||
]
|
||||
}
|
||||
}
|
||||
44
.trellis/workspace/OpenCode/index.md
Normal file
44
.trellis/workspace/OpenCode/index.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Workspace Index - OpenCode
|
||||
|
||||
> Journal tracking for AI development sessions.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 4
|
||||
- **Last Active**: 2026-04-20
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
|
||||
## Active Documents
|
||||
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~200 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 4 | 2026-04-20 | Unify log and network layouts | `a82754e` | `master` |
|
||||
| 3 | 2026-04-20 | Add WebView2 runtime note to README | `b00ee4c8dc2256a18dd155d5da619b48e0d722cc` | `master` |
|
||||
| 2 | 2026-04-19 | Proxy SSE minimal fix | `e4789d0` | `master` |
|
||||
| 1 | 2026-04-19 | Desktop GUI i18n and UX refresh | - | `master` |
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
200
.trellis/workspace/OpenCode/journal-1.md
Normal file
200
.trellis/workspace/OpenCode/journal-1.md
Normal file
@ -0,0 +1,200 @@
|
||||
# Journal - OpenCode (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: 2026-04-18
|
||||
|
||||
---
|
||||
|
||||
## Session 1: Finish Windows desktop integrations
|
||||
|
||||
**Date**: 2026-04-18
|
||||
**Task**: Finish Windows desktop integrations
|
||||
**Branch**: `master`
|
||||
|
||||
### Summary
|
||||
|
||||
Completed the Windows-focused desktop integration pass for `ocswitch desktop` by fixing the Wails bridge path, adding native tray controls, wiring desktop notifications, implementing Windows launch-at-login, and fixing cross-platform file locking that previously blocked Windows builds.
|
||||
|
||||
### Main Changes
|
||||
|
||||
| Area | Description |
|
||||
|------|-------------|
|
||||
| Wails bridge | Corrected frontend bridge detection and access from `window.go.main.App` to `window.go.desktop.App`. |
|
||||
| Tray | Added Wails-only native tray integration via `github.com/getlantern/systray` with open/hide/start/stop/quit actions and proxy status display. |
|
||||
| Notifications | Hooked desktop preference state to Wails runtime notifications for proxy start/stop, sync apply, and notification enablement. |
|
||||
| Launch at login | Added Windows Startup-folder `.cmd` launcher generation while preserving existing Linux XDG autostart flow. |
|
||||
| Windows compatibility | Replaced direct `syscall.Flock` usage with platform-specific file locking helpers and aligned config tests with cross-platform lock helpers. |
|
||||
| Task docs | Added implementation PRD for `.trellis/tasks/04-18-windows-desktop-integrations/`. |
|
||||
|
||||
**Updated Files**:
|
||||
- `.trellis/tasks/04-18-windows-desktop-integrations/prd.md`
|
||||
- `.trellis/tasks/04-18-windows-desktop-integrations/task.json`
|
||||
- `.trellis/workspace/OpenCode/journal-1.md`
|
||||
- `internal/desktop/app.go`
|
||||
- `internal/desktop/tray.go`
|
||||
- `internal/desktop/tray_wails.go`
|
||||
- `internal/desktop/notify.go`
|
||||
- `internal/desktop/autostart.go`
|
||||
- `internal/desktop/autostart_test.go`
|
||||
- `internal/desktop/runtime_wails.go`
|
||||
- `internal/desktop/runtime_fallback.go`
|
||||
- `internal/fileutil/fileutil.go`
|
||||
- `internal/fileutil/lock_unix.go`
|
||||
- `internal/fileutil/lock_windows.go`
|
||||
- `internal/config/config_test.go`
|
||||
- `internal/config/file_lock_testhelper_unix_test.go`
|
||||
- `internal/config/file_lock_testhelper_windows_test.go`
|
||||
- `frontend/index.html`
|
||||
- `frontend/src/api.ts`
|
||||
- `frontend/src/env.d.ts`
|
||||
- `frontend/tsconfig.json`
|
||||
|
||||
### Git Commits
|
||||
|
||||
(No commits - implementation left in worktree)
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] `npm run build` (in `frontend/`)
|
||||
- [OK] `go test ./internal/desktop`
|
||||
- [OK] `go test -tags desktop_wails ./internal/desktop`
|
||||
- [OK] `go test ./internal/config ./internal/opencode`
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- If needed, run a full desktop binary build path (`wails build` or Windows packaging) on a machine with final packaging prerequisites.
|
||||
|
||||
|
||||
|
||||
## Session 1: Desktop GUI i18n and UX refresh
|
||||
|
||||
**Date**: 2026-04-19
|
||||
**Task**: Desktop GUI i18n and UX refresh
|
||||
**Branch**: `master`
|
||||
|
||||
### Summary
|
||||
|
||||
Added desktop theme/language preferences across Go and frontend, rebuilt the app into a tabbed localized shell, refreshed CSS theming/layout, updated Wails desktop prefs models, and verified with npm run build plus go test ./internal/app ./internal/desktop.
|
||||
|
||||
### Main Changes
|
||||
|
||||
(Add details)
|
||||
|
||||
### Git Commits
|
||||
|
||||
(No commits - planning session)
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 2: Proxy SSE minimal fix
|
||||
|
||||
**Date**: 2026-04-19
|
||||
**Task**: Proxy SSE minimal fix
|
||||
**Branch**: `master`
|
||||
|
||||
### Summary
|
||||
|
||||
Fixed empty-200 SSE failover and bypassed idle timeout after SSE start in the local Responses proxy; added targeted regression tests.
|
||||
|
||||
### Main Changes
|
||||
|
||||
(Add details)
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `e4789d0` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 3: Add WebView2 runtime note to README
|
||||
|
||||
**Date**: 2026-04-20
|
||||
**Task**: Add WebView2 runtime note to README
|
||||
**Branch**: `master`
|
||||
|
||||
### Summary
|
||||
|
||||
Updated README.md and README_EN.md with a Windows desktop runtime note covering single-file distribution and the fallback WebView2 installation link.
|
||||
|
||||
### Main Changes
|
||||
|
||||
(Add details)
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `b00ee4c8dc2256a18dd155d5da619b48e0d722cc` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 4: Unify log and network layouts
|
||||
|
||||
**Date**: 2026-04-20
|
||||
**Task**: Unify log and network layouts
|
||||
**Branch**: `master`
|
||||
|
||||
### Summary
|
||||
|
||||
Refactored the desktop log and network pages to reuse the alias full-width card layout, removed the old narrow trace list styles, added the missing network count i18n key, and verified with frontend build plus a tracked-file secret scan before push.
|
||||
|
||||
### Main Changes
|
||||
|
||||
(Add details)
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `a82754e` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
64
README.md
64
README.md
@ -54,6 +54,70 @@ go build -o ocswitch ./cmd/ocswitch
|
||||
go run ./cmd/ocswitch --help
|
||||
```
|
||||
|
||||
## 桌面 GUI
|
||||
|
||||
仓库同时包含一个基于 Wails 的桌面控制台,适合直接在 Windows 上管理 provider、alias、同步和桌面偏好。
|
||||
|
||||
当前桌面界面提供:
|
||||
|
||||
- 左侧导航页签:`Overview` / `Providers` / `Aliases` / `Sync` / `Settings`
|
||||
- 中英文界面切换:`en-US` / `zh-CN` / `system`
|
||||
- 主题偏好:`light` / `dark` / `system`
|
||||
- 与浏览器 fallback shell 共用同一套前端
|
||||
|
||||
### 构建桌面可执行文件
|
||||
|
||||
先安装前端依赖并确认前端能正常构建:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
然后在仓库根目录构建桌面应用:
|
||||
|
||||
```bash
|
||||
wails build -tags desktop_wails
|
||||
```
|
||||
|
||||
Windows 下默认产物路径:
|
||||
|
||||
```text
|
||||
build/bin/ocswitch-desktop.exe
|
||||
```
|
||||
|
||||
提示:
|
||||
|
||||
- Windows 11 一般已内置 WebView2 Runtime;主流 Windows 10 设备通常也已经安装。
|
||||
- `ocswitch-desktop.exe` 按单文件产物分发,不需要额外同目录依赖文件。
|
||||
- 如果桌面应用在 Windows 上无法启动,常见原因之一是系统缺少 WebView2 Runtime;这时请先安装 Microsoft Edge WebView2 Runtime 再重试:
|
||||
https://developer.microsoft.com/microsoft-edge/webview2/
|
||||
|
||||
### 开发模式
|
||||
|
||||
如果你想本地调试桌面 GUI:
|
||||
|
||||
```bash
|
||||
wails dev -tags desktop_wails
|
||||
```
|
||||
|
||||
### 使用方式
|
||||
|
||||
启动桌面应用后,可以直接在界面里完成这些操作:
|
||||
|
||||
- 查看当前代理状态、配置路径和 Doctor 摘要
|
||||
- 管理 provider,包括搜索、筛选、编辑和从 OpenCode 导入
|
||||
- 管理 alias 和 target 绑定关系
|
||||
- 预览并应用 `ocswitch opencode sync`
|
||||
- 保存桌面偏好,包括开机启动、托盘行为、通知、主题和语言
|
||||
|
||||
如果你已经构建出可执行文件,也可以直接运行:
|
||||
|
||||
```bash
|
||||
./build/bin/ocswitch-desktop.exe
|
||||
```
|
||||
|
||||
## 5 分钟快速上手
|
||||
|
||||
### 1. 添加上游 provider
|
||||
|
||||
66
README_EN.md
66
README_EN.md
@ -21,6 +21,72 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
|
||||
go build -o ocswitch ./cmd/ocswitch
|
||||
```
|
||||
|
||||
## Desktop GUI
|
||||
|
||||
The repository also ships a Wails-based desktop control panel for managing
|
||||
providers, aliases, sync flows, and desktop preferences on Windows.
|
||||
|
||||
Current desktop capabilities:
|
||||
|
||||
- Sidebar tabs: `Overview` / `Providers` / `Aliases` / `Sync` / `Settings`
|
||||
- UI language preference: `en-US` / `zh-CN` / `system`
|
||||
- Theme preference: `light` / `dark` / `system`
|
||||
- Shared frontend between the desktop shell and the browser fallback shell
|
||||
|
||||
### Build the desktop executable
|
||||
|
||||
Install frontend dependencies and verify the frontend build first:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then build the desktop app from the repository root:
|
||||
|
||||
```bash
|
||||
wails build -tags desktop_wails
|
||||
```
|
||||
|
||||
On Windows, the default output path is:
|
||||
|
||||
```text
|
||||
build/bin/ocswitch-desktop.exe
|
||||
```
|
||||
|
||||
Note:
|
||||
|
||||
- Windows 11 generally includes the WebView2 Runtime, and most mainstream Windows 10 devices already have it installed.
|
||||
- `ocswitch-desktop.exe` is distributed as a single-file artifact and does not require extra sidecar files in the same directory.
|
||||
- If the desktop app fails to start on Windows, one common cause is a missing WebView2 Runtime. Install the Microsoft Edge WebView2 Runtime and try again:
|
||||
https://developer.microsoft.com/microsoft-edge/webview2/
|
||||
|
||||
### Development mode
|
||||
|
||||
To run the desktop GUI in local development mode:
|
||||
|
||||
```bash
|
||||
wails dev -tags desktop_wails
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
After launching the desktop app, you can:
|
||||
|
||||
- inspect proxy state, config path, and doctor summary
|
||||
- manage providers with search, filtering, editing, and OpenCode import
|
||||
- manage aliases and target bindings
|
||||
- preview and apply `ocswitch opencode sync`
|
||||
- save desktop preferences including launch-at-login, tray behavior,
|
||||
notifications, theme, and language
|
||||
|
||||
If you already built the executable, you can run it directly:
|
||||
|
||||
```bash
|
||||
./build/bin/ocswitch-desktop.exe
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
|
||||
@ -2,12 +2,65 @@ package frontend
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var assets embed.FS
|
||||
|
||||
//go:embed src/i18n/locales/*.json
|
||||
var localeAssets embed.FS
|
||||
|
||||
var (
|
||||
localeCache = map[string]map[string]any{}
|
||||
localeCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func DistFS() (fs.FS, error) {
|
||||
return fs.Sub(assets, "dist")
|
||||
}
|
||||
|
||||
func LocaleValue(language string, path ...string) (string, bool, error) {
|
||||
locale, err := loadLocale(language)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
var current any = locale
|
||||
for _, key := range path {
|
||||
next, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
current, ok = next[key]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
value, ok := current.(string)
|
||||
return value, ok, nil
|
||||
}
|
||||
|
||||
func loadLocale(language string) (map[string]any, error) {
|
||||
localeCacheMu.RLock()
|
||||
if locale, ok := localeCache[language]; ok {
|
||||
localeCacheMu.RUnlock()
|
||||
return locale, nil
|
||||
}
|
||||
localeCacheMu.RUnlock()
|
||||
|
||||
data, err := localeAssets.ReadFile(fmt.Sprintf("src/i18n/locales/%s.json", language))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var locale map[string]any
|
||||
if err := json.Unmarshal(data, &locale); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localeCacheMu.Lock()
|
||||
localeCache[language] = locale
|
||||
localeCacheMu.Unlock()
|
||||
return locale, nil
|
||||
}
|
||||
|
||||
@ -4,9 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ocswitch desktop</title>
|
||||
<script>
|
||||
window.__OCSWITCH_WAILS__ = typeof window.go !== 'undefined' && typeof window.go.main !== 'undefined'
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
98
frontend/package-lock.json
generated
98
frontend/package-lock.json
generated
@ -8,8 +8,10 @@
|
||||
"name": "ocswitch-desktop-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"i18next": "^25.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
@ -50,6 +52,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -253,6 +256,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
|
||||
@ -1215,6 +1227,7 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@ -1284,6 +1297,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@ -1453,6 +1467,47 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.10.10",
|
||||
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.10.10.tgz",
|
||||
"integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@ -1553,6 +1608,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -1594,6 +1650,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@ -1614,6 +1671,32 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 23.4.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@ -1719,8 +1802,9 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -1766,6 +1850,7 @@
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@ -1835,6 +1920,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@ -9,7 +9,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^25.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1 +1 @@
|
||||
2f0b51396a8b6c44ea214228c533bdbe
|
||||
30ca7bcbca69c2b717e8a2e8cbadd5c5
|
||||
2824
frontend/src/App.tsx
2824
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,25 @@
|
||||
import type {
|
||||
AliasTargetInput,
|
||||
AliasUpsertInput,
|
||||
ConfigExportView,
|
||||
ConfigImportInput,
|
||||
ConfigImportResult,
|
||||
DesktopPrefsSaveResult,
|
||||
AliasView,
|
||||
DesktopPrefsView,
|
||||
DoctorRunResult,
|
||||
MetaView,
|
||||
Overview,
|
||||
ProviderImportInput,
|
||||
ProviderImportResult,
|
||||
ProviderSaveResult,
|
||||
ProviderStateInput,
|
||||
ProviderUpsertInput,
|
||||
ProviderView,
|
||||
ProxySettingsSaveResult,
|
||||
ProxySettingsView,
|
||||
ProxyStatusView,
|
||||
RequestTrace,
|
||||
SyncInput,
|
||||
SyncPreview,
|
||||
SyncResult,
|
||||
@ -17,7 +31,7 @@ type ApiEnvelope<T> = {
|
||||
}
|
||||
|
||||
function isWails(): boolean {
|
||||
return Boolean(window.__OCSWITCH_WAILS__ && window.go?.main?.App)
|
||||
return typeof window.go?.desktop?.App !== 'undefined'
|
||||
}
|
||||
|
||||
async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@ -33,7 +47,7 @@ async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
function bridge() {
|
||||
const app = window.go?.main?.App
|
||||
const app = window.go?.desktop?.App
|
||||
if (!app) {
|
||||
throw new Error('Wails bridge unavailable')
|
||||
}
|
||||
@ -48,10 +62,32 @@ export async function getMeta(): Promise<MetaView> {
|
||||
return http<MetaView>('/api/meta')
|
||||
}
|
||||
|
||||
export async function openExternalURL(url: string): Promise<void> {
|
||||
if (isWails()) {
|
||||
await bridge().OpenExternalURL(url)
|
||||
return
|
||||
}
|
||||
const target = url.trim()
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
window.open(target, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
export function getOverview(): Promise<Overview> {
|
||||
return isWails() ? bridge().Overview() : http<Overview>('/api/overview')
|
||||
}
|
||||
|
||||
export function exportConfig(): Promise<ConfigExportView> {
|
||||
return isWails() ? bridge().ExportConfig() : http<ConfigExportView>('/api/config/export')
|
||||
}
|
||||
|
||||
export function importConfig(input: ConfigImportInput): Promise<ConfigImportResult> {
|
||||
return isWails()
|
||||
? bridge().ImportConfig(input)
|
||||
: http<ConfigImportResult>('/api/config/import', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function listProviders(): Promise<ProviderView[]> {
|
||||
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
|
||||
}
|
||||
@ -60,14 +96,72 @@ export function listAliases(): Promise<AliasView[]> {
|
||||
return isWails() ? bridge().Aliases() : http<AliasView[]>('/api/aliases')
|
||||
}
|
||||
|
||||
export function saveProvider(input: ProviderUpsertInput): Promise<ProviderSaveResult> {
|
||||
return isWails()
|
||||
? bridge().SaveProvider(input)
|
||||
: http<ProviderSaveResult>('/api/providers', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function setProviderState(input: ProviderStateInput): Promise<ProviderView> {
|
||||
return isWails()
|
||||
? bridge().SetProviderState(input)
|
||||
: http<ProviderView>('/api/providers/state', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export async function deleteProvider(id: string): Promise<void> {
|
||||
if (isWails()) {
|
||||
await bridge().DeleteProvider(id)
|
||||
return
|
||||
}
|
||||
await http<{ ok: boolean }>('/api/providers/delete', { method: 'POST', body: JSON.stringify({ id }) })
|
||||
}
|
||||
|
||||
export function importProviders(input: ProviderImportInput): Promise<ProviderImportResult> {
|
||||
return isWails()
|
||||
? bridge().ImportProviders(input)
|
||||
: http<ProviderImportResult>('/api/providers/import', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function saveAlias(input: AliasUpsertInput): Promise<AliasView> {
|
||||
return isWails()
|
||||
? bridge().SaveAlias(input)
|
||||
: http<AliasView>('/api/aliases', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export async function deleteAlias(alias: string): Promise<void> {
|
||||
if (isWails()) {
|
||||
await bridge().DeleteAlias(alias)
|
||||
return
|
||||
}
|
||||
await http<{ ok: boolean }>('/api/aliases/delete', { method: 'POST', body: JSON.stringify({ alias }) })
|
||||
}
|
||||
|
||||
export function bindAliasTarget(input: AliasTargetInput): Promise<AliasView> {
|
||||
return isWails()
|
||||
? bridge().BindTarget(input)
|
||||
: http<AliasView>('/api/aliases/bind', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function setAliasTargetState(input: AliasTargetInput): Promise<AliasView> {
|
||||
return isWails()
|
||||
? bridge().SetTargetState(input)
|
||||
: http<AliasView>('/api/aliases/state', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function unbindAliasTarget(input: AliasTargetInput): Promise<AliasView> {
|
||||
return isWails()
|
||||
? bridge().UnbindTarget(input)
|
||||
: http<AliasView>('/api/aliases/unbind', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function getDesktopPrefs(): Promise<DesktopPrefsView> {
|
||||
return isWails() ? bridge().DesktopPrefs() : http<DesktopPrefsView>('/api/desktop-prefs')
|
||||
}
|
||||
|
||||
export function saveDesktopPrefs(input: DesktopPrefsView): Promise<DesktopPrefsView> {
|
||||
export function saveDesktopPrefs(input: DesktopPrefsView): Promise<DesktopPrefsSaveResult> {
|
||||
return isWails()
|
||||
? bridge().SavePrefs(input)
|
||||
: http<DesktopPrefsView>('/api/desktop-prefs', { method: 'POST', body: JSON.stringify(input) })
|
||||
: http<DesktopPrefsSaveResult>('/api/desktop-prefs', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function runDoctor(): Promise<DoctorRunResult> {
|
||||
@ -78,6 +172,20 @@ export function getProxyStatus(): Promise<ProxyStatusView> {
|
||||
return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status')
|
||||
}
|
||||
|
||||
export function getProxySettings(): Promise<ProxySettingsView> {
|
||||
return isWails() ? bridge().ProxySettings() : http<ProxySettingsView>('/api/proxy/settings')
|
||||
}
|
||||
|
||||
export function saveProxySettings(input: ProxySettingsView): Promise<ProxySettingsSaveResult> {
|
||||
return isWails()
|
||||
? bridge().SaveProxySettings(input)
|
||||
: http<ProxySettingsSaveResult>('/api/proxy/settings', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function listRequestTraces(): Promise<RequestTrace[]> {
|
||||
return isWails() ? bridge().RequestTraces(100) : http<RequestTrace[]>('/api/proxy/traces')
|
||||
}
|
||||
|
||||
export function startProxy(): Promise<ProxyStatusView> {
|
||||
return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' })
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/GitHub_Invertocat_Black_Clearspace.png
Normal file
BIN
frontend/src/assets/GitHub_Invertocat_Black_Clearspace.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
61
frontend/src/assets/icon.svg
Normal file
61
frontend/src/assets/icon.svg
Normal file
@ -0,0 +1,61 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<!-- 渐变定义 -->
|
||||
<linearGradient id="bg-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1E1E2E" />
|
||||
<stop offset="100%" stop-color="#0F0F16" />
|
||||
</linearGradient>
|
||||
<linearGradient id="primary-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00F0FF" />
|
||||
<stop offset="100%" stop-color="#7000FF" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- 发光滤镜 -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
|
||||
<!-- 文字专属轻度发光滤镜 -->
|
||||
<filter id="text-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- 背景底板 (圆角矩形,适合Windows应用) -->
|
||||
<rect x="16" y="16" width="224" height="224" rx="50" fill="url(#bg-gradient)" stroke="#2A2A3C" stroke-width="4"/>
|
||||
|
||||
<!-- ================= 主体图形 (整体中心上移至 Y=110) ================= -->
|
||||
|
||||
<!-- 左侧输入节点 (代表多个大模型上游) -->
|
||||
<circle cx="60" cy="62" r="10" fill="#00F0FF" opacity="0.8"/>
|
||||
<circle cx="50" cy="110" r="12" fill="#7000FF" opacity="0.8"/>
|
||||
<circle cx="60" cy="158" r="10" fill="#00F0FF" opacity="0.8"/>
|
||||
|
||||
<!-- 连接线 (代表流式转发与故障切换) -->
|
||||
<path d="M 70 62 Q 110 62 128 110" fill="none" stroke="#00F0FF" stroke-width="4" stroke-dasharray="4 4" opacity="0.6"/>
|
||||
<path d="M 62 110 L 128 110" fill="none" stroke="#7000FF" stroke-width="6" opacity="0.8"/>
|
||||
<path d="M 70 158 Q 110 158 128 110" fill="none" stroke="#00F0FF" stroke-width="4" stroke-dasharray="4 4" opacity="0.6"/>
|
||||
|
||||
<!-- 中心核心/切换器 (代表 OC Switch 本身) -->
|
||||
<circle cx="128" cy="110" r="32" fill="url(#primary-gradient)" filter="url(#glow)"/>
|
||||
<circle cx="128" cy="110" r="24" fill="#1E1E2E"/>
|
||||
|
||||
<!-- 中心 Switch 拨动形态 (代表控制台) -->
|
||||
<rect x="112" y="100" width="32" height="20" rx="10" fill="#2A2A3C"/>
|
||||
<circle cx="132" cy="110" r="6" fill="#00F0FF"/>
|
||||
|
||||
<!-- 右侧单输出 (代表单一稳定的聚合模型别名) -->
|
||||
<path d="M 160 110 L 206 110" fill="none" stroke="url(#primary-gradient)" stroke-width="8" stroke-linecap="round" filter="url(#glow)"/>
|
||||
<polygon points="196,100 212,110 196,120" fill="#7000FF" filter="url(#glow)"/>
|
||||
|
||||
<!-- ================= 底部文字排版 ================= -->
|
||||
<!-- 使用系统无衬线粗体字,现代感强 -->
|
||||
<text x="128" y="200" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-size="26" font-weight="900" text-anchor="middle" letter-spacing="2">
|
||||
<!-- OC 部分青色高亮发光 -->
|
||||
<tspan fill="#00F0FF" filter="url(#text-glow)">OC</tspan>
|
||||
<!-- SWITCH 部分纯白对比 -->
|
||||
<tspan fill="#FFFFFF"> SWITCH</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
30
frontend/src/env.d.ts
vendored
30
frontend/src/env.d.ts
vendored
@ -1,21 +1,45 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OCSWITCH_WAILS__?: boolean
|
||||
go?: {
|
||||
main?: {
|
||||
desktop?: {
|
||||
App?: {
|
||||
Meta: () => Promise<Record<string, string>>
|
||||
OpenExternalURL: (url: string) => Promise<void>
|
||||
Overview: () => Promise<import('./types').Overview>
|
||||
ExportConfig: () => Promise<import('./types').ConfigExportView>
|
||||
ImportConfig: (input: import('./types').ConfigImportInput) => Promise<import('./types').ConfigImportResult>
|
||||
Providers: () => Promise<import('./types').ProviderView[]>
|
||||
Aliases: () => Promise<import('./types').AliasView[]>
|
||||
SaveProvider: (input: import('./types').ProviderUpsertInput) => Promise<import('./types').ProviderSaveResult>
|
||||
SetProviderState: (input: import('./types').ProviderStateInput) => Promise<import('./types').ProviderView>
|
||||
DeleteProvider: (id: string) => Promise<void>
|
||||
ImportProviders: (input: import('./types').ProviderImportInput) => Promise<import('./types').ProviderImportResult>
|
||||
SaveAlias: (input: import('./types').AliasUpsertInput) => Promise<import('./types').AliasView>
|
||||
DeleteAlias: (alias: string) => Promise<void>
|
||||
BindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
|
||||
SetTargetState: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
|
||||
UnbindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
|
||||
DoctorRun: () => Promise<import('./types').DoctorRunResult>
|
||||
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
|
||||
ProxySettings: () => Promise<import('./types').ProxySettingsView>
|
||||
RequestTraces: (limit: number) => Promise<import('./types').RequestTrace[]>
|
||||
StartProxy: () => Promise<import('./types').ProxyStatusView>
|
||||
SaveProxySettings: (input: import('./types').ProxySettingsView) => Promise<import('./types').ProxySettingsSaveResult>
|
||||
StopProxy: () => Promise<import('./types').ProxyStatusView>
|
||||
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
|
||||
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsView>
|
||||
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult>
|
||||
PreviewSync: (input: import('./types').SyncInput) => Promise<import('./types').SyncPreview>
|
||||
ApplySync: (input: import('./types').SyncInput) => Promise<import('./types').SyncResult>
|
||||
}
|
||||
|
||||
34
frontend/src/i18n/index.ts
Normal file
34
frontend/src/i18n/index.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import type { LanguagePreference } from '../types'
|
||||
import en from './locales/en.json'
|
||||
import zhCN from './locales/zh-CN.json'
|
||||
|
||||
export function normalizeSupportedLanguage(language?: string): 'en-US' | 'zh-CN' {
|
||||
const value = language?.trim().toLowerCase()
|
||||
if (value?.startsWith('zh')) {
|
||||
return 'zh-CN'
|
||||
}
|
||||
return 'en-US'
|
||||
}
|
||||
|
||||
export function resolveLanguagePreference(language: LanguagePreference, systemLanguage?: string): 'en-US' | 'zh-CN' {
|
||||
if (language === 'en-US' || language === 'zh-CN') {
|
||||
return language
|
||||
}
|
||||
return normalizeSupportedLanguage(systemLanguage)
|
||||
}
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'en-US': { translation: en },
|
||||
'zh-CN': { translation: zhCN },
|
||||
},
|
||||
lng: 'en-US',
|
||||
fallbackLng: 'en-US',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
336
frontend/src/i18n/locales/en.json
Normal file
336
frontend/src/i18n/locales/en.json
Normal file
@ -0,0 +1,336 @@
|
||||
{
|
||||
"app": {
|
||||
"eyebrow": "ocswitch desktop",
|
||||
"brand": "ocswitch desktop",
|
||||
"title": "Native control panel",
|
||||
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
|
||||
"loadingConfig": "loading config",
|
||||
"dev": "dev"
|
||||
},
|
||||
"tray": {
|
||||
"appTitle": "ocswitch",
|
||||
"appTooltip": "ocswitch desktop",
|
||||
"proxyChecking": "Proxy: checking...",
|
||||
"proxyStatusHint": "Current proxy status",
|
||||
"proxyUnavailable": "Proxy: unavailable",
|
||||
"proxyRunning": "Proxy: running (%s)",
|
||||
"proxyStopped": "Proxy: stopped (%s)",
|
||||
"openWindow": "Open window",
|
||||
"openWindowHint": "Show desktop window",
|
||||
"hideWindow": "Hide window",
|
||||
"hideWindowHint": "Hide desktop window",
|
||||
"startProxy": "Start proxy",
|
||||
"startProxyHint": "Start local proxy",
|
||||
"stopProxy": "Stop proxy",
|
||||
"stopProxyHint": "Stop local proxy",
|
||||
"quit": "Quit",
|
||||
"quitHint": "Exit application"
|
||||
},
|
||||
"nav": {
|
||||
"overview": { "title": "Overview", "description": "Status, actions, and environment" },
|
||||
"providers": { "title": "Providers", "description": "Manage upstream endpoints" },
|
||||
"aliases": { "title": "Aliases", "description": "Route models across targets" },
|
||||
"log": { "title": "Log", "description": "Business routing, alias decisions, and failover chain" },
|
||||
"network": { "title": "Network", "description": "Request metadata except LLM input and output content" },
|
||||
"sync": { "title": "Sync", "description": "OpenCode sync and doctor" },
|
||||
"settings": { "title": "Settings", "description": "Theme, language, desktop behavior" }
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"runDoctor": "Run doctor",
|
||||
"startProxy": "Start proxy",
|
||||
"stopProxy": "Stop proxy",
|
||||
"save": "Save",
|
||||
"reset": "Reset",
|
||||
"preview": "Preview",
|
||||
"apply": "Apply sync",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"import": "Import providers",
|
||||
"bind": "Bind target",
|
||||
"unbind": "Unbind",
|
||||
"newProvider": "New provider",
|
||||
"newAlias": "New alias",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"exportConfig": "Export config",
|
||||
"importConfig": "Import config"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProviderTitle": "Delete provider",
|
||||
"deleteAliasTitle": "Delete alias",
|
||||
"unbindTargetTitle": "Unbind target"
|
||||
},
|
||||
"status": {
|
||||
"proxyRunning": "Proxy running",
|
||||
"proxyIdle": "Proxy idle",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"messages": {
|
||||
"refreshing": "Refreshing...",
|
||||
"fresh": "Fresh",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"running": "Running...",
|
||||
"applying": "Applying...",
|
||||
"previewing": "Previewing...",
|
||||
"importing": "Importing providers...",
|
||||
"loading": "Loading...",
|
||||
"noData": "No data yet.",
|
||||
"warningsSuffix": "Warnings: {{warnings}}",
|
||||
"proxyStarted": "Proxy started",
|
||||
"proxyStopped": "Proxy stopped",
|
||||
"doctorOk": "Doctor OK",
|
||||
"previewChanges": "Preview shows changes",
|
||||
"previewNoChanges": "Preview shows no changes",
|
||||
"syncApplied": "Sync applied",
|
||||
"syncUpToDate": "Already up to date",
|
||||
"confirmDeleteProvider": "Delete provider {{id}}? This cannot be undone.",
|
||||
"confirmDeleteAlias": "Delete alias {{alias}}? This cannot be undone.",
|
||||
"confirmUnbindTarget": "Unbind {{provider}}/{{model}} from {{alias}}?"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"subtitle": "See the current desktop state and run common actions without leaving this screen.",
|
||||
"actionsTitle": "Actions",
|
||||
"statsTitle": "System snapshot",
|
||||
"environmentTitle": "Environment",
|
||||
"environmentSubtitle": "Only runtime and desktop app details stay here.",
|
||||
"doctorTitle": "Doctor summary",
|
||||
"debugTitle": "Debug details",
|
||||
"debugSummary": "Show raw overview JSON",
|
||||
"providers": "Providers",
|
||||
"aliases": "Aliases",
|
||||
"routableAliases": "Routable aliases",
|
||||
"proxy": "Proxy",
|
||||
"version": "Version",
|
||||
"shell": "Shell",
|
||||
"configPath": "Config path",
|
||||
"startedAt": "Started at",
|
||||
"lastError": "Last error",
|
||||
"doctorReady": "Doctor has not run yet. Use it to inspect config and OpenCode wiring.",
|
||||
"doctorIssues": "{{count}} issue(s) found",
|
||||
"doctorHealthy": "No known issues"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
"subtitle": "Edit upstream providers with a fixed editor and a scrollable list.",
|
||||
"listTitle": "Provider list",
|
||||
"formCreateTitle": "Create provider",
|
||||
"formEditTitle": "Edit provider {{id}}",
|
||||
"importTitle": "Import from OpenCode",
|
||||
"search": "Search providers",
|
||||
"searchPlaceholder": "Filter by id, name, or URL",
|
||||
"filter": "Status filter",
|
||||
"filterAll": "All",
|
||||
"filterEnabled": "Enabled",
|
||||
"filterDisabled": "Disabled",
|
||||
"listCount": "{{shown}} shown / {{total}} total",
|
||||
"detailHint": "Select one row to inspect and edit it on the right.",
|
||||
"selectTitle": "Select a provider",
|
||||
"selectHint": "The left list is optimized for density. Pick one row to inspect or edit details here.",
|
||||
"empty": "No providers configured yet.",
|
||||
"emptyHint": "Create one manually or import providers from your OpenCode config.",
|
||||
"noMatches": "No providers match the current filters.",
|
||||
"noMatchesHint": "Try a broader search term or switch the status filter.",
|
||||
"id": "Provider id",
|
||||
"name": "Display name",
|
||||
"baseUrl": "Base URL",
|
||||
"apiKey": "API key",
|
||||
"headers": "Headers",
|
||||
"models": "Models",
|
||||
"sourcePath": "Import path",
|
||||
"saveDisabled": "Save as disabled",
|
||||
"skipModels": "Skip /v1/models discovery",
|
||||
"clearHeaders": "Clear saved headers before update",
|
||||
"overwrite": "Overwrite existing providers",
|
||||
"apiKeyMasked": "API key",
|
||||
"apiKeyNotSet": "not set",
|
||||
"cardBaseUrl": "URL",
|
||||
"cardModels": "Models",
|
||||
"cardApiKey": "API key",
|
||||
"detailBasicsTitle": "Basics",
|
||||
"detailTogglesTitle": "Behavior",
|
||||
"headersNone": "none",
|
||||
"modelsNone": "none",
|
||||
"modelsCount": "{{count}} models",
|
||||
"sourceLabel": "Source",
|
||||
"placeholderId": "su8",
|
||||
"placeholderName": "SU8",
|
||||
"placeholderBaseUrl": "https://example.com/v1",
|
||||
"placeholderApiKey": "Leave blank to keep existing key when editing",
|
||||
"placeholderHeaders": "X-Token=abc\nX-Workspace=my-team",
|
||||
"placeholderSourcePath": "Leave blank to use global OpenCode config",
|
||||
"statusSaved": "Saved provider {{id}}",
|
||||
"statusEditing": "Editing provider {{id}}. Leave API key blank to keep the current value.",
|
||||
"statusEnabling": "Enabling provider {{id}}...",
|
||||
"statusDisabling": "Disabling provider {{id}}...",
|
||||
"statusEnabled": "Enabled provider {{id}}",
|
||||
"statusDisabled": "Disabled provider {{id}}",
|
||||
"statusDeleting": "Deleting provider {{id}}...",
|
||||
"statusDeleted": "Deleted provider {{id}}",
|
||||
"statusImportDone": "Import done: imported={{imported}}, skipped={{skipped}}"
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Aliases",
|
||||
"subtitle": "Separate alias editing from target binding so routing stays readable.",
|
||||
"listTitle": "Alias list",
|
||||
"formCreateTitle": "Create alias",
|
||||
"formEditTitle": "Edit alias {{alias}}",
|
||||
"bindTitle": "Bind target",
|
||||
"search": "Search aliases",
|
||||
"searchPlaceholder": "Filter by alias, display name, provider, or model",
|
||||
"listCount": "{{shown}} shown / {{total}} total",
|
||||
"detailHint": "Select one row to inspect alias settings and targets on the right.",
|
||||
"selectTitle": "Select an alias",
|
||||
"selectHint": "The left list stays compact so you can scan more routes per screen. Pick one row to inspect or edit details here.",
|
||||
"empty": "No aliases configured yet.",
|
||||
"emptyHint": "Create an alias first, then bind provider/model targets to it.",
|
||||
"noMatches": "No aliases match the current filters.",
|
||||
"noMatchesHint": "Try a different search term or create a new alias route.",
|
||||
"alias": "Alias",
|
||||
"displayName": "Display name",
|
||||
"aliasForBinding": "Alias for binding",
|
||||
"providerId": "Provider id",
|
||||
"model": "Model",
|
||||
"createDisabled": "Create or keep disabled",
|
||||
"targetDisabled": "Add target disabled",
|
||||
"placeholderAlias": "gpt-5.4",
|
||||
"placeholderDisplayName": "GPT 5.4",
|
||||
"placeholderAliasBinding": "Existing alias or new alias",
|
||||
"placeholderProviderId": "su8",
|
||||
"placeholderModel": "gpt-5.4",
|
||||
"targets": "Targets",
|
||||
"targetsCount": "{{count}} targets",
|
||||
"routable": "{{available}}/{{total}} routable",
|
||||
"cardRoute": "Route",
|
||||
"cardTargets": "Targets",
|
||||
"cardPrimary": "Primary",
|
||||
"detailBasicsTitle": "Alias details",
|
||||
"detailTargetsHint": "Manage routable provider/model targets for this alias.",
|
||||
"statusSaved": "Saved alias {{alias}}",
|
||||
"statusEditing": "Editing alias {{alias}}",
|
||||
"statusDeleting": "Deleting alias {{alias}}...",
|
||||
"statusDeleted": "Deleted alias {{alias}}",
|
||||
"statusBinding": "Binding target...",
|
||||
"statusBound": "Bound {{provider}}/{{model}} to {{alias}}",
|
||||
"statusRemoving": "Removing {{provider}}/{{model}} from {{alias}}...",
|
||||
"statusRemoved": "Removed {{provider}}/{{model}} from {{alias}}",
|
||||
"statusEnabling": "Enabling {{provider}}/{{model}} on {{alias}}...",
|
||||
"statusDisabling": "Disabling {{provider}}/{{model}} on {{alias}}...",
|
||||
"statusEnabled": "Enabled {{provider}}/{{model}} on {{alias}}",
|
||||
"statusDisabled": "Disabled {{provider}}/{{model}} on {{alias}}",
|
||||
"noTargets": "No targets bound."
|
||||
},
|
||||
"sync": {
|
||||
"title": "Sync",
|
||||
"subtitle": "Preview and apply OpenCode sync without leaving the desktop shell.",
|
||||
"syncTitle": "OpenCode sync",
|
||||
"doctorTitle": "Doctor report",
|
||||
"targetPath": "Target path",
|
||||
"model": "model",
|
||||
"smallModel": "small_model",
|
||||
"placeholderTargetPath": "Use default OpenCode config",
|
||||
"placeholderModel": "ocswitch/<alias>",
|
||||
"outputTitle": "Last output",
|
||||
"doctorHint": "Run doctor to inspect config and OpenCode wiring.",
|
||||
"issuesTitle": "Issues",
|
||||
"noIssues": "No issues reported.",
|
||||
"debugTitle": "Raw doctor JSON",
|
||||
"debugSummary": "Show doctor output"
|
||||
},
|
||||
"log": {
|
||||
"title": "Request log",
|
||||
"subtitle": "Inspect alias routing, final target, and failover chain for each request.",
|
||||
"count": "{{count}} requests",
|
||||
"empty": "No proxied requests yet.",
|
||||
"emptyHint": "Start the proxy and send a request to populate the timeline.",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"failover": "Failover",
|
||||
"detailTitle": "Business detail",
|
||||
"detailSubtitle": "This panel focuses on alias-level routing semantics.",
|
||||
"alias": "Alias",
|
||||
"finalRoute": "Final route",
|
||||
"status": "Status",
|
||||
"totalTime": "Total time",
|
||||
"startedAt": "Started at",
|
||||
"firstByte": "First byte",
|
||||
"stream": "Stream",
|
||||
"chainTitle": "Attempt chain",
|
||||
"attemptLabel": "Attempt {{attempt}}",
|
||||
"provider": "Provider",
|
||||
"model": "Model"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network detail",
|
||||
"subtitle": "Inspect URL, timing, status, headers, and params without exposing model IO content.",
|
||||
"count": "{{count}} requests",
|
||||
"detailTitle": "Network inspector",
|
||||
"detailSubtitle": "Each attempt shows technical metadata captured around the upstream call.",
|
||||
"url": "URL",
|
||||
"firstByte": "TTFB",
|
||||
"totalTime": "Total time",
|
||||
"statusCode": "Status code",
|
||||
"requestHeaders": "Client request headers",
|
||||
"requestParams": "Client request params",
|
||||
"responseHeaders": "Upstream response headers",
|
||||
"responseBody": "Upstream response body",
|
||||
"attemptTitle": "Attempt {{attempt}} · {{provider}} / {{model}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Persist desktop behavior, theme preference, and language preference.",
|
||||
"appearanceTitle": "Appearance",
|
||||
"languageTitle": "Language",
|
||||
"behaviorTitle": "Desktop behavior",
|
||||
"timeoutTitle": "Proxy timeouts",
|
||||
"configTitle": "Config file",
|
||||
"importTitle": "Import mode",
|
||||
"importModeText": "Import text",
|
||||
"importModeFile": "Import file",
|
||||
"importText": "Config text",
|
||||
"importFile": "Config file",
|
||||
"importPlaceholder": "Paste full ocswitch config JSON",
|
||||
"fileEmpty": "No file selected yet.",
|
||||
"exportDone": "Exported config {{path}}",
|
||||
"importDone": "Imported config {{path}}",
|
||||
"fileLoaded": "Loaded file {{name}}",
|
||||
"importEmpty": "Provide config content first.",
|
||||
"aboutTitle": "About",
|
||||
"aboutSubtitle": "Read-only environment details for the current desktop shell.",
|
||||
"theme": "Theme",
|
||||
"language": "Language",
|
||||
"languageLabel": "Preferred language",
|
||||
"themeSystem": "System",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"languageSystem": "System",
|
||||
"languageEnglish": "English",
|
||||
"languageChinese": "Simplified Chinese",
|
||||
"resolvedTheme": "Resolved theme",
|
||||
"resolvedLanguage": "Resolved language",
|
||||
"launchAtLogin": "Launch at login",
|
||||
"autoStartProxy": "Start proxy on app launch",
|
||||
"connectTimeout": "Connect timeout (ms)",
|
||||
"responseHeaderTimeout": "Response header timeout (ms)",
|
||||
"firstByteTimeout": "First byte timeout (ms)",
|
||||
"requestReadTimeout": "Client request read timeout (ms)",
|
||||
"streamIdleTimeout": "Stream idle timeout (ms)",
|
||||
"timeoutHint": "Saved values apply to the next proxy start.",
|
||||
"timeoutRunningHint": "The proxy is currently running. Saved values apply only after you restart it.",
|
||||
"saveTimeouts": "Save proxy timeouts",
|
||||
"minimizeToTray": "Minimize to tray / close to background",
|
||||
"notifications": "Native notifications"
|
||||
},
|
||||
"errors": {
|
||||
"invalidHeaderFormat": "Invalid header \"{{line}}\". Use KEY=VALUE.",
|
||||
"invalidHeaderName": "Invalid header \"{{line}}\". Header name must not be empty."
|
||||
}
|
||||
}
|
||||
336
frontend/src/i18n/locales/zh-CN.json
Normal file
336
frontend/src/i18n/locales/zh-CN.json
Normal file
@ -0,0 +1,336 @@
|
||||
{
|
||||
"app": {
|
||||
"eyebrow": "ocswitch desktop",
|
||||
"brand": "ocswitch desktop",
|
||||
"title": "桌面控制台",
|
||||
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
|
||||
"loadingConfig": "正在读取配置",
|
||||
"dev": "dev"
|
||||
},
|
||||
"tray": {
|
||||
"appTitle": "ocswitch",
|
||||
"appTooltip": "ocswitch desktop",
|
||||
"proxyChecking": "代理:检查中...",
|
||||
"proxyStatusHint": "当前代理状态",
|
||||
"proxyUnavailable": "代理:不可用",
|
||||
"proxyRunning": "代理:运行中(%s)",
|
||||
"proxyStopped": "代理:已停止(%s)",
|
||||
"openWindow": "打开主面板",
|
||||
"openWindowHint": "显示桌面主窗口",
|
||||
"hideWindow": "隐藏窗口",
|
||||
"hideWindowHint": "隐藏桌面主窗口",
|
||||
"startProxy": "启动代理",
|
||||
"startProxyHint": "启动本地代理",
|
||||
"stopProxy": "停止代理",
|
||||
"stopProxyHint": "停止本地代理",
|
||||
"quit": "退出",
|
||||
"quitHint": "退出应用"
|
||||
},
|
||||
"nav": {
|
||||
"overview": { "title": "概览", "description": "状态、操作与环境信息" },
|
||||
"providers": { "title": "提供商", "description": "管理上游端点" },
|
||||
"aliases": { "title": "别名路由", "description": "管理别名与目标绑定" },
|
||||
"log": { "title": "日志", "description": "查看业务路由、alias 决策与 failover 链路" },
|
||||
"network": { "title": "网络", "description": "查看除 LLM 输入输出内容外的全部请求元数据" },
|
||||
"sync": { "title": "同步与体检", "description": "OpenCode 同步与 Doctor" },
|
||||
"settings": { "title": "设置", "description": "主题、语言与桌面偏好" }
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "刷新",
|
||||
"runDoctor": "运行 Doctor",
|
||||
"startProxy": "启动代理",
|
||||
"stopProxy": "停止代理",
|
||||
"save": "保存",
|
||||
"reset": "重置",
|
||||
"preview": "预览",
|
||||
"apply": "应用同步",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"import": "导入提供商",
|
||||
"bind": "绑定目标",
|
||||
"unbind": "解绑",
|
||||
"newProvider": "新建提供商",
|
||||
"newAlias": "新建别名",
|
||||
"close": "关闭",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"exportConfig": "导出配置",
|
||||
"importConfig": "导入配置"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProviderTitle": "确认删除提供商",
|
||||
"deleteAliasTitle": "确认删除别名",
|
||||
"unbindTargetTitle": "确认解绑目标"
|
||||
},
|
||||
"status": {
|
||||
"proxyRunning": "代理运行中",
|
||||
"proxyIdle": "代理未运行",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"yes": "是",
|
||||
"no": "否"
|
||||
},
|
||||
"messages": {
|
||||
"refreshing": "正在刷新...",
|
||||
"fresh": "已刷新",
|
||||
"saving": "正在保存...",
|
||||
"saved": "已保存",
|
||||
"running": "正在执行...",
|
||||
"applying": "正在应用...",
|
||||
"previewing": "正在预览...",
|
||||
"importing": "正在导入提供商...",
|
||||
"loading": "正在加载...",
|
||||
"noData": "暂无数据。",
|
||||
"warningsSuffix": "警告:{{warnings}}",
|
||||
"proxyStarted": "代理已启动",
|
||||
"proxyStopped": "代理已停止",
|
||||
"doctorOk": "Doctor 通过",
|
||||
"previewChanges": "预览显示有变更",
|
||||
"previewNoChanges": "预览显示无变更",
|
||||
"syncApplied": "同步已应用",
|
||||
"syncUpToDate": "已经是最新状态",
|
||||
"confirmDeleteProvider": "确认删除提供商 {{id}}?此操作不可撤销。",
|
||||
"confirmDeleteAlias": "确认删除别名 {{alias}}?此操作不可撤销。",
|
||||
"confirmUnbindTarget": "确认从 {{alias}} 解绑 {{provider}}/{{model}}?"
|
||||
},
|
||||
"overview": {
|
||||
"title": "概览",
|
||||
"subtitle": "在一个页面里快速查看当前桌面状态,并完成常用操作。",
|
||||
"actionsTitle": "快捷操作",
|
||||
"statsTitle": "系统快照",
|
||||
"environmentTitle": "环境信息",
|
||||
"environmentSubtitle": "仅保留当前运行态和桌面应用相关信息。",
|
||||
"doctorTitle": "Doctor 摘要",
|
||||
"debugTitle": "调试详情",
|
||||
"debugSummary": "查看原始 overview JSON",
|
||||
"providers": "提供商",
|
||||
"aliases": "别名",
|
||||
"routableAliases": "可路由别名",
|
||||
"proxy": "代理",
|
||||
"version": "版本",
|
||||
"shell": "Shell",
|
||||
"configPath": "配置路径",
|
||||
"startedAt": "启动时间",
|
||||
"lastError": "最近错误",
|
||||
"doctorReady": "尚未运行 Doctor,可用来检查配置与 OpenCode 接线情况。",
|
||||
"doctorIssues": "发现 {{count}} 个问题",
|
||||
"doctorHealthy": "当前未发现问题"
|
||||
},
|
||||
"providers": {
|
||||
"title": "提供商",
|
||||
"subtitle": "左侧滚动列表,右侧固定编辑区,减少整页来回滚动。",
|
||||
"listTitle": "提供商列表",
|
||||
"formCreateTitle": "新建提供商",
|
||||
"formEditTitle": "编辑提供商 {{id}}",
|
||||
"importTitle": "从 OpenCode 导入",
|
||||
"search": "搜索提供商",
|
||||
"searchPlaceholder": "按 id、名称或 URL 过滤",
|
||||
"filter": "状态筛选",
|
||||
"filterAll": "全部",
|
||||
"filterEnabled": "已启用",
|
||||
"filterDisabled": "已禁用",
|
||||
"listCount": "显示 {{shown}} / 共 {{total}}",
|
||||
"detailHint": "左侧选中一行后,在右侧查看并编辑详情。",
|
||||
"selectTitle": "选择一个提供商",
|
||||
"selectHint": "左侧列表已压缩为高密度模式,适合首屏浏览更多条目;选中一行后在这里查看或编辑详情。",
|
||||
"empty": "还没有配置任何提供商。",
|
||||
"emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。",
|
||||
"noMatches": "当前筛选条件下没有匹配的提供商。",
|
||||
"noMatchesHint": "尝试放宽搜索词或切换状态筛选。",
|
||||
"id": "提供商 id",
|
||||
"name": "显示名称",
|
||||
"baseUrl": "Base URL",
|
||||
"apiKey": "API key",
|
||||
"headers": "Headers",
|
||||
"models": "Models",
|
||||
"sourcePath": "导入路径",
|
||||
"saveDisabled": "保存为禁用状态",
|
||||
"skipModels": "跳过 /v1/models 发现",
|
||||
"clearHeaders": "更新前清空已保存 headers",
|
||||
"overwrite": "覆盖现有提供商",
|
||||
"apiKeyMasked": "API key",
|
||||
"apiKeyNotSet": "未设置",
|
||||
"cardBaseUrl": "地址",
|
||||
"cardModels": "模型",
|
||||
"cardApiKey": "密钥",
|
||||
"detailBasicsTitle": "基础信息",
|
||||
"detailTogglesTitle": "行为选项",
|
||||
"headersNone": "无",
|
||||
"modelsNone": "无",
|
||||
"modelsCount": "{{count}} 个模型",
|
||||
"sourceLabel": "来源",
|
||||
"placeholderId": "su8",
|
||||
"placeholderName": "SU8",
|
||||
"placeholderBaseUrl": "https://example.com/v1",
|
||||
"placeholderApiKey": "编辑时留空表示保留现有 key",
|
||||
"placeholderHeaders": "X-Token=abc\nX-Workspace=my-team",
|
||||
"placeholderSourcePath": "留空则使用全局 OpenCode 配置",
|
||||
"statusSaved": "已保存提供商 {{id}}",
|
||||
"statusEditing": "正在编辑提供商 {{id}},API key 留空会保留当前值。",
|
||||
"statusEnabling": "正在启用提供商 {{id}}...",
|
||||
"statusDisabling": "正在禁用提供商 {{id}}...",
|
||||
"statusEnabled": "已启用提供商 {{id}}",
|
||||
"statusDisabled": "已禁用提供商 {{id}}",
|
||||
"statusDeleting": "正在删除提供商 {{id}}...",
|
||||
"statusDeleted": "已删除提供商 {{id}}",
|
||||
"statusImportDone": "导入完成:imported={{imported}}, skipped={{skipped}}"
|
||||
},
|
||||
"aliases": {
|
||||
"title": "别名路由",
|
||||
"subtitle": "把别名编辑和目标绑定拆开,路由关系更清晰。",
|
||||
"listTitle": "别名列表",
|
||||
"formCreateTitle": "新建别名",
|
||||
"formEditTitle": "编辑别名 {{alias}}",
|
||||
"bindTitle": "绑定目标",
|
||||
"search": "搜索别名",
|
||||
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
|
||||
"listCount": "显示 {{shown}} / 共 {{total}}",
|
||||
"detailHint": "左侧选中一行后,在右侧查看别名设置和目标详情。",
|
||||
"selectTitle": "选择一个别名",
|
||||
"selectHint": "左侧列表保持紧凑,方便一屏浏览更多路由;选中一行后在这里查看或编辑详情。",
|
||||
"empty": "还没有配置任何别名。",
|
||||
"emptyHint": "先新建别名,再把上游 provider/model 绑定到它。",
|
||||
"noMatches": "当前筛选条件下没有匹配的别名。",
|
||||
"noMatchesHint": "尝试修改搜索词,或直接新建一个新的别名路由。",
|
||||
"alias": "别名",
|
||||
"displayName": "显示名称",
|
||||
"aliasForBinding": "用于绑定的别名",
|
||||
"providerId": "提供商 id",
|
||||
"model": "模型",
|
||||
"createDisabled": "创建或保持为禁用",
|
||||
"targetDisabled": "新增目标时设为禁用",
|
||||
"placeholderAlias": "gpt-5.4",
|
||||
"placeholderDisplayName": "GPT 5.4",
|
||||
"placeholderAliasBinding": "已有别名或新别名",
|
||||
"placeholderProviderId": "su8",
|
||||
"placeholderModel": "gpt-5.4",
|
||||
"targets": "目标",
|
||||
"targetsCount": "{{count}} 个目标",
|
||||
"routable": "可路由 {{available}}/{{total}}",
|
||||
"cardRoute": "路由",
|
||||
"cardTargets": "目标数",
|
||||
"cardPrimary": "主目标",
|
||||
"detailBasicsTitle": "别名信息",
|
||||
"detailTargetsHint": "为当前别名维护可路由的 provider/model 目标。",
|
||||
"statusSaved": "已保存别名 {{alias}}",
|
||||
"statusEditing": "正在编辑别名 {{alias}}",
|
||||
"statusDeleting": "正在删除别名 {{alias}}...",
|
||||
"statusDeleted": "已删除别名 {{alias}}",
|
||||
"statusBinding": "正在绑定目标...",
|
||||
"statusBound": "已将 {{provider}}/{{model}} 绑定到 {{alias}}",
|
||||
"statusRemoving": "正在从 {{alias}} 移除 {{provider}}/{{model}}...",
|
||||
"statusRemoved": "已从 {{alias}} 移除 {{provider}}/{{model}}",
|
||||
"statusEnabling": "正在启用 {{alias}} 上的 {{provider}}/{{model}}...",
|
||||
"statusDisabling": "正在禁用 {{alias}} 上的 {{provider}}/{{model}}...",
|
||||
"statusEnabled": "已启用 {{alias}} 上的 {{provider}}/{{model}}",
|
||||
"statusDisabled": "已禁用 {{alias}} 上的 {{provider}}/{{model}}",
|
||||
"noTargets": "还没有绑定目标。"
|
||||
},
|
||||
"sync": {
|
||||
"title": "同步与体检",
|
||||
"subtitle": "不离开桌面应用即可预览、应用 OpenCode 同步并运行 Doctor。",
|
||||
"syncTitle": "OpenCode 同步",
|
||||
"doctorTitle": "Doctor 报告",
|
||||
"targetPath": "目标路径",
|
||||
"model": "model",
|
||||
"smallModel": "small_model",
|
||||
"placeholderTargetPath": "使用默认 OpenCode 配置",
|
||||
"placeholderModel": "ocswitch/<alias>",
|
||||
"outputTitle": "最近输出",
|
||||
"doctorHint": "运行 Doctor 以检查配置与 OpenCode 接线。",
|
||||
"issuesTitle": "问题列表",
|
||||
"noIssues": "未报告问题。",
|
||||
"debugTitle": "原始 Doctor JSON",
|
||||
"debugSummary": "查看 Doctor 输出"
|
||||
},
|
||||
"log": {
|
||||
"title": "请求日志",
|
||||
"subtitle": "按请求查看 alias 路由、最终目标与 failover 链路。",
|
||||
"count": "共 {{count}} 条请求",
|
||||
"empty": "还没有代理请求。",
|
||||
"emptyHint": "先启动代理并发起一次请求,时间线才会出现内容。",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"failover": "发生切换",
|
||||
"detailTitle": "业务详情",
|
||||
"detailSubtitle": "这里聚焦 alias 级别的业务路由语义。",
|
||||
"alias": "Alias",
|
||||
"finalRoute": "最终路由",
|
||||
"status": "状态码",
|
||||
"totalTime": "总耗时",
|
||||
"startedAt": "开始时间",
|
||||
"firstByte": "首字节时间",
|
||||
"stream": "流式",
|
||||
"chainTitle": "尝试链路",
|
||||
"attemptLabel": "第 {{attempt}} 次尝试",
|
||||
"provider": "提供商",
|
||||
"model": "模型"
|
||||
},
|
||||
"network": {
|
||||
"title": "网络详情",
|
||||
"subtitle": "查看 URL、时延、状态码、请求头与参数,同时避免暴露模型输入输出正文。",
|
||||
"count": "共 {{count}} 条请求",
|
||||
"detailTitle": "网络检查器",
|
||||
"detailSubtitle": "每次尝试都会展示调用上游时采集到的技术元数据。",
|
||||
"url": "URL",
|
||||
"firstByte": "TTFB",
|
||||
"totalTime": "总耗时",
|
||||
"statusCode": "状态码",
|
||||
"requestHeaders": "客户端请求头",
|
||||
"requestParams": "客户端请求参数",
|
||||
"responseHeaders": "上游响应头",
|
||||
"responseBody": "上游响应体",
|
||||
"attemptTitle": "第 {{attempt}} 次尝试 · {{provider}} / {{model}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"subtitle": "持久化桌面行为、主题偏好和语言偏好。",
|
||||
"appearanceTitle": "外观",
|
||||
"languageTitle": "语言",
|
||||
"behaviorTitle": "桌面行为",
|
||||
"timeoutTitle": "代理超时",
|
||||
"configTitle": "配置文件",
|
||||
"importTitle": "导入方式",
|
||||
"importModeText": "文本导入",
|
||||
"importModeFile": "文件导入",
|
||||
"importText": "配置文本",
|
||||
"importFile": "配置文件",
|
||||
"importPlaceholder": "粘贴完整 ocswitch 配置 JSON",
|
||||
"fileEmpty": "尚未选择文件。",
|
||||
"exportDone": "已导出配置 {{path}}",
|
||||
"importDone": "已导入配置 {{path}}",
|
||||
"fileLoaded": "已读取文件 {{name}}",
|
||||
"importEmpty": "请先提供配置内容。",
|
||||
"aboutTitle": "关于",
|
||||
"aboutSubtitle": "显示当前桌面壳环境信息,仅用于查看。",
|
||||
"theme": "主题",
|
||||
"language": "语言",
|
||||
"languageLabel": "首选语言",
|
||||
"themeSystem": "跟随系统",
|
||||
"themeLight": "浅色",
|
||||
"themeDark": "深色",
|
||||
"languageSystem": "跟随系统",
|
||||
"languageEnglish": "English",
|
||||
"languageChinese": "简体中文",
|
||||
"resolvedTheme": "实际主题",
|
||||
"resolvedLanguage": "实际语言",
|
||||
"launchAtLogin": "开机启动",
|
||||
"autoStartProxy": "启动应用时自动开启代理",
|
||||
"connectTimeout": "连接超时(毫秒)",
|
||||
"responseHeaderTimeout": "响应头超时(毫秒)",
|
||||
"firstByteTimeout": "首字节超时(毫秒)",
|
||||
"requestReadTimeout": "客户端请求读取超时(毫秒)",
|
||||
"streamIdleTimeout": "流空闲超时(毫秒)",
|
||||
"timeoutHint": "保存后会在下一次启动代理时生效。",
|
||||
"timeoutRunningHint": "代理当前正在运行,保存后的值要在重启代理后才会生效。",
|
||||
"saveTimeouts": "保存代理超时",
|
||||
"minimizeToTray": "最小化到托盘 / 关闭时转入后台",
|
||||
"notifications": "系统通知"
|
||||
},
|
||||
"errors": {
|
||||
"invalidHeaderFormat": "Header \"{{line}}\" 格式无效,请使用 KEY=VALUE。",
|
||||
"invalidHeaderName": "Header \"{{line}}\" 无效,名称不能为空。"
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './i18n'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,46 @@
|
||||
export type ThemePreference = 'system' | 'light' | 'dark'
|
||||
|
||||
export type LanguagePreference = 'system' | 'en-US' | 'zh-CN'
|
||||
|
||||
export type DesktopPrefsView = {
|
||||
launchAtLogin: boolean
|
||||
autoStartProxy: boolean
|
||||
minimizeToTray: boolean
|
||||
notifications: boolean
|
||||
theme: ThemePreference
|
||||
language: LanguagePreference
|
||||
}
|
||||
|
||||
export type DesktopPrefsSaveResult = {
|
||||
prefs: DesktopPrefsView
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export type ProxySettingsView = {
|
||||
connectTimeoutMs: number
|
||||
responseHeaderTimeoutMs: number
|
||||
firstByteTimeoutMs: number
|
||||
requestReadTimeoutMs: number
|
||||
streamIdleTimeoutMs: number
|
||||
}
|
||||
|
||||
export type ProxySettingsSaveResult = {
|
||||
settings: ProxySettingsView
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export type ConfigExportView = {
|
||||
configPath: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type ConfigImportInput = {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type ConfigImportResult = {
|
||||
configPath: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export type ProxyStatusView = {
|
||||
@ -11,6 +50,48 @@ export type ProxyStatusView = {
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
export type TraceAttempt = {
|
||||
attempt: number
|
||||
provider?: string
|
||||
model?: string
|
||||
url?: string
|
||||
startedAt: string
|
||||
durationMs: number
|
||||
firstByteMs?: number
|
||||
statusCode?: number
|
||||
success: boolean
|
||||
retryable: boolean
|
||||
skipped: boolean
|
||||
result?: string
|
||||
error?: string
|
||||
requestHeaders?: Record<string, string>
|
||||
requestParams?: unknown
|
||||
responseHeaders?: Record<string, string>
|
||||
responseBody?: string
|
||||
}
|
||||
|
||||
export type RequestTrace = {
|
||||
id: number
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
durationMs: number
|
||||
firstByteMs?: number
|
||||
rawModel?: string
|
||||
alias?: string
|
||||
stream: boolean
|
||||
success: boolean
|
||||
statusCode?: number
|
||||
error?: string
|
||||
finalProvider?: string
|
||||
finalModel?: string
|
||||
finalUrl?: string
|
||||
failover: boolean
|
||||
attemptCount: number
|
||||
requestHeaders?: Record<string, string>
|
||||
requestParams?: unknown
|
||||
attempts: TraceAttempt[]
|
||||
}
|
||||
|
||||
export type Overview = {
|
||||
configPath: string
|
||||
providerCount: number
|
||||
@ -32,6 +113,39 @@ export type ProviderView = {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type ProviderSaveResult = {
|
||||
provider: ProviderView
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export type ProviderUpsertInput = {
|
||||
id: string
|
||||
name?: string
|
||||
baseUrl: string
|
||||
apiKey?: string
|
||||
headers?: Record<string, string>
|
||||
disabled: boolean
|
||||
skipModels: boolean
|
||||
clearHeaders: boolean
|
||||
}
|
||||
|
||||
export type ProviderStateInput = {
|
||||
id: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type ProviderImportInput = {
|
||||
sourcePath?: string
|
||||
overwrite: boolean
|
||||
}
|
||||
|
||||
export type ProviderImportResult = {
|
||||
sourcePath: string
|
||||
imported: number
|
||||
skipped: number
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export type AliasTargetView = {
|
||||
provider: string
|
||||
model: string
|
||||
@ -47,6 +161,19 @@ export type AliasView = {
|
||||
targets: AliasTargetView[]
|
||||
}
|
||||
|
||||
export type AliasUpsertInput = {
|
||||
alias: string
|
||||
displayName?: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type AliasTargetInput = {
|
||||
alias: string
|
||||
provider: string
|
||||
model: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type DoctorIssue = {
|
||||
message: string
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
38
frontend/wailsjs/go/desktop/App.d.ts
vendored
38
frontend/wailsjs/go/desktop/App.d.ts
vendored
@ -1,39 +1,65 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {app} from '../models';
|
||||
import {desktop} from '../models';
|
||||
import {context} from '../models';
|
||||
import {desktop} from '../models';
|
||||
|
||||
export function Aliases():Promise<Array<app.AliasView>>;
|
||||
|
||||
export function ApplySync(arg1:app.SyncInput):Promise<app.SyncResult>;
|
||||
|
||||
export function AutoStart():Promise<desktop.AutoStart>;
|
||||
|
||||
export function BeforeClose(arg1:context.Context):Promise<boolean>;
|
||||
|
||||
export function BindTarget(arg1:app.AliasTargetInput):Promise<app.AliasView>;
|
||||
|
||||
export function Bindings():Promise<desktop.Bindings>;
|
||||
|
||||
export function DeleteAlias(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteProvider(arg1:string):Promise<void>;
|
||||
|
||||
export function DesktopPrefs():Promise<app.DesktopPrefsView>;
|
||||
|
||||
export function DoctorRun():Promise<app.DoctorRunResult>;
|
||||
|
||||
export function ExportConfig():Promise<app.ConfigExportView>;
|
||||
|
||||
export function ImportConfig(arg1:app.ConfigImportInput):Promise<app.ConfigImportResult>;
|
||||
|
||||
export function ImportProviders(arg1:app.ProviderImportInput):Promise<app.ProviderImportResult>;
|
||||
|
||||
export function Meta():Promise<Record<string, string>>;
|
||||
|
||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||
|
||||
export function Overview():Promise<app.Overview>;
|
||||
|
||||
export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;
|
||||
|
||||
export function Providers():Promise<Array<app.ProviderView>>;
|
||||
|
||||
export function ProxySettings():Promise<app.ProxySettingsView>;
|
||||
|
||||
export function ProxyStatus():Promise<app.ProxyStatusView>;
|
||||
|
||||
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsView>;
|
||||
export function RequestTraces(arg1:number):Promise<Array<app.RequestTrace>>;
|
||||
|
||||
export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsView>;
|
||||
export function SaveAlias(arg1:app.AliasUpsertInput):Promise<app.AliasView>;
|
||||
|
||||
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
|
||||
|
||||
export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
|
||||
|
||||
export function SaveProvider(arg1:app.ProviderUpsertInput):Promise<app.ProviderSaveResult>;
|
||||
|
||||
export function SaveProxySettings(arg1:app.ProxySettingsInput):Promise<app.ProxySettingsSaveResult>;
|
||||
|
||||
export function Service():Promise<app.Service>;
|
||||
|
||||
export function SetProviderState(arg1:app.ProviderStateInput):Promise<app.ProviderView>;
|
||||
|
||||
export function SetTargetState(arg1:app.AliasTargetInput):Promise<app.AliasView>;
|
||||
|
||||
export function SetVersion(arg1:string):Promise<void>;
|
||||
|
||||
export function Shutdown(arg1:context.Context):Promise<void>;
|
||||
@ -46,4 +72,4 @@ export function StopProxy():Promise<app.ProxyStatusView>;
|
||||
|
||||
export function SyncDesktopPreferences(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function Tray():Promise<desktop.Tray>;
|
||||
export function UnbindTarget(arg1:app.AliasTargetInput):Promise<app.AliasView>;
|
||||
|
||||
@ -10,18 +10,26 @@ export function ApplySync(arg1) {
|
||||
return window['go']['desktop']['App']['ApplySync'](arg1);
|
||||
}
|
||||
|
||||
export function AutoStart() {
|
||||
return window['go']['desktop']['App']['AutoStart']();
|
||||
}
|
||||
|
||||
export function BeforeClose(arg1) {
|
||||
return window['go']['desktop']['App']['BeforeClose'](arg1);
|
||||
}
|
||||
|
||||
export function BindTarget(arg1) {
|
||||
return window['go']['desktop']['App']['BindTarget'](arg1);
|
||||
}
|
||||
|
||||
export function Bindings() {
|
||||
return window['go']['desktop']['App']['Bindings']();
|
||||
}
|
||||
|
||||
export function DeleteAlias(arg1) {
|
||||
return window['go']['desktop']['App']['DeleteAlias'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteProvider(arg1) {
|
||||
return window['go']['desktop']['App']['DeleteProvider'](arg1);
|
||||
}
|
||||
|
||||
export function DesktopPrefs() {
|
||||
return window['go']['desktop']['App']['DesktopPrefs']();
|
||||
}
|
||||
@ -30,10 +38,26 @@ export function DoctorRun() {
|
||||
return window['go']['desktop']['App']['DoctorRun']();
|
||||
}
|
||||
|
||||
export function ExportConfig() {
|
||||
return window['go']['desktop']['App']['ExportConfig']();
|
||||
}
|
||||
|
||||
export function ImportConfig(arg1) {
|
||||
return window['go']['desktop']['App']['ImportConfig'](arg1);
|
||||
}
|
||||
|
||||
export function ImportProviders(arg1) {
|
||||
return window['go']['desktop']['App']['ImportProviders'](arg1);
|
||||
}
|
||||
|
||||
export function Meta() {
|
||||
return window['go']['desktop']['App']['Meta']();
|
||||
}
|
||||
|
||||
export function OpenExternalURL(arg1) {
|
||||
return window['go']['desktop']['App']['OpenExternalURL'](arg1);
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
return window['go']['desktop']['App']['Overview']();
|
||||
}
|
||||
@ -46,10 +70,22 @@ export function Providers() {
|
||||
return window['go']['desktop']['App']['Providers']();
|
||||
}
|
||||
|
||||
export function ProxySettings() {
|
||||
return window['go']['desktop']['App']['ProxySettings']();
|
||||
}
|
||||
|
||||
export function ProxyStatus() {
|
||||
return window['go']['desktop']['App']['ProxyStatus']();
|
||||
}
|
||||
|
||||
export function RequestTraces(arg1) {
|
||||
return window['go']['desktop']['App']['RequestTraces'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAlias(arg1) {
|
||||
return window['go']['desktop']['App']['SaveAlias'](arg1);
|
||||
}
|
||||
|
||||
export function SaveDesktopPrefs(arg1, arg2) {
|
||||
return window['go']['desktop']['App']['SaveDesktopPrefs'](arg1, arg2);
|
||||
}
|
||||
@ -58,10 +94,26 @@ export function SavePrefs(arg1) {
|
||||
return window['go']['desktop']['App']['SavePrefs'](arg1);
|
||||
}
|
||||
|
||||
export function SaveProvider(arg1) {
|
||||
return window['go']['desktop']['App']['SaveProvider'](arg1);
|
||||
}
|
||||
|
||||
export function SaveProxySettings(arg1) {
|
||||
return window['go']['desktop']['App']['SaveProxySettings'](arg1);
|
||||
}
|
||||
|
||||
export function Service() {
|
||||
return window['go']['desktop']['App']['Service']();
|
||||
}
|
||||
|
||||
export function SetProviderState(arg1) {
|
||||
return window['go']['desktop']['App']['SetProviderState'](arg1);
|
||||
}
|
||||
|
||||
export function SetTargetState(arg1) {
|
||||
return window['go']['desktop']['App']['SetTargetState'](arg1);
|
||||
}
|
||||
|
||||
export function SetVersion(arg1) {
|
||||
return window['go']['desktop']['App']['SetVersion'](arg1);
|
||||
}
|
||||
@ -86,6 +138,6 @@ export function SyncDesktopPreferences(arg1) {
|
||||
return window['go']['desktop']['App']['SyncDesktopPreferences'](arg1);
|
||||
}
|
||||
|
||||
export function Tray() {
|
||||
return window['go']['desktop']['App']['Tray']();
|
||||
export function UnbindTarget(arg1) {
|
||||
return window['go']['desktop']['App']['UnbindTarget'](arg1);
|
||||
}
|
||||
|
||||
@ -1,5 +1,23 @@
|
||||
export namespace app {
|
||||
|
||||
export class AliasTargetInput {
|
||||
alias: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
disabled: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AliasTargetInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.alias = source["alias"];
|
||||
this.provider = source["provider"];
|
||||
this.model = source["model"];
|
||||
this.disabled = source["disabled"];
|
||||
}
|
||||
}
|
||||
export class AliasTargetView {
|
||||
provider: string;
|
||||
model: string;
|
||||
@ -16,6 +34,22 @@ export namespace app {
|
||||
this.enabled = source["enabled"];
|
||||
}
|
||||
}
|
||||
export class AliasUpsertInput {
|
||||
alias: string;
|
||||
displayName?: string;
|
||||
disabled: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AliasUpsertInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.alias = source["alias"];
|
||||
this.displayName = source["displayName"];
|
||||
this.disabled = source["disabled"];
|
||||
}
|
||||
}
|
||||
export class AliasView {
|
||||
alias: string;
|
||||
displayName?: string;
|
||||
@ -56,10 +90,53 @@ export namespace app {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ConfigExportView {
|
||||
configPath: string;
|
||||
content: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ConfigExportView(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.configPath = source["configPath"];
|
||||
this.content = source["content"];
|
||||
}
|
||||
}
|
||||
export class ConfigImportInput {
|
||||
content: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ConfigImportInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.content = source["content"];
|
||||
}
|
||||
}
|
||||
export class ConfigImportResult {
|
||||
configPath: string;
|
||||
warnings?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ConfigImportResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.configPath = source["configPath"];
|
||||
this.warnings = source["warnings"];
|
||||
}
|
||||
}
|
||||
export class DesktopPrefsInput {
|
||||
launchAtLogin: boolean;
|
||||
autoStartProxy: boolean;
|
||||
minimizeToTray: boolean;
|
||||
notifications: boolean;
|
||||
theme: string;
|
||||
language: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DesktopPrefsInput(source);
|
||||
@ -68,14 +145,20 @@ export namespace app {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.launchAtLogin = source["launchAtLogin"];
|
||||
this.autoStartProxy = source["autoStartProxy"];
|
||||
this.minimizeToTray = source["minimizeToTray"];
|
||||
this.notifications = source["notifications"];
|
||||
this.theme = source["theme"];
|
||||
this.language = source["language"];
|
||||
}
|
||||
}
|
||||
export class DesktopPrefsView {
|
||||
launchAtLogin: boolean;
|
||||
autoStartProxy: boolean;
|
||||
minimizeToTray: boolean;
|
||||
notifications: boolean;
|
||||
theme: string;
|
||||
language: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DesktopPrefsView(source);
|
||||
@ -84,10 +167,46 @@ export namespace app {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.launchAtLogin = source["launchAtLogin"];
|
||||
this.autoStartProxy = source["autoStartProxy"];
|
||||
this.minimizeToTray = source["minimizeToTray"];
|
||||
this.notifications = source["notifications"];
|
||||
this.theme = source["theme"];
|
||||
this.language = source["language"];
|
||||
}
|
||||
}
|
||||
export class DesktopPrefsSaveResult {
|
||||
prefs: DesktopPrefsView;
|
||||
warnings?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DesktopPrefsSaveResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.prefs = this.convertValues(source["prefs"], DesktopPrefsView);
|
||||
this.warnings = source["warnings"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class DoctorIssue {
|
||||
message: string;
|
||||
|
||||
@ -179,8 +298,7 @@ export namespace app {
|
||||
export class ProxyStatusView {
|
||||
running: boolean;
|
||||
bindAddress: string;
|
||||
// Go type: time
|
||||
startedAt?: any;
|
||||
startedAt?: string;
|
||||
lastError?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@ -191,27 +309,9 @@ export namespace app {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.running = source["running"];
|
||||
this.bindAddress = source["bindAddress"];
|
||||
this.startedAt = this.convertValues(source["startedAt"], null);
|
||||
this.startedAt = source["startedAt"];
|
||||
this.lastError = source["lastError"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class Overview {
|
||||
configPath: string;
|
||||
@ -253,6 +353,38 @@ export namespace app {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ProviderImportInput {
|
||||
sourcePath?: string;
|
||||
overwrite: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderImportInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.sourcePath = source["sourcePath"];
|
||||
this.overwrite = source["overwrite"];
|
||||
}
|
||||
}
|
||||
export class ProviderImportResult {
|
||||
sourcePath: string;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
warnings?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderImportResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.sourcePath = source["sourcePath"];
|
||||
this.imported = source["imported"];
|
||||
this.skipped = source["skipped"];
|
||||
this.warnings = source["warnings"];
|
||||
}
|
||||
}
|
||||
export class ProviderView {
|
||||
id: string;
|
||||
name?: string;
|
||||
@ -281,7 +413,263 @@ export namespace app {
|
||||
this.disabled = source["disabled"];
|
||||
}
|
||||
}
|
||||
export class ProviderSaveResult {
|
||||
provider: ProviderView;
|
||||
warnings?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderSaveResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.provider = this.convertValues(source["provider"], ProviderView);
|
||||
this.warnings = source["warnings"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ProviderStateInput {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderStateInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.disabled = source["disabled"];
|
||||
}
|
||||
}
|
||||
export class ProviderUpsertInput {
|
||||
id: string;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
disabled: boolean;
|
||||
skipModels: boolean;
|
||||
clearHeaders: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderUpsertInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.baseUrl = source["baseUrl"];
|
||||
this.apiKey = source["apiKey"];
|
||||
this.headers = source["headers"];
|
||||
this.disabled = source["disabled"];
|
||||
this.skipModels = source["skipModels"];
|
||||
this.clearHeaders = source["clearHeaders"];
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxySettingsInput {
|
||||
connectTimeoutMs: number;
|
||||
responseHeaderTimeoutMs: number;
|
||||
firstByteTimeoutMs: number;
|
||||
requestReadTimeoutMs: number;
|
||||
streamIdleTimeoutMs: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProxySettingsInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.connectTimeoutMs = source["connectTimeoutMs"];
|
||||
this.responseHeaderTimeoutMs = source["responseHeaderTimeoutMs"];
|
||||
this.firstByteTimeoutMs = source["firstByteTimeoutMs"];
|
||||
this.requestReadTimeoutMs = source["requestReadTimeoutMs"];
|
||||
this.streamIdleTimeoutMs = source["streamIdleTimeoutMs"];
|
||||
}
|
||||
}
|
||||
export class ProxySettingsView {
|
||||
connectTimeoutMs: number;
|
||||
responseHeaderTimeoutMs: number;
|
||||
firstByteTimeoutMs: number;
|
||||
requestReadTimeoutMs: number;
|
||||
streamIdleTimeoutMs: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProxySettingsView(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.connectTimeoutMs = source["connectTimeoutMs"];
|
||||
this.responseHeaderTimeoutMs = source["responseHeaderTimeoutMs"];
|
||||
this.firstByteTimeoutMs = source["firstByteTimeoutMs"];
|
||||
this.requestReadTimeoutMs = source["requestReadTimeoutMs"];
|
||||
this.streamIdleTimeoutMs = source["streamIdleTimeoutMs"];
|
||||
}
|
||||
}
|
||||
export class ProxySettingsSaveResult {
|
||||
settings: ProxySettingsView;
|
||||
warnings?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProxySettingsSaveResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.settings = this.convertValues(source["settings"], ProxySettingsView);
|
||||
this.warnings = source["warnings"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class TraceAttempt {
|
||||
attempt: number;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
url?: string;
|
||||
startedAt: string;
|
||||
durationMs: number;
|
||||
firstByteMs?: number;
|
||||
statusCode?: number;
|
||||
success: boolean;
|
||||
retryable: boolean;
|
||||
skipped: boolean;
|
||||
result?: string;
|
||||
error?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestParams?: any;
|
||||
responseHeaders?: Record<string, string>;
|
||||
responseBody?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TraceAttempt(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.attempt = source["attempt"];
|
||||
this.provider = source["provider"];
|
||||
this.model = source["model"];
|
||||
this.url = source["url"];
|
||||
this.startedAt = source["startedAt"];
|
||||
this.durationMs = source["durationMs"];
|
||||
this.firstByteMs = source["firstByteMs"];
|
||||
this.statusCode = source["statusCode"];
|
||||
this.success = source["success"];
|
||||
this.retryable = source["retryable"];
|
||||
this.skipped = source["skipped"];
|
||||
this.result = source["result"];
|
||||
this.error = source["error"];
|
||||
this.requestHeaders = source["requestHeaders"];
|
||||
this.requestParams = source["requestParams"];
|
||||
this.responseHeaders = source["responseHeaders"];
|
||||
this.responseBody = source["responseBody"];
|
||||
}
|
||||
}
|
||||
export class RequestTrace {
|
||||
id: number;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
durationMs: number;
|
||||
firstByteMs?: number;
|
||||
rawModel?: string;
|
||||
alias?: string;
|
||||
stream: boolean;
|
||||
success: boolean;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
finalProvider?: string;
|
||||
finalModel?: string;
|
||||
finalUrl?: string;
|
||||
failover: boolean;
|
||||
attemptCount: number;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestParams?: any;
|
||||
attempts: TraceAttempt[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RequestTrace(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.startedAt = source["startedAt"];
|
||||
this.finishedAt = source["finishedAt"];
|
||||
this.durationMs = source["durationMs"];
|
||||
this.firstByteMs = source["firstByteMs"];
|
||||
this.rawModel = source["rawModel"];
|
||||
this.alias = source["alias"];
|
||||
this.stream = source["stream"];
|
||||
this.success = source["success"];
|
||||
this.statusCode = source["statusCode"];
|
||||
this.error = source["error"];
|
||||
this.finalProvider = source["finalProvider"];
|
||||
this.finalModel = source["finalModel"];
|
||||
this.finalUrl = source["finalUrl"];
|
||||
this.failover = source["failover"];
|
||||
this.attemptCount = source["attemptCount"];
|
||||
this.requestHeaders = source["requestHeaders"];
|
||||
this.requestParams = source["requestParams"];
|
||||
this.attempts = this.convertValues(source["attempts"], TraceAttempt);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class Service {
|
||||
|
||||
|
||||
@ -359,18 +747,6 @@ export namespace app {
|
||||
|
||||
export namespace desktop {
|
||||
|
||||
export class AutoStart {
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AutoStart(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
|
||||
}
|
||||
}
|
||||
export class Bindings {
|
||||
|
||||
|
||||
@ -383,18 +759,6 @@ export namespace desktop {
|
||||
|
||||
}
|
||||
}
|
||||
export class Tray {
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Tray(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
3
go.mod
3
go.mod
@ -3,9 +3,11 @@ module github.com/Apale7/opencode-provider-switch
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/tidwall/jsonc v0.3.2
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
golang.org/x/sys v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
@ -37,6 +39,5 @@ require (
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@ -1,3 +1,5 @@
|
||||
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 h1:eA5/u2XRd8OUkoMqEv3IBlFYSruNlXD8bRHDiqm0VNI=
|
||||
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
|
||||
90
internal/app/config_transfer.go
Normal file
90
internal/app/config_transfer.go
Normal file
@ -0,0 +1,90 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
func (s *Service) ExportConfig(ctx context.Context) (ConfigExportView, error) {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ConfigExportView{}, err
|
||||
}
|
||||
content, err := marshalConfigContent(cfg)
|
||||
if err != nil {
|
||||
return ConfigExportView{}, err
|
||||
}
|
||||
return ConfigExportView{ConfigPath: cfg.Path(), Content: content}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ImportConfig(ctx context.Context, in ConfigImportInput) (ConfigImportResult, error) {
|
||||
_ = ctx
|
||||
content := strings.TrimSpace(in.Content)
|
||||
if content == "" {
|
||||
return ConfigImportResult{}, fmt.Errorf("config content is required")
|
||||
}
|
||||
|
||||
imported := config.Default()
|
||||
if err := json.Unmarshal([]byte(content), imported); err != nil {
|
||||
return ConfigImportResult{}, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if imported.Server.Host == "" {
|
||||
imported.Server.Host = "127.0.0.1"
|
||||
}
|
||||
if imported.Server.Port == 0 {
|
||||
imported.Server.Port = 9982
|
||||
}
|
||||
if imported.Server.APIKey == "" {
|
||||
imported.Server.APIKey = config.DefaultLocalAPIKey
|
||||
}
|
||||
if errs := imported.Validate(); len(errs) > 0 {
|
||||
return ConfigImportResult{}, errs[0]
|
||||
}
|
||||
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ConfigImportResult{}, err
|
||||
}
|
||||
cfg.Server = imported.Server
|
||||
cfg.Desktop = imported.Desktop
|
||||
cfg.Providers = append([]config.Provider(nil), imported.Providers...)
|
||||
cfg.Aliases = append([]config.Alias(nil), imported.Aliases...)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return ConfigImportResult{}, err
|
||||
}
|
||||
|
||||
result := ConfigImportResult{ConfigPath: cfg.Path()}
|
||||
if s.currentProxyStatus(proxyBindAddress(cfg)).Running {
|
||||
result.Warnings = append(result.Warnings, "proxy is still running with the previous in-memory config; restart it to apply imported settings")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func marshalConfigContent(cfg *config.Config) (string, error) {
|
||||
providers := append([]config.Provider(nil), cfg.Providers...)
|
||||
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
|
||||
aliases := append([]config.Alias(nil), cfg.Aliases...)
|
||||
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
|
||||
snapshot := struct {
|
||||
Server config.Server `json:"server"`
|
||||
Desktop config.Desktop `json:"desktop,omitempty"`
|
||||
Providers []config.Provider `json:"providers"`
|
||||
Aliases []config.Alias `json:"aliases"`
|
||||
}{
|
||||
Server: cfg.Server,
|
||||
Desktop: cfg.Desktop,
|
||||
Providers: providers,
|
||||
Aliases: aliases,
|
||||
}
|
||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
return string(append(data, '\n')), nil
|
||||
}
|
||||
380
internal/app/manage.go
Normal file
380
internal/app/manage.go
Normal file
@ -0,0 +1,380 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
||||
)
|
||||
|
||||
func (s *Service) UpsertProvider(ctx context.Context, in ProviderUpsertInput) (ProviderSaveResult, error) {
|
||||
_ = ctx
|
||||
if strings.TrimSpace(in.ID) == "" {
|
||||
return ProviderSaveResult{}, fmt.Errorf("provider id is required")
|
||||
}
|
||||
if err := config.ValidateProviderBaseURL(in.BaseURL); err != nil {
|
||||
return ProviderSaveResult{}, fmt.Errorf("invalid baseUrl: %w", err)
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ProviderSaveResult{}, err
|
||||
}
|
||||
warnings := []string{}
|
||||
provider := config.Provider{
|
||||
ID: strings.TrimSpace(in.ID),
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
BaseURL: config.NormalizeProviderBaseURL(in.BaseURL),
|
||||
APIKey: in.APIKey,
|
||||
Headers: normalizeProviderHeaders(in.Headers),
|
||||
Disabled: in.Disabled,
|
||||
}
|
||||
var existing *config.Provider
|
||||
if cur := cfg.FindProvider(provider.ID); cur != nil {
|
||||
existing = cur
|
||||
if provider.Name == "" {
|
||||
provider.Name = cur.Name
|
||||
}
|
||||
if provider.APIKey == "" {
|
||||
provider.APIKey = cur.APIKey
|
||||
}
|
||||
if len(provider.Headers) == 0 && !in.ClearHeaders && len(cur.Headers) > 0 {
|
||||
provider.Headers = cloneHeaders(cur.Headers)
|
||||
}
|
||||
provider.Models = append([]string(nil), cur.Models...)
|
||||
provider.ModelsSource = cur.ModelsSource
|
||||
if !providerConnectionEqual(*cur, provider) {
|
||||
provider.ModelsSource = ""
|
||||
}
|
||||
}
|
||||
if !in.SkipModels {
|
||||
models, err := opencode.FetchProviderModels(provider.BaseURL, provider.APIKey, provider.Headers)
|
||||
if err != nil {
|
||||
if existing != nil && !providerConnectionEqual(*existing, provider) {
|
||||
provider.Models = append([]string(nil), existing.Models...)
|
||||
provider.ModelsSource = ""
|
||||
warnings = append(warnings, "provider connection changed and model discovery failed; keeping existing model catalog as untrusted")
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("could not discover provider models: %v", err))
|
||||
} else if normalized := config.NormalizeProviderModels(models); len(normalized) > 0 {
|
||||
provider.Models = normalized
|
||||
provider.ModelsSource = "discovered"
|
||||
} else if existing != nil && !providerConnectionEqual(*existing, provider) {
|
||||
provider.Models = append([]string(nil), existing.Models...)
|
||||
provider.ModelsSource = ""
|
||||
warnings = append(warnings, "provider connection changed and model discovery returned no models; keeping existing model catalog as untrusted")
|
||||
} else {
|
||||
warnings = append(warnings, "provider model discovery returned no models; keeping existing model catalog")
|
||||
}
|
||||
} else if existing != nil && !providerConnectionEqual(*existing, provider) {
|
||||
provider.Models = append([]string(nil), existing.Models...)
|
||||
provider.ModelsSource = ""
|
||||
warnings = append(warnings, "provider connection changed with skip models enabled; keeping existing model catalog as untrusted")
|
||||
}
|
||||
cfg.UpsertProvider(provider)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return ProviderSaveResult{}, err
|
||||
}
|
||||
return ProviderSaveResult{Provider: providerView(provider), Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveProvider(ctx context.Context, id string) error {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.RemoveProvider(strings.TrimSpace(id)) {
|
||||
return fmt.Errorf("provider %q not found", id)
|
||||
}
|
||||
return cfg.Save()
|
||||
}
|
||||
|
||||
func (s *Service) SetAliasTargetDisabled(ctx context.Context, in AliasTargetInput) (AliasView, error) {
|
||||
_ = ctx
|
||||
alias := strings.TrimSpace(in.Alias)
|
||||
providerID := strings.TrimSpace(in.Provider)
|
||||
model := strings.TrimSpace(in.Model)
|
||||
if alias == "" || providerID == "" || model == "" {
|
||||
return AliasView{}, fmt.Errorf("alias, provider and model are required")
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
current := cfg.FindAlias(alias)
|
||||
if current == nil {
|
||||
return AliasView{}, fmt.Errorf("alias %q not found", alias)
|
||||
}
|
||||
updated := *current
|
||||
found := false
|
||||
for i := range updated.Targets {
|
||||
if updated.Targets[i].Provider == providerID && updated.Targets[i].Model == model {
|
||||
updated.Targets[i].Enabled = !in.Disabled
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return AliasView{}, fmt.Errorf("target %s/%s not found on alias %s", providerID, model, alias)
|
||||
}
|
||||
cfg.UpsertAlias(updated)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
return aliasView(cfg, updated), nil
|
||||
}
|
||||
|
||||
func (s *Service) SetProviderDisabled(ctx context.Context, in ProviderStateInput) (ProviderView, error) {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ProviderView{}, err
|
||||
}
|
||||
existing := cfg.FindProvider(strings.TrimSpace(in.ID))
|
||||
if existing == nil {
|
||||
return ProviderView{}, fmt.Errorf("provider %q not found", in.ID)
|
||||
}
|
||||
updated := *existing
|
||||
updated.Disabled = in.Disabled
|
||||
cfg.UpsertProvider(updated)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return ProviderView{}, err
|
||||
}
|
||||
return providerView(updated), nil
|
||||
}
|
||||
|
||||
func (s *Service) ImportProviders(ctx context.Context, in ProviderImportInput) (ProviderImportResult, error) {
|
||||
_ = ctx
|
||||
sourcePath := strings.TrimSpace(in.SourcePath)
|
||||
if sourcePath == "" {
|
||||
p, existed := opencode.ResolveGlobalConfigPath()
|
||||
if !existed {
|
||||
return ProviderImportResult{}, fmt.Errorf("no OpenCode config found at %s; use sourcePath to specify", p)
|
||||
}
|
||||
sourcePath = p
|
||||
}
|
||||
raw, err := opencode.Load(sourcePath)
|
||||
if err != nil {
|
||||
return ProviderImportResult{}, err
|
||||
}
|
||||
imports := opencode.ImportCustomProviders(raw)
|
||||
result := ProviderImportResult{SourcePath: sourcePath}
|
||||
if len(imports) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ProviderImportResult{}, err
|
||||
}
|
||||
for _, ip := range imports {
|
||||
if !in.Overwrite && cfg.FindProvider(ip.ID) != nil {
|
||||
result.Skipped++
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("skip %q (already exists, enable overwrite to replace it)", ip.ID))
|
||||
continue
|
||||
}
|
||||
baseURL := config.NormalizeProviderBaseURL(ip.BaseURL)
|
||||
if err := config.ValidateProviderBaseURL(baseURL); err != nil {
|
||||
result.Skipped++
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("skip %q (invalid baseURL %q: %v)", ip.ID, ip.BaseURL, err))
|
||||
continue
|
||||
}
|
||||
merged := mergeImportedProvider(cfg.FindProvider(ip.ID), opencode.ImportableProvider{
|
||||
ID: ip.ID,
|
||||
Name: ip.Name,
|
||||
BaseURL: baseURL,
|
||||
APIKey: ip.APIKey,
|
||||
Models: ip.Models,
|
||||
})
|
||||
cfg.UpsertProvider(merged)
|
||||
result.Imported++
|
||||
}
|
||||
if result.Imported > 0 {
|
||||
if err := cfg.Save(); err != nil {
|
||||
return ProviderImportResult{}, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpsertAlias(ctx context.Context, in AliasUpsertInput) (AliasView, error) {
|
||||
_ = ctx
|
||||
name := strings.TrimSpace(in.Alias)
|
||||
if name == "" {
|
||||
return AliasView{}, fmt.Errorf("alias name is required")
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
a := config.Alias{Alias: name, DisplayName: strings.TrimSpace(in.DisplayName), Enabled: !in.Disabled}
|
||||
if existing := cfg.FindAlias(name); existing != nil {
|
||||
if a.DisplayName == "" {
|
||||
a.DisplayName = existing.DisplayName
|
||||
}
|
||||
a.Targets = existing.Targets
|
||||
}
|
||||
cfg.UpsertAlias(a)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
return aliasView(cfg, a), nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveAlias(ctx context.Context, name string) error {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.RemoveAlias(strings.TrimSpace(name)) {
|
||||
return fmt.Errorf("alias %q not found", name)
|
||||
}
|
||||
return cfg.Save()
|
||||
}
|
||||
|
||||
func (s *Service) BindAliasTarget(ctx context.Context, in AliasTargetInput) (AliasView, error) {
|
||||
_ = ctx
|
||||
alias := strings.TrimSpace(in.Alias)
|
||||
providerID := strings.TrimSpace(in.Provider)
|
||||
model := strings.TrimSpace(in.Model)
|
||||
if alias == "" || providerID == "" || model == "" {
|
||||
return AliasView{}, fmt.Errorf("alias, provider and model are required")
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
p := cfg.FindProvider(providerID)
|
||||
if p == nil {
|
||||
return AliasView{}, fmt.Errorf("provider %q does not exist; add it first", providerID)
|
||||
}
|
||||
if err := validateProviderModelKnown(providerID, p.Models, p.ModelsSource, model); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
if cfg.FindAlias(alias) == nil {
|
||||
cfg.UpsertAlias(config.Alias{Alias: alias, Enabled: true})
|
||||
}
|
||||
if err := cfg.AddTarget(alias, config.Target{Provider: providerID, Model: model, Enabled: !in.Disabled}); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
current := cfg.FindAlias(alias)
|
||||
if current == nil {
|
||||
return AliasView{}, fmt.Errorf("alias %q not found", alias)
|
||||
}
|
||||
return aliasView(cfg, *current), nil
|
||||
}
|
||||
|
||||
func (s *Service) UnbindAliasTarget(ctx context.Context, in AliasTargetInput) (AliasView, error) {
|
||||
_ = ctx
|
||||
alias := strings.TrimSpace(in.Alias)
|
||||
providerID := strings.TrimSpace(in.Provider)
|
||||
model := strings.TrimSpace(in.Model)
|
||||
if alias == "" || providerID == "" || model == "" {
|
||||
return AliasView{}, fmt.Errorf("alias, provider and model are required")
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
if err := cfg.RemoveTarget(alias, providerID, model); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
current := cfg.FindAlias(alias)
|
||||
if current == nil {
|
||||
return AliasView{}, fmt.Errorf("alias %q not found", alias)
|
||||
}
|
||||
return aliasView(cfg, *current), nil
|
||||
}
|
||||
|
||||
func providerConnectionEqual(a, b config.Provider) bool {
|
||||
return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) &&
|
||||
a.APIKey == b.APIKey &&
|
||||
reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers))
|
||||
}
|
||||
|
||||
func normalizeProviderHeaders(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
key := strings.ToLower(strings.TrimSpace(k))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = strings.TrimSpace(v)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeImportedProvider(existing *config.Provider, ip opencode.ImportableProvider) config.Provider {
|
||||
importedModels := config.NormalizeProviderModels(ip.Models)
|
||||
merged := config.Provider{
|
||||
ID: ip.ID,
|
||||
Name: ip.Name,
|
||||
BaseURL: config.NormalizeProviderBaseURL(ip.BaseURL),
|
||||
APIKey: ip.APIKey,
|
||||
Models: importedModels,
|
||||
ModelsSource: "imported",
|
||||
}
|
||||
if len(importedModels) == 0 {
|
||||
merged.ModelsSource = ""
|
||||
}
|
||||
if existing == nil {
|
||||
return merged
|
||||
}
|
||||
merged.Headers = cloneHeaders(existing.Headers)
|
||||
merged.Disabled = existing.Disabled
|
||||
if merged.Name == "" {
|
||||
merged.Name = existing.Name
|
||||
}
|
||||
if existing.ModelsSource == "discovered" {
|
||||
prospective := merged
|
||||
prospective.Headers = cloneHeaders(existing.Headers)
|
||||
prospective.Disabled = existing.Disabled
|
||||
if providerConnectionEqual(*existing, prospective) {
|
||||
merged.Models = append([]string(nil), existing.Models...)
|
||||
merged.ModelsSource = existing.ModelsSource
|
||||
return merged
|
||||
}
|
||||
if len(importedModels) == 0 {
|
||||
merged.Models = append([]string(nil), existing.Models...)
|
||||
merged.ModelsSource = ""
|
||||
return merged
|
||||
}
|
||||
}
|
||||
if len(importedModels) == 0 {
|
||||
merged.Models = nil
|
||||
merged.ModelsSource = ""
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func validateProviderModelKnown(providerID string, known []string, source string, model string) error {
|
||||
if source != "discovered" || len(known) == 0 {
|
||||
return nil
|
||||
}
|
||||
if slices.Contains(known, model) {
|
||||
return nil
|
||||
}
|
||||
choices := make([]string, 0, len(known))
|
||||
for _, item := range known {
|
||||
choices = append(choices, providerID+"/"+item)
|
||||
}
|
||||
sort.Strings(choices)
|
||||
return fmt.Errorf("model %q is not in provider %q discovered models; available: %s", model, providerID, strings.Join(choices, ", "))
|
||||
}
|
||||
@ -15,16 +15,18 @@ import (
|
||||
|
||||
type Service struct {
|
||||
configPath string
|
||||
traces *proxy.TraceStore
|
||||
|
||||
mu sync.Mutex
|
||||
proxyCancel context.CancelFunc
|
||||
proxyDone chan struct{}
|
||||
proxyReady chan error
|
||||
proxyErr error
|
||||
proxyStatus ProxyStatusView
|
||||
}
|
||||
|
||||
func NewService(configPath string) *Service {
|
||||
return &Service{configPath: strings.TrimSpace(configPath)}
|
||||
return &Service{configPath: strings.TrimSpace(configPath), traces: proxy.NewTraceStore(200)}
|
||||
}
|
||||
|
||||
func (s *Service) ConfigPath() string {
|
||||
@ -155,7 +157,6 @@ func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResu
|
||||
}
|
||||
|
||||
func (s *Service) StartProxy(ctx context.Context) error {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
@ -165,29 +166,36 @@ func (s *Service) StartProxy(ctx context.Context) error {
|
||||
}
|
||||
|
||||
bindAddress := proxyBindAddress(cfg)
|
||||
started := false
|
||||
|
||||
s.mu.Lock()
|
||||
if s.proxyCancel == nil {
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
ready := make(chan error, 1)
|
||||
s.proxyCancel = cancel
|
||||
s.proxyDone = done
|
||||
s.proxyReady = ready
|
||||
s.proxyErr = nil
|
||||
s.proxyStatus = ProxyStatusView{
|
||||
Running: true,
|
||||
BindAddress: bindAddress,
|
||||
StartedAt: time.Now(),
|
||||
StartedAt: formatTimestamp(time.Now()),
|
||||
}
|
||||
started = true
|
||||
go s.runProxy(runCtx, cancel, done, cfg, bindAddress)
|
||||
go s.runProxy(runCtx, cancel, done, ready, cfg, bindAddress)
|
||||
}
|
||||
ready := s.proxyReady
|
||||
done := s.proxyDone
|
||||
s.mu.Unlock()
|
||||
|
||||
if !started {
|
||||
return nil
|
||||
select {
|
||||
case err := <-ready:
|
||||
return err
|
||||
case <-done:
|
||||
return s.WaitProxy(context.Background())
|
||||
case <-ctx.Done():
|
||||
_ = s.StopProxy(context.Background())
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) StopProxy(ctx context.Context) error {
|
||||
@ -234,6 +242,47 @@ func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) {
|
||||
return s.currentProxyStatus(proxyBindAddress(cfg)), nil
|
||||
}
|
||||
|
||||
func (s *Service) GetProxySettings(ctx context.Context) (ProxySettingsView, error) {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ProxySettingsView{}, err
|
||||
}
|
||||
return proxySettingsView(cfg.Server), nil
|
||||
}
|
||||
|
||||
func (s *Service) SaveProxySettings(ctx context.Context, in ProxySettingsInput) (ProxySettingsSaveResult, error) {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ProxySettingsSaveResult{}, err
|
||||
}
|
||||
cfg.Server.ConnectTimeoutMs = normalizePositiveInt(in.ConnectTimeoutMs, config.DefaultConnectTimeoutMs)
|
||||
cfg.Server.ResponseHeaderTimeoutMs = normalizePositiveInt(in.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
|
||||
cfg.Server.FirstByteTimeoutMs = normalizePositiveInt(in.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
|
||||
cfg.Server.RequestReadTimeoutMs = normalizePositiveInt(in.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs)
|
||||
cfg.Server.StreamIdleTimeoutMs = normalizePositiveInt(in.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return ProxySettingsSaveResult{}, err
|
||||
}
|
||||
warnings := []string{}
|
||||
status := s.currentProxyStatus(proxyBindAddress(cfg))
|
||||
if status.Running {
|
||||
warnings = append(warnings, "saved proxy timeout settings; restart proxy to apply changes")
|
||||
}
|
||||
return ProxySettingsSaveResult{Settings: proxySettingsView(cfg.Server), Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListRequestTraces(ctx context.Context, limit int) ([]RequestTrace, error) {
|
||||
_ = ctx
|
||||
raw := s.traces.List(limit)
|
||||
out := make([]RequestTrace, 0, len(raw))
|
||||
for _, trace := range raw {
|
||||
out = append(out, requestTraceView(trace))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
@ -251,8 +300,11 @@ func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (D
|
||||
}
|
||||
cfg.Desktop = config.Desktop{
|
||||
LaunchAtLogin: in.LaunchAtLogin,
|
||||
AutoStartProxy: in.AutoStartProxy,
|
||||
MinimizeToTray: in.MinimizeToTray,
|
||||
Notifications: in.Notifications,
|
||||
Theme: normalizeThemePreference(in.Theme),
|
||||
Language: normalizeLanguagePreference(in.Language),
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
return DesktopPrefsView{}, err
|
||||
@ -314,19 +366,20 @@ func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
status := s.proxyStatus
|
||||
if status.BindAddress == "" {
|
||||
if !status.Running || status.BindAddress == "" {
|
||||
status.BindAddress = bindAddress
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, cfg *config.Config, bindAddress string) {
|
||||
err := proxy.New(cfg).ListenAndServe(runCtx)
|
||||
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, ready chan error, cfg *config.Config, bindAddress string) {
|
||||
err := proxy.New(cfg, s.traces).ListenAndServeWithReady(runCtx, ready)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.proxyDone == done {
|
||||
s.proxyCancel = nil
|
||||
s.proxyDone = nil
|
||||
s.proxyReady = nil
|
||||
}
|
||||
s.proxyErr = err
|
||||
status := s.proxyStatus
|
||||
@ -385,8 +438,48 @@ func doctorIssues(errs []error) []DoctorIssue {
|
||||
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
|
||||
return DesktopPrefsView{
|
||||
LaunchAtLogin: prefs.LaunchAtLogin,
|
||||
AutoStartProxy: prefs.AutoStartProxy,
|
||||
MinimizeToTray: prefs.MinimizeToTray,
|
||||
Notifications: prefs.Notifications,
|
||||
Theme: normalizeThemePreference(prefs.Theme),
|
||||
Language: normalizeLanguagePreference(prefs.Language),
|
||||
}
|
||||
}
|
||||
|
||||
func proxySettingsView(server config.Server) ProxySettingsView {
|
||||
return ProxySettingsView{
|
||||
ConnectTimeoutMs: normalizePositiveInt(server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
||||
ResponseHeaderTimeoutMs: normalizePositiveInt(server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs),
|
||||
FirstByteTimeoutMs: normalizePositiveInt(server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs),
|
||||
RequestReadTimeoutMs: normalizePositiveInt(server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
|
||||
StreamIdleTimeoutMs: normalizePositiveInt(server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePositiveInt(value int, fallback int) int {
|
||||
if value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeThemePreference(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch trimmed {
|
||||
case "light", "dark":
|
||||
return trimmed
|
||||
default:
|
||||
return "system"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLanguagePreference(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch trimmed {
|
||||
case "en-US", "zh-CN":
|
||||
return trimmed
|
||||
default:
|
||||
return "system"
|
||||
}
|
||||
}
|
||||
|
||||
@ -436,6 +529,13 @@ func cloneHeaders(in map[string]string) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func formatTimestamp(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func maskKey(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
|
||||
@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -20,13 +23,16 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
||||
|
||||
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
||||
LaunchAtLogin: true,
|
||||
AutoStartProxy: true,
|
||||
MinimizeToTray: true,
|
||||
Notifications: true,
|
||||
Theme: "dark",
|
||||
Language: "zh-CN",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
||||
}
|
||||
if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications {
|
||||
if !prefs.LaunchAtLogin || !prefs.AutoStartProxy || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" {
|
||||
t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
|
||||
}
|
||||
|
||||
@ -34,11 +40,125 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications {
|
||||
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.AutoStartProxy || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications || cfg.Desktop.Theme != "dark" || cfg.Desktop.Language != "zh-CN" {
|
||||
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDesktopPrefsNormalizesUnknownThemeAndLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
svc := NewService(path)
|
||||
|
||||
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
||||
Theme: "night-mode",
|
||||
Language: "fr-FR",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
||||
}
|
||||
if prefs.Theme != "system" || prefs.Language != "system" {
|
||||
t.Fatalf("normalized prefs = %#v", prefs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProxySettingsPersistsToConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
svc := NewService(path)
|
||||
|
||||
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{
|
||||
ConnectTimeoutMs: 12000,
|
||||
ResponseHeaderTimeoutMs: 21000,
|
||||
FirstByteTimeoutMs: 22000,
|
||||
RequestReadTimeoutMs: 33000,
|
||||
StreamIdleTimeoutMs: 70000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveProxySettings() error = %v", err)
|
||||
}
|
||||
if result.Settings.ConnectTimeoutMs != 12000 || result.Settings.ResponseHeaderTimeoutMs != 21000 || result.Settings.FirstByteTimeoutMs != 22000 || result.Settings.RequestReadTimeoutMs != 33000 || result.Settings.StreamIdleTimeoutMs != 70000 {
|
||||
t.Fatalf("SaveProxySettings() = %#v", result.Settings)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if cfg.Server.ConnectTimeoutMs != 12000 || cfg.Server.ResponseHeaderTimeoutMs != 21000 || cfg.Server.FirstByteTimeoutMs != 22000 || cfg.Server.RequestReadTimeoutMs != 33000 || cfg.Server.StreamIdleTimeoutMs != 70000 {
|
||||
t.Fatalf("persisted server settings = %#v", cfg.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProxySettingsWarnsWhenProxyRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
port := freePort(t)
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
cfg.Server.Port = port
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
if err := svc.StartProxy(context.Background()); err != nil {
|
||||
t.Fatalf("StartProxy() error = %v", err)
|
||||
}
|
||||
defer func() { _ = svc.StopProxy(context.Background()) }()
|
||||
|
||||
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{ConnectTimeoutMs: 12000})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveProxySettings() error = %v", err)
|
||||
}
|
||||
if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "restart proxy") {
|
||||
t.Fatalf("warnings = %#v", result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProxySettingsNormalizesNonPositiveValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
svc := NewService(path)
|
||||
|
||||
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{
|
||||
ConnectTimeoutMs: 0,
|
||||
ResponseHeaderTimeoutMs: -1,
|
||||
FirstByteTimeoutMs: 0,
|
||||
RequestReadTimeoutMs: -50,
|
||||
StreamIdleTimeoutMs: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveProxySettings() error = %v", err)
|
||||
}
|
||||
if result.Settings.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
|
||||
result.Settings.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
|
||||
result.Settings.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
|
||||
result.Settings.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
|
||||
result.Settings.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
|
||||
t.Fatalf("normalized settings = %#v", result.Settings)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if cfg.Server.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
|
||||
cfg.Server.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
|
||||
cfg.Server.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
|
||||
cfg.Server.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
|
||||
cfg.Server.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
|
||||
t.Fatalf("persisted server settings = %#v", cfg.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@ -93,6 +213,378 @@ func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartProxyReturnsBindErrorWithoutRunningStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
port := freePort(t)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:"+itoa(port))
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
cfg.Server.Port = port
|
||||
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
err = svc.StartProxy(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("StartProxy() error = nil, want bind failure")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "bind") {
|
||||
t.Fatalf("StartProxy() error = %v, want bind failure", err)
|
||||
}
|
||||
|
||||
status, statusErr := svc.GetProxyStatus(context.Background())
|
||||
if statusErr != nil {
|
||||
t.Fatalf("GetProxyStatus() error = %v", statusErr)
|
||||
}
|
||||
if status.Running {
|
||||
t.Fatalf("status = %#v, want stopped", status)
|
||||
}
|
||||
if status.LastError == "" {
|
||||
t.Fatalf("status = %#v, want last error", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentStartProxyCallsShareStartupResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
port := freePort(t)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:"+itoa(port))
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
cfg.Server.Port = port
|
||||
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
errCh := make(chan error, 2)
|
||||
start := make(chan struct{})
|
||||
for range 2 {
|
||||
go func() {
|
||||
<-start
|
||||
errCh <- svc.StartProxy(context.Background())
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
|
||||
for range 2 {
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("StartProxy() error = nil, want bind failure")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "bind") {
|
||||
t.Fatalf("StartProxy() error = %v, want bind failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
status, statusErr := svc.GetProxyStatus(context.Background())
|
||||
if statusErr != nil {
|
||||
t.Fatalf("GetProxyStatus() error = %v", statusErr)
|
||||
}
|
||||
if status.Running {
|
||||
t.Fatalf("status = %#v, want stopped", status)
|
||||
}
|
||||
if status.LastError == "" {
|
||||
t.Fatalf("status = %#v, want last error", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProxyStatusUsesCurrentConfigAddressWhenStopped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
firstPort := freePort(t)
|
||||
secondPort := freePort(t)
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
cfg.Server.Port = firstPort
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
if err := svc.StartProxy(context.Background()); err != nil {
|
||||
t.Fatalf("StartProxy() error = %v", err)
|
||||
}
|
||||
if err := svc.StopProxy(context.Background()); err != nil {
|
||||
t.Fatalf("StopProxy() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err = config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() reload error = %v", err)
|
||||
}
|
||||
cfg.Server.Port = secondPort
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() update error = %v", err)
|
||||
}
|
||||
|
||||
status, err := svc.GetProxyStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetProxyStatus() error = %v", err)
|
||||
}
|
||||
if status.Running {
|
||||
t.Fatalf("status.Running = true, want false")
|
||||
}
|
||||
want := "127.0.0.1:" + itoa(secondPort)
|
||||
if status.BindAddress != want {
|
||||
t.Fatalf("status.BindAddress = %q, want %q", status.BindAddress, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProviderReturnsWarningsAndKeepsCatalog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.UpsertProvider(config.Provider{
|
||||
ID: "relay",
|
||||
BaseURL: "https://old.example.com/v1",
|
||||
APIKey: "sk-old",
|
||||
Models: []string{"gpt-4.1"},
|
||||
ModelsSource: "discovered",
|
||||
})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/models")
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
svc := NewService(path)
|
||||
result, err := svc.UpsertProvider(context.Background(), ProviderUpsertInput{
|
||||
ID: "relay",
|
||||
BaseURL: upstream.URL + "/v1",
|
||||
APIKey: "sk-new",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertProvider() error = %v", err)
|
||||
}
|
||||
if result.Provider.BaseURL != upstream.URL+"/v1" {
|
||||
t.Fatalf("saved baseUrl = %q, want %q", result.Provider.BaseURL, upstream.URL+"/v1")
|
||||
}
|
||||
if !containsWarning(result.Warnings, "model discovery failed") {
|
||||
t.Fatalf("warnings %#v do not mention stale catalog preservation", result.Warnings)
|
||||
}
|
||||
if !containsWarning(result.Warnings, "could not discover provider models") {
|
||||
t.Fatalf("warnings %#v do not mention discovery failure", result.Warnings)
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
provider := reloaded.FindProvider("relay")
|
||||
if provider == nil {
|
||||
t.Fatal("provider relay not found after save")
|
||||
}
|
||||
if provider.ModelsSource != "" {
|
||||
t.Fatalf("provider.ModelsSource = %q, want empty", provider.ModelsSource)
|
||||
}
|
||||
if len(provider.Models) != 1 || provider.Models[0] != "gpt-4.1" {
|
||||
t.Fatalf("provider.Models = %#v, want existing catalog kept", provider.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportProvidersReturnsWarnings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ocswitch.json")
|
||||
sourcePath := filepath.Join(dir, "opencode.json")
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.UpsertProvider(config.Provider{ID: "keep", BaseURL: "https://existing.example.com/v1"})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(sourcePath, []byte(`{
|
||||
"provider": {
|
||||
"keep": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {"baseURL": "https://duplicate.example.com/v1", "apiKey": "sk-dup"}
|
||||
},
|
||||
"broken": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {"baseURL": "https://broken.example.com", "apiKey": "sk-bad"}
|
||||
},
|
||||
"fresh": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "Fresh",
|
||||
"options": {"baseURL": "https://fresh.example.com/v1", "apiKey": "sk-fresh"},
|
||||
"models": {"gpt-4.1": {}}
|
||||
}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
result, err := svc.ImportProviders(context.Background(), ProviderImportInput{SourcePath: sourcePath})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportProviders() error = %v", err)
|
||||
}
|
||||
if result.Imported != 1 || result.Skipped != 2 {
|
||||
t.Fatalf("ImportProviders() = %#v, want imported=1 skipped=2", result)
|
||||
}
|
||||
if !containsWarning(result.Warnings, `skip "keep"`) {
|
||||
t.Fatalf("warnings %#v do not mention duplicate provider", result.Warnings)
|
||||
}
|
||||
if !containsWarning(result.Warnings, `skip "broken"`) {
|
||||
t.Fatalf("warnings %#v do not mention invalid provider", result.Warnings)
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if provider := reloaded.FindProvider("fresh"); provider == nil {
|
||||
t.Fatal("provider fresh not imported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAliasTargetDisabledPersistsState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.UpsertProvider(config.Provider{ID: "relay", BaseURL: "https://relay.example.com/v1"})
|
||||
cfg.UpsertAlias(config.Alias{
|
||||
Alias: "chat",
|
||||
Enabled: true,
|
||||
Targets: []config.Target{{Provider: "relay", Model: "gpt-4.1", Enabled: true}},
|
||||
})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
disabled, err := svc.SetAliasTargetDisabled(context.Background(), AliasTargetInput{
|
||||
Alias: "chat",
|
||||
Provider: "relay",
|
||||
Model: "gpt-4.1",
|
||||
Disabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetAliasTargetDisabled(disable) error = %v", err)
|
||||
}
|
||||
if disabled.AvailableTargetCount != 0 || disabled.Targets[0].Enabled {
|
||||
t.Fatalf("disabled alias view = %#v", disabled)
|
||||
}
|
||||
|
||||
enabled, err := svc.SetAliasTargetDisabled(context.Background(), AliasTargetInput{
|
||||
Alias: "chat",
|
||||
Provider: "relay",
|
||||
Model: "gpt-4.1",
|
||||
Disabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetAliasTargetDisabled(enable) error = %v", err)
|
||||
}
|
||||
if enabled.AvailableTargetCount != 1 || !enabled.Targets[0].Enabled {
|
||||
t.Fatalf("enabled alias view = %#v", enabled)
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
alias := reloaded.FindAlias("chat")
|
||||
if alias == nil {
|
||||
t.Fatal("alias chat not found after update")
|
||||
}
|
||||
if len(alias.Targets) != 1 || !alias.Targets[0].Enabled {
|
||||
t.Fatalf("persisted alias targets = %#v", alias.Targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertAliasCanReEnableExistingAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.UpsertAlias(config.Alias{Alias: "chat", DisplayName: "Chat", Enabled: false})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
alias, err := svc.UpsertAlias(context.Background(), AliasUpsertInput{
|
||||
Alias: "chat",
|
||||
DisplayName: "Chat enabled",
|
||||
Disabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertAlias() error = %v", err)
|
||||
}
|
||||
if !alias.Enabled {
|
||||
t.Fatalf("alias.Enabled = false, want true: %#v", alias)
|
||||
}
|
||||
if alias.DisplayName != "Chat enabled" {
|
||||
t.Fatalf("alias.DisplayName = %q, want %q", alias.DisplayName, "Chat enabled")
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
persisted := reloaded.FindAlias("chat")
|
||||
if persisted == nil || !persisted.Enabled {
|
||||
t.Fatalf("persisted alias = %#v, want enabled", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func containsWarning(warnings []string, want string) bool {
|
||||
for _, warning := range warnings {
|
||||
if strings.Contains(warning, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertEventually(t *testing.T, fn func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package app
|
||||
|
||||
import "time"
|
||||
import "github.com/Apale7/opencode-provider-switch/internal/proxy"
|
||||
|
||||
type Overview struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
@ -12,10 +12,123 @@ type Overview struct {
|
||||
}
|
||||
|
||||
type ProxyStatusView struct {
|
||||
Running bool `json:"running"`
|
||||
BindAddress string `json:"bindAddress"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
BindAddress string `json:"bindAddress"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type ProxySettingsView struct {
|
||||
ConnectTimeoutMs int `json:"connectTimeoutMs"`
|
||||
ResponseHeaderTimeoutMs int `json:"responseHeaderTimeoutMs"`
|
||||
FirstByteTimeoutMs int `json:"firstByteTimeoutMs"`
|
||||
RequestReadTimeoutMs int `json:"requestReadTimeoutMs"`
|
||||
StreamIdleTimeoutMs int `json:"streamIdleTimeoutMs"`
|
||||
}
|
||||
|
||||
type ProxySettingsInput struct {
|
||||
ConnectTimeoutMs int `json:"connectTimeoutMs"`
|
||||
ResponseHeaderTimeoutMs int `json:"responseHeaderTimeoutMs"`
|
||||
FirstByteTimeoutMs int `json:"firstByteTimeoutMs"`
|
||||
RequestReadTimeoutMs int `json:"requestReadTimeoutMs"`
|
||||
StreamIdleTimeoutMs int `json:"streamIdleTimeoutMs"`
|
||||
}
|
||||
|
||||
type ProxySettingsSaveResult struct {
|
||||
Settings ProxySettingsView `json:"settings"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type RequestTrace struct {
|
||||
ID uint64 `json:"id"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
FirstByteMs int64 `json:"firstByteMs,omitempty"`
|
||||
RawModel string `json:"rawModel,omitempty"`
|
||||
Alias string `json:"alias,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
Success bool `json:"success"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
FinalProvider string `json:"finalProvider,omitempty"`
|
||||
FinalModel string `json:"finalModel,omitempty"`
|
||||
FinalURL string `json:"finalUrl,omitempty"`
|
||||
Failover bool `json:"failover"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
|
||||
RequestParams any `json:"requestParams,omitempty"`
|
||||
Attempts []TraceAttempt `json:"attempts"`
|
||||
}
|
||||
|
||||
type TraceAttempt struct {
|
||||
Attempt int `json:"attempt"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
FirstByteMs int64 `json:"firstByteMs,omitempty"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
|
||||
RequestParams any `json:"requestParams,omitempty"`
|
||||
ResponseHeaders map[string]string `json:"responseHeaders,omitempty"`
|
||||
ResponseBody string `json:"responseBody,omitempty"`
|
||||
}
|
||||
|
||||
func requestTraceView(trace proxy.RequestTrace) RequestTrace {
|
||||
attempts := make([]TraceAttempt, 0, len(trace.Attempts))
|
||||
for _, attempt := range trace.Attempts {
|
||||
attempts = append(attempts, traceAttemptView(attempt))
|
||||
}
|
||||
return RequestTrace{
|
||||
ID: trace.ID,
|
||||
StartedAt: formatTimestamp(trace.StartedAt),
|
||||
FinishedAt: formatTimestamp(trace.FinishedAt),
|
||||
DurationMs: trace.DurationMs,
|
||||
FirstByteMs: trace.FirstByteMs,
|
||||
RawModel: trace.RawModel,
|
||||
Alias: trace.Alias,
|
||||
Stream: trace.Stream,
|
||||
Success: trace.Success,
|
||||
StatusCode: trace.StatusCode,
|
||||
Error: trace.Error,
|
||||
FinalProvider: trace.FinalProvider,
|
||||
FinalModel: trace.FinalModel,
|
||||
FinalURL: trace.FinalURL,
|
||||
Failover: trace.Failover,
|
||||
AttemptCount: trace.AttemptCount,
|
||||
RequestHeaders: trace.RequestHeaders,
|
||||
RequestParams: trace.RequestParams,
|
||||
Attempts: attempts,
|
||||
}
|
||||
}
|
||||
|
||||
func traceAttemptView(attempt proxy.TraceAttempt) TraceAttempt {
|
||||
return TraceAttempt{
|
||||
Attempt: attempt.Attempt,
|
||||
Provider: attempt.Provider,
|
||||
Model: attempt.Model,
|
||||
URL: attempt.URL,
|
||||
StartedAt: formatTimestamp(attempt.StartedAt),
|
||||
DurationMs: attempt.DurationMs,
|
||||
FirstByteMs: attempt.FirstByteMs,
|
||||
StatusCode: attempt.StatusCode,
|
||||
Success: attempt.Success,
|
||||
Retryable: attempt.Retryable,
|
||||
Skipped: attempt.Skipped,
|
||||
Result: attempt.Result,
|
||||
Error: attempt.Error,
|
||||
RequestHeaders: attempt.RequestHeaders,
|
||||
RequestParams: attempt.RequestParams,
|
||||
ResponseHeaders: attempt.ResponseHeaders,
|
||||
ResponseBody: attempt.ResponseBody,
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderView struct {
|
||||
@ -30,6 +143,11 @@ type ProviderView struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type ProviderSaveResult struct {
|
||||
Provider ProviderView `json:"provider"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type AliasTargetView struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
@ -90,13 +208,79 @@ type SyncResult struct {
|
||||
}
|
||||
|
||||
type DesktopPrefsView struct {
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
MinimizeToTray bool `json:"minimizeToTray"`
|
||||
Notifications bool `json:"notifications"`
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
AutoStartProxy bool `json:"autoStartProxy"`
|
||||
MinimizeToTray bool `json:"minimizeToTray"`
|
||||
Notifications bool `json:"notifications"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type DesktopPrefsSaveResult struct {
|
||||
Prefs DesktopPrefsView `json:"prefs"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type DesktopPrefsInput struct {
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
MinimizeToTray bool `json:"minimizeToTray"`
|
||||
Notifications bool `json:"notifications"`
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
AutoStartProxy bool `json:"autoStartProxy"`
|
||||
MinimizeToTray bool `json:"minimizeToTray"`
|
||||
Notifications bool `json:"notifications"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type ConfigExportView struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ConfigImportInput struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ConfigImportResult struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderUpsertInput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
SkipModels bool `json:"skipModels"`
|
||||
ClearHeaders bool `json:"clearHeaders"`
|
||||
}
|
||||
|
||||
type ProviderImportInput struct {
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type ProviderImportResult struct {
|
||||
SourcePath string `json:"sourcePath"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderStateInput struct {
|
||||
ID string `json:"id"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type AliasUpsertInput struct {
|
||||
Alias string `json:"alias"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type AliasTargetInput struct {
|
||||
Alias string `json:"alias"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
@ -51,16 +51,32 @@ type Provider struct {
|
||||
|
||||
// Server holds proxy listen settings.
|
||||
type Server struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
APIKey string `json:"api_key"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
APIKey string `json:"api_key"`
|
||||
ConnectTimeoutMs int `json:"connect_timeout_ms,omitempty"`
|
||||
ResponseHeaderTimeoutMs int `json:"response_header_timeout_ms,omitempty"`
|
||||
FirstByteTimeoutMs int `json:"first_byte_timeout_ms,omitempty"`
|
||||
RequestReadTimeoutMs int `json:"request_read_timeout_ms,omitempty"`
|
||||
StreamIdleTimeoutMs int `json:"stream_idle_timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultConnectTimeoutMs = 10_000
|
||||
DefaultResponseHeaderTimeoutMs = 15_000
|
||||
DefaultFirstByteTimeoutMs = 15_000
|
||||
DefaultRequestReadTimeoutMs = 30_000
|
||||
DefaultStreamIdleTimeoutMs = 60_000
|
||||
)
|
||||
|
||||
// Desktop holds desktop-shell user preferences.
|
||||
type Desktop struct {
|
||||
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
|
||||
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
|
||||
Notifications bool `json:"notifications,omitempty"`
|
||||
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
|
||||
AutoStartProxy bool `json:"auto_start_proxy,omitempty"`
|
||||
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
|
||||
Notifications bool `json:"notifications,omitempty"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// Config is the on-disk ocswitch config.
|
||||
@ -102,9 +118,14 @@ func ValidateProviderBaseURL(baseURL string) error {
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Server: Server{
|
||||
Host: "127.0.0.1",
|
||||
Port: 9982,
|
||||
APIKey: DefaultLocalAPIKey,
|
||||
Host: "127.0.0.1",
|
||||
Port: 9982,
|
||||
APIKey: DefaultLocalAPIKey,
|
||||
ConnectTimeoutMs: DefaultConnectTimeoutMs,
|
||||
ResponseHeaderTimeoutMs: DefaultResponseHeaderTimeoutMs,
|
||||
FirstByteTimeoutMs: DefaultFirstByteTimeoutMs,
|
||||
RequestReadTimeoutMs: DefaultRequestReadTimeoutMs,
|
||||
StreamIdleTimeoutMs: DefaultStreamIdleTimeoutMs,
|
||||
},
|
||||
Desktop: Desktop{},
|
||||
Providers: []Provider{},
|
||||
@ -156,6 +177,7 @@ func Load(path string) (*Config, error) {
|
||||
if c.Server.APIKey == "" {
|
||||
c.Server.APIKey = DefaultLocalAPIKey
|
||||
}
|
||||
normalizeServerTimeouts(&c.Server)
|
||||
c.path = path
|
||||
return c, nil
|
||||
}
|
||||
@ -446,8 +468,41 @@ func (c *Config) Validate() []error {
|
||||
if c.Server.APIKey == DefaultLocalAPIKey && !isLoopbackHost(c.Server.Host) {
|
||||
errs = append(errs, fmt.Errorf("server.api_key must not use the default value when listening on non-loopback host %q", c.Server.Host))
|
||||
}
|
||||
if c.Server.ConnectTimeoutMs <= 0 {
|
||||
errs = append(errs, fmt.Errorf("server.connect_timeout_ms must be greater than 0"))
|
||||
}
|
||||
if c.Server.ResponseHeaderTimeoutMs <= 0 {
|
||||
errs = append(errs, fmt.Errorf("server.response_header_timeout_ms must be greater than 0"))
|
||||
}
|
||||
if c.Server.FirstByteTimeoutMs <= 0 {
|
||||
errs = append(errs, fmt.Errorf("server.first_byte_timeout_ms must be greater than 0"))
|
||||
}
|
||||
if c.Server.RequestReadTimeoutMs <= 0 {
|
||||
errs = append(errs, fmt.Errorf("server.request_read_timeout_ms must be greater than 0"))
|
||||
}
|
||||
if c.Server.StreamIdleTimeoutMs <= 0 {
|
||||
errs = append(errs, fmt.Errorf("server.stream_idle_timeout_ms must be greater than 0"))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func normalizeServerTimeouts(server *Server) {
|
||||
if server == nil {
|
||||
return
|
||||
}
|
||||
server.ConnectTimeoutMs = normalizeServerTimeoutMs(server.ConnectTimeoutMs, DefaultConnectTimeoutMs)
|
||||
server.ResponseHeaderTimeoutMs = normalizeServerTimeoutMs(server.ResponseHeaderTimeoutMs, DefaultResponseHeaderTimeoutMs)
|
||||
server.FirstByteTimeoutMs = normalizeServerTimeoutMs(server.FirstByteTimeoutMs, DefaultFirstByteTimeoutMs)
|
||||
server.RequestReadTimeoutMs = normalizeServerTimeoutMs(server.RequestReadTimeoutMs, DefaultRequestReadTimeoutMs)
|
||||
server.StreamIdleTimeoutMs = normalizeServerTimeoutMs(server.StreamIdleTimeoutMs, DefaultStreamIdleTimeoutMs)
|
||||
}
|
||||
|
||||
func normalizeServerTimeoutMs(value int, fallback int) int {
|
||||
if value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
func isLoopbackHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || strings.EqualFold(host, "localhost") {
|
||||
|
||||
@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@ -219,7 +218,7 @@ func TestSaveLinearizesConcurrentWriters(t *testing.T) {
|
||||
t.Fatalf("OpenFile(lock): %v", err)
|
||||
}
|
||||
defer lockFile.Close()
|
||||
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil {
|
||||
if err := lockTestFile(lockFile); err != nil {
|
||||
t.Fatalf("Flock(lock): %v", err)
|
||||
}
|
||||
|
||||
@ -255,7 +254,7 @@ func TestSaveLinearizesConcurrentWriters(t *testing.T) {
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil {
|
||||
if err := unlockTestFile(lockFile); err != nil {
|
||||
t.Fatalf("Flock(unlock): %v", err)
|
||||
}
|
||||
|
||||
|
||||
23
internal/config/file_lock_testhelper_unix_test.go
Normal file
23
internal/config/file_lock_testhelper_unix_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func lockTestFile(file *os.File) error {
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||
return fmt.Errorf("flock lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unlockTestFile(file *os.File) error {
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil {
|
||||
return fmt.Errorf("flock unlock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
26
internal/config/file_lock_testhelper_windows_test.go
Normal file
26
internal/config/file_lock_testhelper_windows_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func lockTestFile(file *os.File) error {
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &overlapped); err != nil {
|
||||
return fmt.Errorf("lock file ex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unlockTestFile(file *os.File) error {
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped); err != nil {
|
||||
return fmt.Errorf("unlock file ex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -2,19 +2,43 @@ package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
)
|
||||
|
||||
type trayAdapter interface {
|
||||
Attach(context.Context)
|
||||
Detach()
|
||||
Sync(context.Context, app.DesktopPrefsView)
|
||||
RefreshProxyStatus(context.Context)
|
||||
BeforeClose(context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type notifierAdapter interface {
|
||||
Attach(context.Context)
|
||||
Detach()
|
||||
Sync(context.Context, app.DesktopPrefsView)
|
||||
Send(context.Context, string, string) error
|
||||
}
|
||||
|
||||
type autoStartAdapter interface {
|
||||
Attach(context.Context)
|
||||
Detach()
|
||||
Sync(context.Context, app.DesktopPrefsView) error
|
||||
}
|
||||
|
||||
// App is the desktop-shell composition root. Native shell integrations are kept
|
||||
// out of internal/app so CLI and GUI can share the same workflows.
|
||||
type App struct {
|
||||
service *app.Service
|
||||
bindings *Bindings
|
||||
tray *Tray
|
||||
notify *Notifier
|
||||
auto *AutoStart
|
||||
tray trayAdapter
|
||||
notify notifierAdapter
|
||||
auto autoStartAdapter
|
||||
|
||||
ctx context.Context
|
||||
version string
|
||||
@ -40,6 +64,25 @@ func (a *App) Startup(ctx context.Context) {
|
||||
a.notify.Attach(ctx)
|
||||
a.auto.Attach(ctx)
|
||||
_ = a.SyncDesktopPreferences(ctx)
|
||||
if prefs, err := a.bindings.GetDesktopPrefs(ctx); err == nil && prefs.AutoStartProxy {
|
||||
_, _ = a.bindings.StartProxy(ctx)
|
||||
a.watchAutoStartProxyFailure()
|
||||
}
|
||||
a.tray.RefreshProxyStatus(ctx)
|
||||
}
|
||||
|
||||
func (a *App) watchAutoStartProxyFailure() {
|
||||
go func() {
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
err := a.service.WaitProxy(waitCtx)
|
||||
if err == nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
callCtx := a.callContext()
|
||||
a.tray.RefreshProxyStatus(callCtx)
|
||||
_ = a.notify.Send(callCtx, "Proxy failed to start", err.Error())
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) BeforeClose(ctx context.Context) bool {
|
||||
@ -63,26 +106,31 @@ func (a *App) SyncDesktopPreferences(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.auto.Sync(ctx, prefs); err != nil {
|
||||
return err
|
||||
}
|
||||
autoErr := a.auto.Sync(ctx, prefs)
|
||||
a.tray.Sync(ctx, prefs)
|
||||
return nil
|
||||
a.notify.Sync(ctx, prefs)
|
||||
return autoErr
|
||||
}
|
||||
|
||||
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
|
||||
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
|
||||
previous, _ := a.bindings.GetDesktopPrefs(ctx)
|
||||
prefs, err := a.bindings.SaveDesktopPrefs(ctx, in)
|
||||
if err != nil {
|
||||
return app.DesktopPrefsView{}, err
|
||||
return app.DesktopPrefsSaveResult{}, err
|
||||
}
|
||||
warnings := []string{}
|
||||
if err := a.auto.Sync(ctx, prefs); err != nil {
|
||||
return app.DesktopPrefsView{}, err
|
||||
warnings = append(warnings, fmt.Sprintf("saved preferences but could not update launch-at-login integration: %v", err))
|
||||
}
|
||||
a.tray.Sync(ctx, prefs)
|
||||
return prefs, nil
|
||||
a.notify.Sync(ctx, prefs)
|
||||
if prefs.Notifications && !previous.Notifications {
|
||||
_ = a.notify.Send(ctx, "Notifications enabled", "ocswitch desktop will now show native alerts.")
|
||||
}
|
||||
return app.DesktopPrefsSaveResult{Prefs: prefs, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func (a *App) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
|
||||
func (a *App) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
|
||||
return a.SaveDesktopPrefs(a.callContext(), in)
|
||||
}
|
||||
|
||||
@ -93,10 +141,34 @@ func (a *App) Meta() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) OpenExternalURL(rawURL string) error {
|
||||
url := strings.TrimSpace(rawURL)
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
return openExternalURL(a.callContext(), url)
|
||||
}
|
||||
|
||||
func (a *App) Overview() (app.Overview, error) {
|
||||
return a.bindings.GetOverview(a.callContext())
|
||||
}
|
||||
|
||||
func (a *App) ExportConfig() (app.ConfigExportView, error) {
|
||||
return a.bindings.ExportConfig(a.callContext())
|
||||
}
|
||||
|
||||
func (a *App) ImportConfig(in app.ConfigImportInput) (app.ConfigImportResult, error) {
|
||||
result, err := a.bindings.ImportConfig(a.callContext(), in)
|
||||
if err != nil {
|
||||
return app.ConfigImportResult{}, err
|
||||
}
|
||||
if syncErr := a.SyncDesktopPreferences(a.callContext()); syncErr != nil {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("imported config but could not sync desktop integrations: %v", syncErr))
|
||||
}
|
||||
a.tray.RefreshProxyStatus(a.callContext())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) Providers() ([]app.ProviderView, error) {
|
||||
return a.bindings.ListProviders(a.callContext())
|
||||
}
|
||||
@ -105,6 +177,42 @@ func (a *App) Aliases() ([]app.AliasView, error) {
|
||||
return a.bindings.ListAliases(a.callContext())
|
||||
}
|
||||
|
||||
func (a *App) SaveProvider(in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
|
||||
return a.bindings.UpsertProvider(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) SetProviderState(in app.ProviderStateInput) (app.ProviderView, error) {
|
||||
return a.bindings.SetProviderDisabled(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) DeleteProvider(id string) error {
|
||||
return a.bindings.RemoveProvider(a.callContext(), id)
|
||||
}
|
||||
|
||||
func (a *App) ImportProviders(in app.ProviderImportInput) (app.ProviderImportResult, error) {
|
||||
return a.bindings.ImportProviders(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) SaveAlias(in app.AliasUpsertInput) (app.AliasView, error) {
|
||||
return a.bindings.UpsertAlias(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) DeleteAlias(name string) error {
|
||||
return a.bindings.RemoveAlias(a.callContext(), name)
|
||||
}
|
||||
|
||||
func (a *App) BindTarget(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return a.bindings.BindAliasTarget(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) SetTargetState(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return a.bindings.SetAliasTargetDisabled(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) UnbindTarget(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return a.bindings.UnbindAliasTarget(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) DoctorRun() (app.DoctorRunResult, error) {
|
||||
report, err := a.bindings.RunDoctor(a.callContext())
|
||||
return app.DoctorRunResult{Report: report, Error: errorString(err)}, nil
|
||||
@ -114,14 +222,38 @@ func (a *App) ProxyStatus() (app.ProxyStatusView, error) {
|
||||
return a.bindings.GetProxyStatus(a.callContext())
|
||||
}
|
||||
|
||||
func (a *App) RequestTraces(limit int) ([]app.RequestTrace, error) {
|
||||
return a.bindings.ListRequestTraces(a.callContext(), limit)
|
||||
}
|
||||
|
||||
func (a *App) ProxySettings() (app.ProxySettingsView, error) {
|
||||
return a.bindings.GetProxySettings(a.callContext())
|
||||
}
|
||||
|
||||
func (a *App) SaveProxySettings(in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
|
||||
return a.bindings.SaveProxySettings(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) StartProxy() (app.ProxyStatusView, error) {
|
||||
return a.bindings.StartProxy(a.callContext())
|
||||
status, err := a.bindings.StartProxy(a.callContext())
|
||||
if err != nil {
|
||||
return app.ProxyStatusView{}, err
|
||||
}
|
||||
a.tray.RefreshProxyStatus(a.callContext())
|
||||
_ = a.notify.Send(a.callContext(), "Proxy started", status.BindAddress)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (a *App) StopProxy() (app.ProxyStatusView, error) {
|
||||
ctx, cancel := context.WithTimeout(a.callContext(), 5*time.Second)
|
||||
defer cancel()
|
||||
return a.bindings.StopProxy(ctx)
|
||||
status, err := a.bindings.StopProxy(ctx)
|
||||
if err != nil {
|
||||
return app.ProxyStatusView{}, err
|
||||
}
|
||||
a.tray.RefreshProxyStatus(a.callContext())
|
||||
_ = a.notify.Send(a.callContext(), "Proxy stopped", status.BindAddress)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (a *App) DesktopPrefs() (app.DesktopPrefsView, error) {
|
||||
@ -133,7 +265,14 @@ func (a *App) PreviewSync(in app.SyncInput) (app.SyncPreview, error) {
|
||||
}
|
||||
|
||||
func (a *App) ApplySync(in app.SyncInput) (app.SyncResult, error) {
|
||||
return a.bindings.SyncOpenCode(a.callContext(), in)
|
||||
result, err := a.bindings.SyncOpenCode(a.callContext(), in)
|
||||
if err != nil {
|
||||
return app.SyncResult{}, err
|
||||
}
|
||||
if result.Changed && !result.DryRun {
|
||||
_ = a.notify.Send(a.callContext(), "OpenCode sync applied", result.TargetPath)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) callContext() context.Context {
|
||||
@ -157,11 +296,3 @@ func (a *App) Service() *app.Service {
|
||||
func (a *App) Bindings() *Bindings {
|
||||
return a.bindings
|
||||
}
|
||||
|
||||
func (a *App) Tray() *Tray {
|
||||
return a.tray
|
||||
}
|
||||
|
||||
func (a *App) AutoStart() *AutoStart {
|
||||
return a.auto
|
||||
}
|
||||
|
||||
BIN
internal/desktop/assets/icon.ico
Normal file
BIN
internal/desktop/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// AutoStart manages real launch-at-login integration where the platform permits
|
||||
// it. This implementation currently targets Linux XDG autostart.
|
||||
// it. Linux uses XDG autostart; Windows uses Startup folder scripts.
|
||||
type AutoStart struct {
|
||||
service *app.Service
|
||||
ctx context.Context
|
||||
@ -33,15 +33,15 @@ func (a *AutoStart) Detach() {
|
||||
|
||||
func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error {
|
||||
_ = ctx
|
||||
if runtime.GOOS != "linux" {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
|
||||
return nil
|
||||
}
|
||||
entryPath, err := a.entryPath()
|
||||
entryPath, err := a.entryPathForOS(runtime.GOOS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prefs.LaunchAtLogin {
|
||||
return a.writeLinuxEntry(entryPath)
|
||||
return a.writeEntry(entryPath, runtime.GOOS)
|
||||
}
|
||||
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove autostart entry: %w", err)
|
||||
@ -50,6 +50,21 @@ func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error
|
||||
}
|
||||
|
||||
func (a *AutoStart) entryPath() (string, error) {
|
||||
return a.entryPathForOS(runtime.GOOS)
|
||||
}
|
||||
|
||||
func (a *AutoStart) entryPathForOS(goos string) (string, error) {
|
||||
switch goos {
|
||||
case "linux":
|
||||
return a.linuxEntryPath()
|
||||
case "windows":
|
||||
return a.windowsEntryPath()
|
||||
default:
|
||||
return "", fmt.Errorf("autostart unsupported on %s", goos)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AutoStart) linuxEntryPath() (string, error) {
|
||||
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
|
||||
if base == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
@ -61,7 +76,19 @@ func (a *AutoStart) entryPath() (string, error) {
|
||||
return filepath.Join(base, "autostart", "ocswitch-desktop.desktop"), nil
|
||||
}
|
||||
|
||||
func (a *AutoStart) writeLinuxEntry(path string) error {
|
||||
func (a *AutoStart) windowsEntryPath() (string, error) {
|
||||
base := strings.TrimSpace(os.Getenv("APPDATA"))
|
||||
if base == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home for autostart: %w", err)
|
||||
}
|
||||
base = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
return filepath.Join(base, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "ocswitch-desktop.cmd"), nil
|
||||
}
|
||||
|
||||
func (a *AutoStart) writeEntry(path string, goos string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir autostart dir: %w", err)
|
||||
}
|
||||
@ -73,6 +100,22 @@ func (a *AutoStart) writeLinuxEntry(path string) error {
|
||||
if a.service != nil {
|
||||
configPath = a.service.ConfigPath()
|
||||
}
|
||||
var content string
|
||||
switch goos {
|
||||
case "linux":
|
||||
content = linuxAutostartEntry(execPath, configPath)
|
||||
case "windows":
|
||||
content = windowsAutostartScript(execPath, configPath)
|
||||
default:
|
||||
return fmt.Errorf("autostart unsupported on %s", goos)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("write autostart entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func linuxAutostartEntry(execPath string, configPath string) string {
|
||||
content := strings.Join([]string{
|
||||
"[Desktop Entry]",
|
||||
"Type=Application",
|
||||
@ -86,13 +129,23 @@ func (a *AutoStart) writeLinuxEntry(path string) error {
|
||||
"StartupNotify=false",
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("write autostart entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
return content
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
replacer := strings.NewReplacer("\\", "\\\\", `"`, `\\"`)
|
||||
return `"` + replacer.Replace(value) + `"`
|
||||
}
|
||||
|
||||
func windowsAutostartScript(execPath string, configPath string) string {
|
||||
return strings.Join([]string{
|
||||
"@echo off",
|
||||
fmt.Sprintf("start \"\" %s --config %s", cmdQuote(execPath), cmdQuote(configPath)),
|
||||
"",
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
func cmdQuote(value string) string {
|
||||
replacer := strings.NewReplacer(`"`, `""`)
|
||||
return `"` + replacer.Replace(value) + `"`
|
||||
}
|
||||
|
||||
@ -57,3 +57,26 @@ func TestShellQuoteEscapesDoubleQuotes(t *testing.T) {
|
||||
t.Fatalf("shellQuote() did not escape quotes: %q", quoted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryPathForWindowsUsesStartupFolder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("APPDATA", root)
|
||||
|
||||
auto := NewAutoStart(nil)
|
||||
path, err := auto.entryPathForOS("windows")
|
||||
if err != nil {
|
||||
t.Fatalf("entryPathForOS(windows) error = %v", err)
|
||||
}
|
||||
want := filepath.Join(root, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "ocswitch-desktop.cmd")
|
||||
if path != want {
|
||||
t.Fatalf("entryPathForOS(windows) = %q, want %q", path, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsAutostartScriptQuotesArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
script := windowsAutostartScript(`C:\Program Files\ocswitch\ocswitch-desktop.exe`, `C:\Users\demo\.config\ocswitch\config.json`)
|
||||
if !strings.Contains(script, `start "" "C:\Program Files\ocswitch\ocswitch-desktop.exe" --config "C:\Users\demo\.config\ocswitch\config.json"`) {
|
||||
t.Fatalf("windowsAutostartScript() missing command: %q", script)
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,14 @@ func (b *Bindings) GetOverview(ctx context.Context) (app.Overview, error) {
|
||||
return b.service.GetOverview(ctx)
|
||||
}
|
||||
|
||||
func (b *Bindings) ExportConfig(ctx context.Context) (app.ConfigExportView, error) {
|
||||
return b.service.ExportConfig(ctx)
|
||||
}
|
||||
|
||||
func (b *Bindings) ImportConfig(ctx context.Context, in app.ConfigImportInput) (app.ConfigImportResult, error) {
|
||||
return b.service.ImportConfig(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) {
|
||||
return b.service.ListProviders(ctx)
|
||||
}
|
||||
@ -29,6 +37,42 @@ func (b *Bindings) ListAliases(ctx context.Context) ([]app.AliasView, error) {
|
||||
return b.service.ListAliases(ctx)
|
||||
}
|
||||
|
||||
func (b *Bindings) UpsertProvider(ctx context.Context, in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
|
||||
return b.service.UpsertProvider(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SetProviderDisabled(ctx context.Context, in app.ProviderStateInput) (app.ProviderView, error) {
|
||||
return b.service.SetProviderDisabled(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) RemoveProvider(ctx context.Context, id string) error {
|
||||
return b.service.RemoveProvider(ctx, id)
|
||||
}
|
||||
|
||||
func (b *Bindings) ImportProviders(ctx context.Context, in app.ProviderImportInput) (app.ProviderImportResult, error) {
|
||||
return b.service.ImportProviders(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) UpsertAlias(ctx context.Context, in app.AliasUpsertInput) (app.AliasView, error) {
|
||||
return b.service.UpsertAlias(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) RemoveAlias(ctx context.Context, name string) error {
|
||||
return b.service.RemoveAlias(ctx, name)
|
||||
}
|
||||
|
||||
func (b *Bindings) BindAliasTarget(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.service.BindAliasTarget(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SetAliasTargetDisabled(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.service.SetAliasTargetDisabled(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) UnbindAliasTarget(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.service.UnbindAliasTarget(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) RunDoctor(ctx context.Context) (app.DoctorReport, error) {
|
||||
return b.service.RunDoctor(ctx)
|
||||
}
|
||||
@ -45,6 +89,18 @@ func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, err
|
||||
return b.service.GetProxyStatus(ctx)
|
||||
}
|
||||
|
||||
func (b *Bindings) ListRequestTraces(ctx context.Context, limit int) ([]app.RequestTrace, error) {
|
||||
return b.service.ListRequestTraces(ctx, limit)
|
||||
}
|
||||
|
||||
func (b *Bindings) GetProxySettings(ctx context.Context) (app.ProxySettingsView, error) {
|
||||
return b.service.GetProxySettings(ctx)
|
||||
}
|
||||
|
||||
func (b *Bindings) SaveProxySettings(ctx context.Context, in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
|
||||
return b.service.SaveProxySettings(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) {
|
||||
if err := b.service.StartProxy(ctx); err != nil {
|
||||
return app.ProxyStatusView{}, err
|
||||
@ -63,6 +119,14 @@ func (b *Bindings) Overview() (app.Overview, error) {
|
||||
return b.GetOverview(context.Background())
|
||||
}
|
||||
|
||||
func (b *Bindings) ExportConfigNow() (app.ConfigExportView, error) {
|
||||
return b.ExportConfig(context.Background())
|
||||
}
|
||||
|
||||
func (b *Bindings) ImportConfigNow(in app.ConfigImportInput) (app.ConfigImportResult, error) {
|
||||
return b.ImportConfig(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) Providers() ([]app.ProviderView, error) {
|
||||
return b.ListProviders(context.Background())
|
||||
}
|
||||
@ -71,6 +135,42 @@ func (b *Bindings) Aliases() ([]app.AliasView, error) {
|
||||
return b.ListAliases(context.Background())
|
||||
}
|
||||
|
||||
func (b *Bindings) SaveProvider(in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
|
||||
return b.UpsertProvider(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SetProviderState(in app.ProviderStateInput) (app.ProviderView, error) {
|
||||
return b.SetProviderDisabled(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) DeleteProvider(id string) error {
|
||||
return b.RemoveProvider(context.Background(), id)
|
||||
}
|
||||
|
||||
func (b *Bindings) ImportProviderSet(in app.ProviderImportInput) (app.ProviderImportResult, error) {
|
||||
return b.ImportProviders(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SaveAlias(in app.AliasUpsertInput) (app.AliasView, error) {
|
||||
return b.UpsertAlias(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) DeleteAlias(name string) error {
|
||||
return b.RemoveAlias(context.Background(), name)
|
||||
}
|
||||
|
||||
func (b *Bindings) BindTarget(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.BindAliasTarget(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SetTargetState(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.SetAliasTargetDisabled(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) UnbindTarget(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.UnbindAliasTarget(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) Doctor() (app.DoctorReport, error) {
|
||||
return b.RunDoctor(context.Background())
|
||||
}
|
||||
@ -79,6 +179,18 @@ func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) {
|
||||
return b.GetProxyStatus(context.Background())
|
||||
}
|
||||
|
||||
func (b *Bindings) RequestTraces(limit int) ([]app.RequestTrace, error) {
|
||||
return b.ListRequestTraces(context.Background(), limit)
|
||||
}
|
||||
|
||||
func (b *Bindings) ProxySettings() (app.ProxySettingsView, error) {
|
||||
return b.GetProxySettings(context.Background())
|
||||
}
|
||||
|
||||
func (b *Bindings) SaveProxyConfig(in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
|
||||
return b.SaveProxySettings(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) {
|
||||
return b.StartProxy(context.Background())
|
||||
}
|
||||
|
||||
@ -123,21 +123,152 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||
api.HandleFunc("/api/config/export", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
data, err := b.ListProviders(r.Context())
|
||||
data, err := b.ExportConfig(r.Context())
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
api.HandleFunc("/api/config/import", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
data, err := b.ListAliases(r.Context())
|
||||
var in appcore.ConfigImportInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := instance.ImportConfig(in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, err := b.ListProviders(r.Context())
|
||||
writeResult(w, data, err)
|
||||
case http.MethodPost:
|
||||
var in appcore.ProviderUpsertInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.UpsertProvider(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
default:
|
||||
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||
}
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/providers/import", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.ProviderImportInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.ImportProviders(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/providers/state", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.ProviderStateInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.SetProviderDisabled(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/providers/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if !decodeJSONBody(w, r, &payload) {
|
||||
return
|
||||
}
|
||||
writeResult(w, map[string]bool{"ok": true}, b.RemoveProvider(r.Context(), payload.ID))
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, err := b.ListAliases(r.Context())
|
||||
writeResult(w, data, err)
|
||||
case http.MethodPost:
|
||||
var in appcore.AliasUpsertInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.UpsertAlias(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
default:
|
||||
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||
}
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
if !decodeJSONBody(w, r, &payload) {
|
||||
return
|
||||
}
|
||||
writeResult(w, map[string]bool{"ok": true}, b.RemoveAlias(r.Context(), payload.Alias))
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases/bind", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.AliasTargetInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.BindAliasTarget(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases/state", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.AliasTargetInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.SetAliasTargetDisabled(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases/unbind", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.AliasTargetInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.UnbindAliasTarget(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
@ -151,7 +282,7 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.SaveDesktopPrefs(r.Context(), in)
|
||||
data, err := instance.SaveDesktopPrefs(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
default:
|
||||
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||
@ -167,6 +298,32 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/proxy/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, err := b.GetProxySettings(r.Context())
|
||||
writeResult(w, data, err)
|
||||
case http.MethodPost:
|
||||
var in appcore.ProxySettingsInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.SaveProxySettings(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
default:
|
||||
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||
}
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/proxy/traces", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
data, err := b.ListRequestTraces(r.Context(), 100)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
@ -40,7 +46,8 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
h, err := newHandler(New(path), "test", "http://127.0.0.1:9982")
|
||||
instance := New(path)
|
||||
h, err := newHandler(instance, "test", "http://127.0.0.1:9982")
|
||||
if err != nil {
|
||||
t.Fatalf("newHandler() error = %v", err)
|
||||
}
|
||||
@ -71,8 +78,89 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request traces api", func(t *testing.T) {
|
||||
instance.Service().ListRequestTraces(context.Background(), 10)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/proxy/traces", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
var payload struct {
|
||||
Data []app.RequestTrace `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(payload.Data) != 0 {
|
||||
t.Fatalf("traces = %#v, want empty list", payload.Data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proxy settings api", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/proxy/settings", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
var payload struct {
|
||||
Data app.ProxySettingsView `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.ConnectTimeoutMs != config.DefaultConnectTimeoutMs || payload.Data.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs {
|
||||
t.Fatalf("proxy settings = %#v", payload.Data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("save proxy settings", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/proxy/settings", strings.NewReader(`{"connectTimeoutMs":12000,"responseHeaderTimeoutMs":21000,"firstByteTimeoutMs":22000,"requestReadTimeoutMs":33000,"streamIdleTimeoutMs":70000}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
loaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if loaded.Server.ConnectTimeoutMs != 12000 || loaded.Server.ResponseHeaderTimeoutMs != 21000 || loaded.Server.FirstByteTimeoutMs != 22000 || loaded.Server.RequestReadTimeoutMs != 33000 || loaded.Server.StreamIdleTimeoutMs != 70000 {
|
||||
t.Fatalf("persisted server settings = %#v", loaded.Server)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("save proxy settings normalizes non-positive values", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/proxy/settings", strings.NewReader(`{"connectTimeoutMs":0,"responseHeaderTimeoutMs":-1,"firstByteTimeoutMs":0,"requestReadTimeoutMs":-50,"streamIdleTimeoutMs":0}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data app.ProxySettingsSaveResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.Settings.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
|
||||
payload.Data.Settings.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
|
||||
payload.Data.Settings.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
|
||||
payload.Data.Settings.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
|
||||
payload.Data.Settings.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
|
||||
t.Fatalf("proxy settings payload = %#v", payload.Data.Settings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("save desktop prefs", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":true,"notifications":true}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"autoStartProxy":true,"minimizeToTray":true,"notifications":true,"theme":"dark","language":"zh-CN"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
@ -80,6 +168,270 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
loaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.AutoStartProxy || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications || loaded.Desktop.Theme != "dark" || loaded.Desktop.Language != "zh-CN" {
|
||||
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("startup auto starts proxy when enabled", func(t *testing.T) {
|
||||
port := freePort(t)
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
cfg.Server.Port = port
|
||||
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||
cfg.Desktop.AutoStartProxy = true
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
tray := &spyTray{}
|
||||
notify := &spyNotifier{}
|
||||
originalTray := instance.tray
|
||||
originalNotify := instance.notify
|
||||
defer func() {
|
||||
instance.tray = originalTray
|
||||
instance.notify = originalNotify
|
||||
_ = instance.Service().StopProxy(context.Background())
|
||||
}()
|
||||
instance.tray = tray
|
||||
instance.notify = notify
|
||||
|
||||
instance.Startup(context.Background())
|
||||
|
||||
status, err := instance.Service().GetProxyStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetProxyStatus() error = %v", err)
|
||||
}
|
||||
if !status.Running {
|
||||
t.Fatalf("status = %#v, want running", status)
|
||||
}
|
||||
if tray.refreshCalls != 1 {
|
||||
t.Fatalf("tray refreshCalls = %d, want 1", tray.refreshCalls)
|
||||
}
|
||||
if len(notify.sends) != 0 {
|
||||
t.Fatalf("startup notifications = %#v, want none", notify.sends)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("startup auto start surfaces failure without success toast", func(t *testing.T) {
|
||||
port := freePort(t)
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
cfg.Server.Port = port
|
||||
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||
cfg.Desktop.AutoStartProxy = true
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
tray := &spyTray{}
|
||||
notify := &spyNotifier{}
|
||||
originalTray := instance.tray
|
||||
originalNotify := instance.notify
|
||||
defer func() {
|
||||
instance.tray = originalTray
|
||||
instance.notify = originalNotify
|
||||
_ = instance.Service().StopProxy(context.Background())
|
||||
}()
|
||||
instance.tray = tray
|
||||
instance.notify = notify
|
||||
|
||||
instance.Startup(context.Background())
|
||||
|
||||
assertEventually(t, func() bool {
|
||||
return len(notify.sends) == 1
|
||||
})
|
||||
|
||||
status, err := instance.Service().GetProxyStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetProxyStatus() error = %v", err)
|
||||
}
|
||||
if status.Running {
|
||||
t.Fatalf("status = %#v, want stopped after failure", status)
|
||||
}
|
||||
if status.LastError == "" {
|
||||
t.Fatalf("status = %#v, want last error", status)
|
||||
}
|
||||
if len(notify.sends) != 1 {
|
||||
t.Fatalf("notifications = %#v, want 1 failure notice", notify.sends)
|
||||
}
|
||||
if notify.sends[0].Title != "Proxy failed to start" {
|
||||
t.Fatalf("notification = %#v, want failure title", notify.sends[0])
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(notify.sends[0].Body), "bind") {
|
||||
t.Fatalf("notification = %#v, want bind error", notify.sends[0])
|
||||
}
|
||||
if tray.refreshCalls < 2 {
|
||||
t.Fatalf("tray refreshCalls = %d, want refresh after failure", tray.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proxy start api surfaces bind failure", func(t *testing.T) {
|
||||
port := freePort(t)
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
cfg.Server.Port = port
|
||||
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||
cfg.Desktop.AutoStartProxy = false
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/proxy/start", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusBadRequest, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(resp.Body.String()), "bind") {
|
||||
t.Fatalf("body = %q, want bind error", resp.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("save desktop prefs keeps success with integration warning", func(t *testing.T) {
|
||||
originalAuto := instance.auto
|
||||
instance.auto = failingAutoStart{message: "startup folder unavailable"}
|
||||
defer func() {
|
||||
instance.auto = originalAuto
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":false,"notifications":false}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Prefs struct {
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
} `json:"prefs"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if !payload.Data.Prefs.LaunchAtLogin {
|
||||
t.Fatalf("prefs payload = %#v", payload.Data.Prefs)
|
||||
}
|
||||
if len(payload.Data.Warnings) != 1 || !strings.Contains(payload.Data.Warnings[0], "launch-at-login integration") {
|
||||
t.Fatalf("warnings = %#v, want integration warning", payload.Data.Warnings)
|
||||
}
|
||||
|
||||
loaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if !loaded.Desktop.LaunchAtLogin {
|
||||
t.Fatalf("persisted desktop prefs = %#v, want saved despite warning", loaded.Desktop)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("import config syncs desktop integrations and reports warning", func(t *testing.T) {
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.UpsertProvider(config.Provider{
|
||||
ID: "demo",
|
||||
Name: "Demo",
|
||||
BaseURL: "https://example.com/v1",
|
||||
APIKey: "sk-demo-12345678",
|
||||
Models: []string{"gpt-4.1-mini"},
|
||||
})
|
||||
cfg.UpsertAlias(config.Alias{
|
||||
Alias: "chat",
|
||||
DisplayName: "Chat",
|
||||
Enabled: true,
|
||||
Targets: []config.Target{{
|
||||
Provider: "demo",
|
||||
Model: "gpt-4.1-mini",
|
||||
Enabled: true,
|
||||
}},
|
||||
})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
originalAuto := instance.auto
|
||||
originalTray := instance.tray
|
||||
originalNotify := instance.notify
|
||||
auto := failingAutoStart{message: "startup folder unavailable"}
|
||||
tray := &spyTray{}
|
||||
notify := &spyNotifier{}
|
||||
instance.auto = auto
|
||||
instance.tray = tray
|
||||
instance.notify = notify
|
||||
defer func() {
|
||||
instance.auto = originalAuto
|
||||
instance.tray = originalTray
|
||||
instance.notify = originalNotify
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{
|
||||
"content":"{\"server\":{\"host\":\"127.0.0.1\",\"port\":9982,\"api_key\":\"ocswitch-local\"},\"desktop\":{\"launch_at_login\":true,\"minimize_to_tray\":true,\"notifications\":true,\"theme\":\"dark\",\"language\":\"zh-CN\"},\"providers\":[{\"id\":\"demo\",\"name\":\"Demo\",\"base_url\":\"https://example.com/v1\",\"api_key\":\"sk-demo-12345678\",\"models\":[\"gpt-4.1-mini\"]}],\"aliases\":[{\"alias\":\"chat\",\"display_name\":\"Chat\",\"enabled\":true,\"targets\":[{\"provider\":\"demo\",\"model\":\"gpt-4.1-mini\",\"enabled\":true}]}]}"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.ConfigPath == "" {
|
||||
t.Fatalf("configPath is empty: %#v", payload.Data)
|
||||
}
|
||||
if len(payload.Data.Warnings) != 1 || !strings.Contains(payload.Data.Warnings[0], "sync desktop integrations") {
|
||||
t.Fatalf("warnings = %#v, want desktop sync warning", payload.Data.Warnings)
|
||||
}
|
||||
if tray.syncCalls != 1 {
|
||||
t.Fatalf("tray syncCalls = %d, want 1", tray.syncCalls)
|
||||
}
|
||||
if tray.refreshCalls != 1 {
|
||||
t.Fatalf("tray refreshCalls = %d, want 1", tray.refreshCalls)
|
||||
}
|
||||
if !notify.lastPrefs.Notifications {
|
||||
t.Fatalf("notify prefs = %#v, want notifications enabled", notify.lastPrefs)
|
||||
}
|
||||
|
||||
loaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
@ -89,6 +441,140 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("provider save exposes warnings", func(t *testing.T) {
|
||||
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":"bad upstream"}`))
|
||||
}))
|
||||
defer providerServer.Close()
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.UpsertProvider(config.Provider{
|
||||
ID: "warnme",
|
||||
BaseURL: "https://prior.example.com/v1",
|
||||
APIKey: "sk-prior",
|
||||
Models: []string{"gpt-4.1"},
|
||||
ModelsSource: "discovered",
|
||||
})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/providers", strings.NewReader(`{"id":"warnme","baseUrl":"`+providerServer.URL+`/v1","apiKey":"sk-new"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Provider struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"provider"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.Provider.ID != "warnme" {
|
||||
t.Fatalf("saved provider = %#v", payload.Data.Provider)
|
||||
}
|
||||
if len(payload.Data.Warnings) == 0 {
|
||||
t.Fatalf("warnings = %#v, want non-empty", payload.Data.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias target state route toggles enabled flag", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/aliases/state", strings.NewReader(`{"alias":"chat","provider":"demo","model":"gpt-4.1-mini","disabled":true}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("disable status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
AvailableTargetCount int `json:"availableTargetCount"`
|
||||
Targets []struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"targets"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.AvailableTargetCount != 0 || len(payload.Data.Targets) != 1 || payload.Data.Targets[0].Enabled {
|
||||
t.Fatalf("disabled payload = %#v", payload.Data)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/aliases/state", strings.NewReader(`{"alias":"chat","provider":"demo","model":"gpt-4.1-mini","disabled":false}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("enable status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
|
||||
loaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
alias := loaded.FindAlias("chat")
|
||||
if alias == nil || len(alias.Targets) != 1 || !alias.Targets[0].Enabled {
|
||||
t.Fatalf("persisted alias = %#v", alias)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("provider import exposes warnings", func(t *testing.T) {
|
||||
sourcePath := filepath.Join(t.TempDir(), "opencode.json")
|
||||
if err := os.WriteFile(sourcePath, []byte(`{
|
||||
"provider": {
|
||||
"demo": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {"baseURL": "https://duplicate.example.com/v1", "apiKey": "sk-dup"}
|
||||
},
|
||||
"broken": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {"baseURL": "https://broken.example.com", "apiKey": "sk-bad"}
|
||||
}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/providers/import", strings.NewReader(`{"sourcePath":"`+strings.ReplaceAll(sourcePath, "\\", "\\\\")+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.Imported != 0 || payload.Data.Skipped != 2 {
|
||||
t.Fatalf("import payload = %#v", payload.Data)
|
||||
}
|
||||
if len(payload.Data.Warnings) != 2 {
|
||||
t.Fatalf("warnings = %#v, want 2 entries", payload.Data.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("serves app shell", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
@ -102,3 +588,85 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type failingAutoStart struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (f failingAutoStart) Attach(_ context.Context) {}
|
||||
|
||||
func (f failingAutoStart) Detach() {}
|
||||
|
||||
func (f failingAutoStart) Sync(_ context.Context, _ app.DesktopPrefsView) error {
|
||||
return fmt.Errorf(f.message)
|
||||
}
|
||||
|
||||
type spyTray struct {
|
||||
syncCalls int
|
||||
refreshCalls int
|
||||
lastPrefs app.DesktopPrefsView
|
||||
}
|
||||
|
||||
func (s *spyTray) Attach(_ context.Context) {}
|
||||
|
||||
func (s *spyTray) Detach() {}
|
||||
|
||||
func (s *spyTray) Sync(_ context.Context, prefs app.DesktopPrefsView) {
|
||||
s.syncCalls++
|
||||
s.lastPrefs = prefs
|
||||
}
|
||||
|
||||
func (s *spyTray) RefreshProxyStatus(_ context.Context) {
|
||||
s.refreshCalls++
|
||||
}
|
||||
|
||||
func (s *spyTray) BeforeClose(_ context.Context) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type spyNotifier struct {
|
||||
syncCalls int
|
||||
lastPrefs app.DesktopPrefsView
|
||||
sends []notifyCall
|
||||
}
|
||||
|
||||
func (s *spyNotifier) Attach(_ context.Context) {}
|
||||
|
||||
func (s *spyNotifier) Detach() {}
|
||||
|
||||
func (s *spyNotifier) Sync(_ context.Context, prefs app.DesktopPrefsView) {
|
||||
s.syncCalls++
|
||||
s.lastPrefs = prefs
|
||||
}
|
||||
|
||||
func (s *spyNotifier) Send(_ context.Context, title string, body string) error {
|
||||
s.sends = append(s.sends, notifyCall{Title: title, Body: body})
|
||||
return nil
|
||||
}
|
||||
|
||||
type notifyCall struct {
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
func assertEventually(t *testing.T, fn func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if fn() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not satisfied before timeout")
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
return listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
@ -2,14 +2,21 @@ package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
)
|
||||
|
||||
// Notifier is the future native notification adapter.
|
||||
// Notifier bridges desktop preference state to native notifications.
|
||||
type Notifier struct {
|
||||
service *app.Service
|
||||
ctx context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
prefs app.DesktopPrefsView
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func NewNotifier(service *app.Service) *Notifier {
|
||||
@ -17,9 +24,80 @@ func NewNotifier(service *app.Service) *Notifier {
|
||||
}
|
||||
|
||||
func (n *Notifier) Attach(ctx context.Context) {
|
||||
n.mu.Lock()
|
||||
n.ctx = ctx
|
||||
n.mu.Unlock()
|
||||
_ = n.ensureInitialized(ctx)
|
||||
}
|
||||
|
||||
func (n *Notifier) Detach() {
|
||||
n.mu.Lock()
|
||||
n.ctx = nil
|
||||
n.initialized = false
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Notifier) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
|
||||
n.mu.Lock()
|
||||
if ctx != nil {
|
||||
n.ctx = ctx
|
||||
}
|
||||
n.prefs = prefs
|
||||
n.mu.Unlock()
|
||||
if prefs.Notifications {
|
||||
_ = n.ensureInitialized(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) Send(ctx context.Context, title string, body string) error {
|
||||
n.mu.Lock()
|
||||
if !n.prefs.Notifications {
|
||||
n.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = n.ctx
|
||||
}
|
||||
n.mu.Unlock()
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
if err := n.ensureInitialized(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !desktopNotificationsAvailable(ctx) {
|
||||
return nil
|
||||
}
|
||||
return sendDesktopNotification(ctx, desktopNotification{
|
||||
ID: fmt.Sprintf("ocswitch-%d", time.Now().UnixNano()),
|
||||
Title: title,
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Notifier) ensureInitialized(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
n.mu.Lock()
|
||||
ctx = n.ctx
|
||||
n.mu.Unlock()
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
if n.initialized {
|
||||
n.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
n.mu.Unlock()
|
||||
|
||||
if err := initDesktopNotifications(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
n.initialized = true
|
||||
n.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -4,7 +4,44 @@ package desktop
|
||||
|
||||
import "context"
|
||||
|
||||
type desktopNotification struct {
|
||||
ID string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
func hideWindow(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func showWindow(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func quitWindow(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func openExternalURL(ctx context.Context, url string) error {
|
||||
_ = ctx
|
||||
return openBrowser(url)
|
||||
}
|
||||
|
||||
func initDesktopNotifications(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func desktopNotificationsAvailable(ctx context.Context) bool {
|
||||
_ = ctx
|
||||
return false
|
||||
}
|
||||
|
||||
func sendDesktopNotification(ctx context.Context, notification desktopNotification) error {
|
||||
_ = ctx
|
||||
_ = notification
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -4,11 +4,54 @@ package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
type desktopNotification struct {
|
||||
ID string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
func hideWindow(ctx context.Context) error {
|
||||
wruntime.Hide(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func showWindow(ctx context.Context) error {
|
||||
wruntime.Show(ctx)
|
||||
wruntime.WindowShow(ctx)
|
||||
wruntime.WindowUnminimise(ctx)
|
||||
wruntime.WindowSetAlwaysOnTop(ctx, true)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
wruntime.WindowSetAlwaysOnTop(ctx, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func quitWindow(ctx context.Context) error {
|
||||
wruntime.Quit(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func openExternalURL(ctx context.Context, url string) error {
|
||||
wruntime.BrowserOpenURL(ctx, url)
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDesktopNotifications(ctx context.Context) error {
|
||||
return wruntime.InitializeNotifications(ctx)
|
||||
}
|
||||
|
||||
func desktopNotificationsAvailable(ctx context.Context) bool {
|
||||
return wruntime.IsNotificationAvailable(ctx)
|
||||
}
|
||||
|
||||
func sendDesktopNotification(ctx context.Context, notification desktopNotification) error {
|
||||
return wruntime.SendNotification(ctx, wruntime.NotificationOptions{
|
||||
ID: notification.ID,
|
||||
Title: notification.Title,
|
||||
Body: notification.Body,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
//go:build !desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import (
|
||||
@ -6,9 +8,7 @@ import (
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
)
|
||||
|
||||
// Tray holds desktop resident-mode preferences. Wails v2 does not expose a
|
||||
// stable public tray API in the current pinned version, so this type currently
|
||||
// manages close-to-background behavior without depending on private APIs.
|
||||
// Tray is a no-op shell adapter outside the Wails desktop build.
|
||||
type Tray struct {
|
||||
service *app.Service
|
||||
ctx context.Context
|
||||
@ -32,6 +32,10 @@ func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
|
||||
t.prefs = prefs
|
||||
}
|
||||
|
||||
func (t *Tray) RefreshProxyStatus(ctx context.Context) {
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
|
||||
_ = ctx
|
||||
if !t.prefs.MinimizeToTray {
|
||||
|
||||
36
internal/desktop/tray_language.go
Normal file
36
internal/desktop/tray_language.go
Normal file
@ -0,0 +1,36 @@
|
||||
//go:build desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var systemTrayLanguage = detectSystemTrayLanguage
|
||||
|
||||
func trayLanguage(preference string) string {
|
||||
if language := normalizeTrayLanguage(strings.TrimSpace(preference)); language != "" {
|
||||
return language
|
||||
}
|
||||
if language := normalizeTrayLanguage(systemTrayLanguage()); language != "" {
|
||||
return language
|
||||
}
|
||||
for _, value := range []string{os.Getenv("LC_ALL"), os.Getenv("LANG"), os.Getenv("LANGUAGE")} {
|
||||
if language := normalizeTrayLanguage(value); language != "" {
|
||||
return language
|
||||
}
|
||||
}
|
||||
return "en-US"
|
||||
}
|
||||
|
||||
func normalizeTrayLanguage(value string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
if strings.HasPrefix(lower, "zh") {
|
||||
return "zh-CN"
|
||||
}
|
||||
if strings.HasPrefix(lower, "en") {
|
||||
return "en-US"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
7
internal/desktop/tray_language_other.go
Normal file
7
internal/desktop/tray_language_other.go
Normal file
@ -0,0 +1,7 @@
|
||||
//go:build desktop_wails && !windows
|
||||
|
||||
package desktop
|
||||
|
||||
func detectSystemTrayLanguage() string {
|
||||
return ""
|
||||
}
|
||||
47
internal/desktop/tray_language_test.go
Normal file
47
internal/desktop/tray_language_test.go
Normal file
@ -0,0 +1,47 @@
|
||||
//go:build desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTrayLanguageHonorsExplicitPreference(t *testing.T) {
|
||||
original := systemTrayLanguage
|
||||
systemTrayLanguage = func() string { return "en-US" }
|
||||
t.Cleanup(func() { systemTrayLanguage = original })
|
||||
|
||||
t.Setenv("LC_ALL", "")
|
||||
t.Setenv("LANG", "")
|
||||
t.Setenv("LANGUAGE", "")
|
||||
|
||||
if got := trayLanguage("zh-CN"); got != "zh-CN" {
|
||||
t.Fatalf("trayLanguage(zh-CN) = %q, want zh-CN", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrayLanguageUsesSystemLanguageBeforeEnv(t *testing.T) {
|
||||
original := systemTrayLanguage
|
||||
systemTrayLanguage = func() string { return "zh-CN" }
|
||||
t.Cleanup(func() { systemTrayLanguage = original })
|
||||
|
||||
t.Setenv("LC_ALL", "en_US.UTF-8")
|
||||
t.Setenv("LANG", "")
|
||||
t.Setenv("LANGUAGE", "")
|
||||
|
||||
if got := trayLanguage("system"); got != "zh-CN" {
|
||||
t.Fatalf("trayLanguage(system) = %q, want zh-CN", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrayLanguageFallsBackToEnv(t *testing.T) {
|
||||
original := systemTrayLanguage
|
||||
systemTrayLanguage = func() string { return "" }
|
||||
t.Cleanup(func() { systemTrayLanguage = original })
|
||||
|
||||
t.Setenv("LC_ALL", "")
|
||||
t.Setenv("LANG", "zh_CN.UTF-8")
|
||||
t.Setenv("LANGUAGE", "")
|
||||
|
||||
if got := trayLanguage("system"); got != "zh-CN" {
|
||||
t.Fatalf("trayLanguage(system) = %q, want zh-CN", got)
|
||||
}
|
||||
}
|
||||
20
internal/desktop/tray_language_windows.go
Normal file
20
internal/desktop/tray_language_windows.go
Normal file
@ -0,0 +1,20 @@
|
||||
//go:build desktop_wails && windows
|
||||
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func detectSystemTrayLanguage() string {
|
||||
const localeNameMaxLength = 85
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
proc := kernel32.NewProc("GetUserDefaultLocaleName")
|
||||
var buffer [localeNameMaxLength]uint16
|
||||
result, _, _ := proc.Call(uintptr(unsafe.Pointer(&buffer[0])), uintptr(len(buffer)))
|
||||
if result == 0 {
|
||||
return ""
|
||||
}
|
||||
return syscall.UTF16ToString(buffer[:])
|
||||
}
|
||||
329
internal/desktop/tray_wails.go
Normal file
329
internal/desktop/tray_wails.go
Normal file
@ -0,0 +1,329 @@
|
||||
//go:build desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
frontendassets "github.com/Apale7/opencode-provider-switch/frontend"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
)
|
||||
|
||||
//go:embed assets/icon.ico
|
||||
var trayIcon []byte
|
||||
|
||||
// Tray wires resident-mode controls into a native system tray.
|
||||
type Tray struct {
|
||||
service *app.Service
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
prefs app.DesktopPrefsView
|
||||
language string
|
||||
registered bool
|
||||
ready bool
|
||||
running bool
|
||||
quitting bool
|
||||
|
||||
statusItem *systray.MenuItem
|
||||
showItem *systray.MenuItem
|
||||
hideItem *systray.MenuItem
|
||||
startItem *systray.MenuItem
|
||||
stopItem *systray.MenuItem
|
||||
quitItem *systray.MenuItem
|
||||
}
|
||||
|
||||
func NewTray(service *app.Service) *Tray {
|
||||
return &Tray{service: service}
|
||||
}
|
||||
|
||||
func (t *Tray) Attach(ctx context.Context) {
|
||||
t.mu.Lock()
|
||||
t.ctx = ctx
|
||||
if t.registered {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.registered = true
|
||||
t.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
systray.Run(t.onReady, t.onExit)
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *Tray) Detach() {
|
||||
t.mu.Lock()
|
||||
t.ctx = nil
|
||||
registered := t.registered
|
||||
t.mu.Unlock()
|
||||
if registered {
|
||||
systray.Quit()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
|
||||
t.mu.Lock()
|
||||
if ctx != nil {
|
||||
t.ctx = ctx
|
||||
}
|
||||
t.prefs = prefs
|
||||
t.language = trayLanguage(prefs.Language)
|
||||
ready := t.ready
|
||||
statusItem := t.statusItem
|
||||
showItem := t.showItem
|
||||
hideItem := t.hideItem
|
||||
startItem := t.startItem
|
||||
stopItem := t.stopItem
|
||||
quitItem := t.quitItem
|
||||
labels := t.trayLabelsLocked()
|
||||
t.mu.Unlock()
|
||||
if ready && statusItem != nil && showItem != nil && hideItem != nil && startItem != nil && stopItem != nil && quitItem != nil {
|
||||
systray.SetTitle(labels.appTitle)
|
||||
systray.SetTooltip(labels.appTooltip)
|
||||
showItem.SetTitle(labels.openWindow)
|
||||
showItem.SetTooltip(labels.openWindowHint)
|
||||
hideItem.SetTitle(labels.hideWindow)
|
||||
hideItem.SetTooltip(labels.hideWindowHint)
|
||||
startItem.SetTitle(labels.startProxy)
|
||||
startItem.SetTooltip(labels.startProxyHint)
|
||||
stopItem.SetTitle(labels.stopProxy)
|
||||
stopItem.SetTooltip(labels.stopProxyHint)
|
||||
quitItem.SetTitle(labels.quit)
|
||||
quitItem.SetTooltip(labels.quitHint)
|
||||
}
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) RefreshProxyStatus(ctx context.Context) {
|
||||
t.mu.Lock()
|
||||
if ctx != nil {
|
||||
t.ctx = ctx
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
|
||||
t.mu.Lock()
|
||||
t.ctx = ctx
|
||||
quitting := t.quitting
|
||||
minimize := t.prefs.MinimizeToTray
|
||||
t.mu.Unlock()
|
||||
if quitting || !minimize {
|
||||
return false, nil
|
||||
}
|
||||
return true, hideWindow(ctx)
|
||||
}
|
||||
|
||||
func (t *Tray) onReady() {
|
||||
labels := t.labels()
|
||||
if len(trayIcon) > 0 {
|
||||
systray.SetIcon(trayIcon)
|
||||
}
|
||||
systray.SetTitle(labels.appTitle)
|
||||
systray.SetTooltip(labels.appTooltip)
|
||||
systray.SetOnTapped(func() {
|
||||
_ = t.withContext(showWindow)
|
||||
})
|
||||
|
||||
t.mu.Lock()
|
||||
t.ready = true
|
||||
t.running = true
|
||||
t.statusItem = systray.AddMenuItem(labels.proxyChecking, labels.proxyStatusHint)
|
||||
t.statusItem.Disable()
|
||||
systray.AddSeparator()
|
||||
t.showItem = systray.AddMenuItem(labels.openWindow, labels.openWindowHint)
|
||||
t.hideItem = systray.AddMenuItem(labels.hideWindow, labels.hideWindowHint)
|
||||
systray.AddSeparator()
|
||||
t.startItem = systray.AddMenuItem(labels.startProxy, labels.startProxyHint)
|
||||
t.stopItem = systray.AddMenuItem(labels.stopProxy, labels.stopProxyHint)
|
||||
systray.AddSeparator()
|
||||
t.quitItem = systray.AddMenuItem(labels.quit, labels.quitHint)
|
||||
t.mu.Unlock()
|
||||
|
||||
go t.loop()
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) onExit() {
|
||||
t.mu.Lock()
|
||||
t.ready = false
|
||||
t.running = false
|
||||
t.registered = false
|
||||
t.statusItem = nil
|
||||
t.showItem = nil
|
||||
t.hideItem = nil
|
||||
t.startItem = nil
|
||||
t.stopItem = nil
|
||||
t.quitItem = nil
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *Tray) loop() {
|
||||
for {
|
||||
t.mu.Lock()
|
||||
showItem := t.showItem
|
||||
hideItem := t.hideItem
|
||||
startItem := t.startItem
|
||||
stopItem := t.stopItem
|
||||
quitItem := t.quitItem
|
||||
running := t.running
|
||||
t.mu.Unlock()
|
||||
if !running || showItem == nil || hideItem == nil || startItem == nil || stopItem == nil || quitItem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-showItem.ClickedCh:
|
||||
_ = t.withContext(showWindow)
|
||||
case <-hideItem.ClickedCh:
|
||||
_ = t.withContext(hideWindow)
|
||||
case <-startItem.ClickedCh:
|
||||
t.startProxy()
|
||||
case <-stopItem.ClickedCh:
|
||||
t.stopProxy()
|
||||
case <-quitItem.ClickedCh:
|
||||
t.requestQuit()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) startProxy() {
|
||||
if t.service == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = t.service.StartProxy(ctx)
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) stopProxy() {
|
||||
if t.service == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = t.service.StopProxy(ctx)
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) requestQuit() {
|
||||
t.mu.Lock()
|
||||
t.quitting = true
|
||||
ctx := t.ctx
|
||||
t.mu.Unlock()
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
_ = quitWindow(ctx)
|
||||
}
|
||||
|
||||
func (t *Tray) withContext(fn func(context.Context) error) error {
|
||||
t.mu.Lock()
|
||||
ctx := t.ctx
|
||||
t.mu.Unlock()
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
func (t *Tray) refresh() {
|
||||
t.mu.Lock()
|
||||
ready := t.ready
|
||||
statusItem := t.statusItem
|
||||
startItem := t.startItem
|
||||
stopItem := t.stopItem
|
||||
labels := t.trayLabelsLocked()
|
||||
t.mu.Unlock()
|
||||
if !ready || statusItem == nil || startItem == nil || stopItem == nil || t.service == nil {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := t.service.GetProxyStatus(context.Background())
|
||||
if err != nil {
|
||||
statusItem.SetTitle(labels.proxyUnavailable)
|
||||
startItem.Enable()
|
||||
stopItem.Disable()
|
||||
return
|
||||
}
|
||||
|
||||
if status.Running {
|
||||
statusItem.SetTitle(fmt.Sprintf(labels.proxyRunning, status.BindAddress))
|
||||
startItem.Disable()
|
||||
stopItem.Enable()
|
||||
return
|
||||
}
|
||||
|
||||
statusItem.SetTitle(fmt.Sprintf(labels.proxyStopped, status.BindAddress))
|
||||
startItem.Enable()
|
||||
stopItem.Disable()
|
||||
}
|
||||
|
||||
func (t *Tray) labels() trayLabels {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.trayLabelsLocked()
|
||||
}
|
||||
|
||||
func (t *Tray) trayLabelsLocked() trayLabels {
|
||||
return trayLabels{
|
||||
appTitle: trayLocaleString(t.language, "tray", "appTitle", "ocswitch"),
|
||||
appTooltip: trayLocaleString(t.language, "tray", "appTooltip", "ocswitch desktop"),
|
||||
proxyChecking: trayLocaleString(t.language, "tray", "proxyChecking", "Proxy: checking..."),
|
||||
proxyStatusHint: trayLocaleString(t.language, "tray", "proxyStatusHint", "Current proxy status"),
|
||||
proxyUnavailable: trayLocaleString(t.language, "tray", "proxyUnavailable", "Proxy: unavailable"),
|
||||
proxyRunning: trayLocaleString(t.language, "tray", "proxyRunning", "Proxy: running (%s)"),
|
||||
proxyStopped: trayLocaleString(t.language, "tray", "proxyStopped", "Proxy: stopped (%s)"),
|
||||
openWindow: trayLocaleString(t.language, "tray", "openWindow", "Open window"),
|
||||
openWindowHint: trayLocaleString(t.language, "tray", "openWindowHint", "Show desktop window"),
|
||||
hideWindow: trayLocaleString(t.language, "tray", "hideWindow", "Hide window"),
|
||||
hideWindowHint: trayLocaleString(t.language, "tray", "hideWindowHint", "Hide desktop window"),
|
||||
startProxy: trayLocaleString(t.language, "tray", "startProxy", "Start proxy"),
|
||||
startProxyHint: trayLocaleString(t.language, "tray", "startProxyHint", "Start local proxy"),
|
||||
stopProxy: trayLocaleString(t.language, "tray", "stopProxy", "Stop proxy"),
|
||||
stopProxyHint: trayLocaleString(t.language, "tray", "stopProxyHint", "Stop local proxy"),
|
||||
quit: trayLocaleString(t.language, "tray", "quit", "Quit"),
|
||||
quitHint: trayLocaleString(t.language, "tray", "quitHint", "Exit application"),
|
||||
}
|
||||
}
|
||||
|
||||
func trayLocaleString(language string, key string, field string, fallback string) string {
|
||||
value, ok, err := frontendassets.LocaleValue(language, key, field)
|
||||
if err == nil && ok && strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
type trayLabels struct {
|
||||
appTitle string
|
||||
appTooltip string
|
||||
proxyChecking string
|
||||
proxyStatusHint string
|
||||
proxyUnavailable string
|
||||
proxyRunning string
|
||||
proxyStopped string
|
||||
openWindow string
|
||||
openWindowHint string
|
||||
hideWindow string
|
||||
hideWindowHint string
|
||||
startProxy string
|
||||
startProxyHint string
|
||||
stopProxy string
|
||||
stopProxyHint string
|
||||
quit string
|
||||
quitHint string
|
||||
}
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
)
|
||||
|
||||
func RunWails(configPath string, version string) error {
|
||||
@ -24,12 +25,11 @@ func RunWails(configPath string, version string) error {
|
||||
instance.SetVersion(version)
|
||||
|
||||
return wails.Run(&options.App{
|
||||
Title: "ocswitch desktop",
|
||||
Width: 1280,
|
||||
Height: 880,
|
||||
MinWidth: 980,
|
||||
MinHeight: 720,
|
||||
HideWindowOnClose: true,
|
||||
Title: "ocswitch desktop",
|
||||
Width: 1280,
|
||||
Height: 880,
|
||||
MinWidth: 1120,
|
||||
MinHeight: 720,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: mustFS(assets),
|
||||
},
|
||||
@ -46,6 +46,9 @@ func RunWails(configPath string, version string) error {
|
||||
Linux: &linux.Options{
|
||||
ProgramName: "ocswitch-desktop",
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
DisableWindowIcon: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type LockedFile struct {
|
||||
@ -61,7 +60,7 @@ func acquireLock(path string) (*LockedFile, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open lock: %w", err)
|
||||
}
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||
if err := lockFile(file); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("lock file: %w", err)
|
||||
}
|
||||
@ -72,7 +71,7 @@ func (l *LockedFile) Close() error {
|
||||
if l == nil || l.file == nil {
|
||||
return nil
|
||||
}
|
||||
unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
|
||||
unlockErr := unlockFile(l.file)
|
||||
closeErr := l.file.Close()
|
||||
if unlockErr != nil {
|
||||
return fmt.Errorf("unlock file: %w", unlockErr)
|
||||
@ -82,15 +81,3 @@ func (l *LockedFile) Close() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) error {
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open dir: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := f.Sync(); err != nil {
|
||||
return fmt.Errorf("sync dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
35
internal/fileutil/lock_unix.go
Normal file
35
internal/fileutil/lock_unix.go
Normal file
@ -0,0 +1,35 @@
|
||||
//go:build !windows
|
||||
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func lockFile(file *os.File) error {
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||
return fmt.Errorf("flock lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unlockFile(file *os.File) error {
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil {
|
||||
return fmt.Errorf("flock unlock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) error {
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open dir: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := f.Sync(); err != nil {
|
||||
return fmt.Errorf("sync dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
internal/fileutil/lock_windows.go
Normal file
31
internal/fileutil/lock_windows.go
Normal file
@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func lockFile(file *os.File) error {
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &overlapped); err != nil {
|
||||
return fmt.Errorf("lock file ex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unlockFile(file *os.File) error {
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped); err != nil {
|
||||
return fmt.Errorf("unlock file ex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) error {
|
||||
_ = dir
|
||||
return nil
|
||||
}
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -17,10 +18,6 @@ import (
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
)
|
||||
|
||||
var firstByteTimeout = 15 * time.Second
|
||||
var requestReadTimeout = 30 * time.Second
|
||||
var streamIdleTimeout = 60 * time.Second
|
||||
|
||||
type openAIErrorEnvelope struct {
|
||||
Error openAIError `json:"error"`
|
||||
}
|
||||
@ -42,21 +39,31 @@ type Server struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
logger *log.Logger
|
||||
traces *TraceStore
|
||||
}
|
||||
|
||||
// New constructs a Server from cfg.
|
||||
func New(cfg *config.Config) *Server {
|
||||
func New(cfg *config.Config, stores ...*TraceStore) *Server {
|
||||
var traces *TraceStore
|
||||
if len(stores) > 0 {
|
||||
traces = stores[0]
|
||||
}
|
||||
if traces == nil {
|
||||
traces = NewTraceStore(defaultTraceLimit)
|
||||
}
|
||||
firstByteTimeout := timeoutDuration(cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
|
||||
responseHeaderTimeout := timeoutDuration(cfg.Server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
TLSHandshakeTimeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: firstByteTimeout,
|
||||
ResponseHeaderTimeout: minDuration(responseHeaderTimeout, firstByteTimeout),
|
||||
DisableCompression: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
@ -67,11 +74,18 @@ func New(cfg *config.Config) *Server {
|
||||
Timeout: 0,
|
||||
},
|
||||
logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds),
|
||||
traces: traces,
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP listener until ctx is cancelled.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
return s.ListenAndServeWithReady(ctx, nil)
|
||||
}
|
||||
|
||||
// ListenAndServeWithReady starts the HTTP listener until ctx is cancelled and
|
||||
// reports whether the listening socket was bound successfully.
|
||||
func (s *Server) ListenAndServeWithReady(ctx context.Context, ready chan<- error) error {
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/responses", s.handleResponses)
|
||||
@ -80,14 +94,21 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if ready != nil {
|
||||
ready <- err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: requestReadTimeout,
|
||||
ReadTimeout: timeoutDuration(s.cfg.Server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.ListenAndServe() }()
|
||||
go func() { errCh <- srv.Serve(listener) }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@ -141,6 +162,7 @@ var reqCounter uint64
|
||||
// handleResponses is the main alias→failover proxy entry.
|
||||
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
reqID := atomic.AddUint64(&reqCounter, 1)
|
||||
startedAt := time.Now()
|
||||
if r.Method != http.MethodPost {
|
||||
writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
@ -167,21 +189,50 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rawModel := aliasName
|
||||
aliasName = normalizeAliasName(aliasName)
|
||||
trace := RequestTrace{
|
||||
ID: reqID,
|
||||
StartedAt: startedAt,
|
||||
RawModel: rawModel,
|
||||
Alias: aliasName,
|
||||
RequestHeaders: sanitizeHeaderMap(r.Header),
|
||||
RequestParams: sanitizeJSONValue("", payload),
|
||||
}
|
||||
if stream, ok := payload["stream"].(bool); ok {
|
||||
trace.Stream = stream
|
||||
}
|
||||
defer func() {
|
||||
trace.FinishedAt = time.Now()
|
||||
trace.DurationMs = trace.FinishedAt.Sub(trace.StartedAt).Milliseconds()
|
||||
trace.AttemptCount = len(trace.Attempts)
|
||||
trace.Failover = len(trace.Attempts) > 1
|
||||
if trace.FirstByteMs == 0 {
|
||||
for _, attempt := range trace.Attempts {
|
||||
if attempt.FirstByteMs > 0 {
|
||||
trace.FirstByteMs = attempt.FirstByteMs
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.traces.Add(trace)
|
||||
}()
|
||||
s.logger.Printf("req=%d incoming model=%q alias=%q stream=%v", reqID, rawModel, aliasName, payload["stream"])
|
||||
alias := s.cfg.FindAlias(aliasName)
|
||||
if alias == nil {
|
||||
s.logger.Printf("req=%d alias lookup failed for model=%q alias=%q", reqID, rawModel, aliasName)
|
||||
trace.Error = fmt.Sprintf("alias %q not found", aliasName)
|
||||
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q not found", aliasName))
|
||||
return
|
||||
}
|
||||
if !alias.Enabled {
|
||||
s.logger.Printf("req=%d alias=%q disabled", reqID, aliasName)
|
||||
trace.Error = fmt.Sprintf("alias %q is disabled", aliasName)
|
||||
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName))
|
||||
return
|
||||
}
|
||||
targets := s.cfg.AvailableTargets(*alias)
|
||||
if len(targets) == 0 {
|
||||
s.logger.Printf("req=%d alias=%q has no available targets", reqID, aliasName)
|
||||
trace.Error = fmt.Sprintf("alias %q has no available targets", aliasName)
|
||||
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no available targets", aliasName))
|
||||
return
|
||||
}
|
||||
@ -189,28 +240,62 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
failoverCount := 0
|
||||
var lastRetryable *upstreamFailure
|
||||
for attempt, t := range targets {
|
||||
attemptTrace := TraceAttempt{
|
||||
Attempt: attempt + 1,
|
||||
Provider: t.Provider,
|
||||
Model: t.Model,
|
||||
StartedAt: time.Now(),
|
||||
Result: "pending",
|
||||
}
|
||||
p := s.cfg.FindProvider(t.Provider)
|
||||
if p == nil || !p.IsEnabled() {
|
||||
s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider)
|
||||
attemptTrace.Skipped = true
|
||||
attemptTrace.Result = "skipped"
|
||||
attemptTrace.Error = fmt.Sprintf("provider %q unavailable", t.Provider)
|
||||
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
|
||||
trace.Attempts = append(trace.Attempts, attemptTrace)
|
||||
failoverCount++
|
||||
continue
|
||||
}
|
||||
s.logger.Printf("req=%d alias=%s attempt=%d provider=%s remote_model=%s failovers=%d", reqID, aliasName, attempt+1, p.ID, t.Model, failoverCount)
|
||||
cloned := cloneMap(payload)
|
||||
cloned["model"] = t.Model
|
||||
upstreamURL := strings.TrimRight(p.BaseURL, "/") + "/responses"
|
||||
attemptTrace.URL = upstreamURL
|
||||
attemptTrace.RequestParams = sanitizeJSONValue("", cloned)
|
||||
newBody, err := json.Marshal(cloned)
|
||||
if err != nil {
|
||||
s.logger.Printf("req=%d marshal error: %v", reqID, err)
|
||||
attemptTrace.Result = "internal_error"
|
||||
attemptTrace.Error = "marshal error"
|
||||
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
|
||||
trace.Attempts = append(trace.Attempts, attemptTrace)
|
||||
trace.Error = "marshal error"
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "server_error", "marshal error")
|
||||
return
|
||||
}
|
||||
|
||||
ok, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount)
|
||||
if ok {
|
||||
handled, success, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount, &attemptTrace, &trace)
|
||||
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
|
||||
trace.Attempts = append(trace.Attempts, attemptTrace)
|
||||
trace.FinalProvider = p.ID
|
||||
trace.FinalModel = t.Model
|
||||
trace.FinalURL = upstreamURL
|
||||
trace.StatusCode = attemptTrace.StatusCode
|
||||
if trace.FirstByteMs == 0 {
|
||||
trace.FirstByteMs = attemptTrace.FirstByteMs
|
||||
}
|
||||
if handled {
|
||||
trace.Success = success
|
||||
if !success {
|
||||
trace.Error = errorString(upstreamErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !retryable {
|
||||
s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr)
|
||||
trace.Error = errorString(upstreamErr)
|
||||
return
|
||||
}
|
||||
if failure != nil {
|
||||
@ -221,6 +306,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if lastRetryable != nil {
|
||||
trace.StatusCode = lastRetryable.status
|
||||
trace.Error = fmt.Sprintf("upstream %d", lastRetryable.status)
|
||||
copyResponseHeaders(w.Header(), lastRetryable.header)
|
||||
w.WriteHeader(lastRetryable.status)
|
||||
if len(lastRetryable.body) > 0 {
|
||||
@ -229,11 +316,13 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
trace.StatusCode = http.StatusBadGateway
|
||||
trace.Error = fmt.Sprintf("all upstream targets failed for alias %q", aliasName)
|
||||
writeOpenAIError(w, http.StatusBadGateway, "server_error", fmt.Sprintf("all upstream targets failed for alias %q", aliasName))
|
||||
}
|
||||
|
||||
// tryOnce proxies one attempt. Returns (ok, retryable, err, failure).
|
||||
// ok=true means successful response fully/partially written to client.
|
||||
// tryOnce proxies one attempt. Returns (handled, success, retryable, err, failure).
|
||||
// handled=true means a downstream response has already been started or completed.
|
||||
// retryable=true means failure happened before any bytes flushed downstream.
|
||||
func (s *Server) tryOnce(
|
||||
ctx context.Context,
|
||||
@ -245,14 +334,16 @@ func (s *Server) tryOnce(
|
||||
aliasName string,
|
||||
attempt int,
|
||||
failoverCount int,
|
||||
) (ok bool, retryable bool, err error, failure *upstreamFailure) {
|
||||
attemptTrace *TraceAttempt,
|
||||
trace *RequestTrace,
|
||||
) (handled bool, success bool, retryable bool, err error, failure *upstreamFailure) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses"
|
||||
upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("build request: %w", err), nil
|
||||
return false, false, false, fmt.Errorf("build request: %w", err), nil
|
||||
}
|
||||
copyForwardHeaders(upReq.Header, clientReq.Header)
|
||||
upReq.Header.Set("Content-Type", "application/json")
|
||||
@ -264,44 +355,98 @@ func (s *Server) tryOnce(
|
||||
upReq.Header.Set(k, v)
|
||||
}
|
||||
upReq.ContentLength = int64(len(body))
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.RequestHeaders = sanitizeHeaderMap(upReq.Header)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
firstByteTimeout := timeoutDuration(s.cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
|
||||
resp, err := s.client.Do(upReq)
|
||||
if err != nil {
|
||||
return false, true, fmt.Errorf("upstream dial/transport: %w", err), nil
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Retryable = true
|
||||
attemptTrace.Result = "transport_error"
|
||||
attemptTrace.Error = fmt.Sprintf("upstream dial/transport: %v", err)
|
||||
}
|
||||
return false, false, true, fmt.Errorf("upstream dial/transport: %w", err), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.StatusCode = resp.StatusCode
|
||||
attemptTrace.ResponseHeaders = sanitizeHeaderMap(resp.Header)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
|
||||
failure = captureRetryableFailure(resp)
|
||||
return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(failure.body), 200)), failure
|
||||
sanitizedBody := sanitizeResponseBody(resp.Header.Get("Content-Type"), failure.body)
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Retryable = true
|
||||
attemptTrace.Result = "retryable_failure"
|
||||
attemptTrace.Error = fmt.Sprintf("upstream %d: %s", resp.StatusCode, sanitizedBody)
|
||||
attemptTrace.ResponseBody = sanitizedBody
|
||||
}
|
||||
return false, false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, sanitizedBody), failure
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode)
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Result = "final_failure"
|
||||
}
|
||||
s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
|
||||
copyResponseHeaders(w.Header(), resp.Header)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
return true, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.ResponseBody = sanitizeResponseBody(resp.Header.Get("Content-Type"), bodyBytes)
|
||||
}
|
||||
_, _ = w.Write(bodyBytes)
|
||||
return true, false, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
|
||||
}
|
||||
|
||||
remaining := firstByteTimeout - time.Since(startedAt)
|
||||
if remaining <= 0 {
|
||||
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
||||
return false, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
||||
}
|
||||
firstChunk, firstErr := readFirstChunk(resp.Body, remaining)
|
||||
if firstErr != nil {
|
||||
if errors.Is(firstErr, errFirstByteTimeout) {
|
||||
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Retryable = true
|
||||
attemptTrace.Result = "first_byte_timeout"
|
||||
attemptTrace.Error = fmt.Sprintf("upstream first byte timeout after %s", firstByteTimeout)
|
||||
}
|
||||
return false, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
||||
}
|
||||
if errors.Is(firstErr, io.EOF) {
|
||||
if len(firstChunk) == 0 {
|
||||
firstChunk = nil
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Retryable = true
|
||||
attemptTrace.Result = "empty_response"
|
||||
attemptTrace.Error = "upstream closed before first byte"
|
||||
}
|
||||
return false, false, true, fmt.Errorf("upstream closed before first byte"), nil
|
||||
}
|
||||
} else {
|
||||
return false, true, fmt.Errorf("upstream first read: %w", firstErr), nil
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Retryable = true
|
||||
attemptTrace.Result = "first_read_error"
|
||||
attemptTrace.Error = fmt.Sprintf("upstream first read: %v", firstErr)
|
||||
}
|
||||
return false, false, true, fmt.Errorf("upstream first read: %w", firstErr), nil
|
||||
}
|
||||
}
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.FirstByteMs = time.Since(startedAt).Milliseconds()
|
||||
}
|
||||
if trace != nil && trace.FirstByteMs == 0 && attemptTrace != nil {
|
||||
trace.FirstByteMs = attemptTrace.FirstByteMs
|
||||
}
|
||||
|
||||
isEventStream := false
|
||||
streamIdleTimeout := timeoutDuration(s.cfg.Server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
|
||||
if mediaType, _, parseErr := mime.ParseMediaType(resp.Header.Get("Content-Type")); parseErr == nil {
|
||||
isEventStream = mediaType == "text/event-stream"
|
||||
}
|
||||
|
||||
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode)
|
||||
s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
|
||||
@ -310,7 +455,11 @@ func (s *Server) tryOnce(
|
||||
flusher, _ := w.(http.Flusher)
|
||||
if len(firstChunk) > 0 {
|
||||
if _, werr := w.Write(firstChunk); werr != nil {
|
||||
return true, false, werr, nil
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Result = "downstream_write_error"
|
||||
attemptTrace.Error = werr.Error()
|
||||
}
|
||||
return true, false, false, werr, nil
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
@ -318,10 +467,22 @@ func (s *Server) tryOnce(
|
||||
}
|
||||
buf := make([]byte, 16<<10)
|
||||
for {
|
||||
n, rerr := readChunkWithTimeout(resp.Body, buf, streamIdleTimeout)
|
||||
var (
|
||||
n int
|
||||
rerr error
|
||||
)
|
||||
if isEventStream {
|
||||
n, rerr = resp.Body.Read(buf)
|
||||
} else {
|
||||
n, rerr = readChunkWithTimeout(resp.Body, buf, streamIdleTimeout)
|
||||
}
|
||||
if n > 0 {
|
||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||
return true, false, werr, nil
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Result = "downstream_write_error"
|
||||
attemptTrace.Error = werr.Error()
|
||||
}
|
||||
return true, false, false, werr, nil
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
@ -329,13 +490,29 @@ func (s *Server) tryOnce(
|
||||
}
|
||||
if rerr != nil {
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
return true, false, nil, nil
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Result = "success"
|
||||
attemptTrace.Success = true
|
||||
}
|
||||
return true, true, false, nil, nil
|
||||
}
|
||||
return true, false, rerr, nil
|
||||
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream body read failed after response start: %v", aliasName, attempt, provider.ID, target.Model, rerr)
|
||||
if attemptTrace != nil {
|
||||
attemptTrace.Result = "stream_error"
|
||||
attemptTrace.Error = rerr.Error()
|
||||
}
|
||||
return true, false, false, rerr, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errorString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
var errFirstByteTimeout = errors.New("first byte timeout")
|
||||
var errStreamIdleTimeout = errors.New("stream idle timeout")
|
||||
|
||||
@ -433,6 +610,20 @@ func requestReadError(err error) (int, string) {
|
||||
}
|
||||
}
|
||||
|
||||
func timeoutDuration(value int, fallback int) time.Duration {
|
||||
if value <= 0 {
|
||||
value = fallback
|
||||
}
|
||||
return time.Duration(value) * time.Millisecond
|
||||
}
|
||||
|
||||
func minDuration(a time.Duration, b time.Duration) time.Duration {
|
||||
if a <= b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func normalizeAliasName(model string) string {
|
||||
prefix := config.AppName + "/"
|
||||
if strings.HasPrefix(model, prefix) {
|
||||
|
||||
@ -105,6 +105,187 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
|
||||
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
|
||||
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
|
||||
}
|
||||
traces := srv.traces.List(10)
|
||||
if len(traces) != 1 {
|
||||
t.Fatalf("trace count = %d, want 1", len(traces))
|
||||
}
|
||||
if !traces[0].Failover || traces[0].FinalProvider != "p2" || traces[0].AttemptCount != 2 {
|
||||
t.Fatalf("trace = %#v", traces[0])
|
||||
}
|
||||
if got := traces[0].RequestHeaders["Authorization"]; got == "Bearer "+config.DefaultLocalAPIKey || got == "" {
|
||||
t.Fatalf("trace auth header = %q, want masked value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesFailsOverOnEmptySSE200(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer first.Close()
|
||||
|
||||
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("data: ok\n\n"))
|
||||
}))
|
||||
defer second.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{
|
||||
{ID: "p1", BaseURL: first.URL + "/v1"},
|
||||
{ID: "p2", BaseURL: second.URL + "/v1"},
|
||||
},
|
||||
Aliases: []config.Alias{{
|
||||
Alias: "gpt-5.4",
|
||||
Enabled: true,
|
||||
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}, {Provider: "p2", Model: "up-2", Enabled: true}},
|
||||
}},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
srv.handleResponses(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if body := rr.Body.String(); body != "data: ok\n\n" {
|
||||
t.Fatalf("body = %q, want SSE payload from second upstream", body)
|
||||
}
|
||||
if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "2" {
|
||||
t.Fatalf("X-OCSWITCH-Attempt = %q, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("data: first\n\n"))
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
time.Sleep(90 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("data: second\n\n"))
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey, StreamIdleTimeoutMs: 30},
|
||||
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
|
||||
Aliases: []config.Alias{{
|
||||
Alias: "gpt-5.4",
|
||||
Enabled: true,
|
||||
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}},
|
||||
}},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
srv.handleResponses(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if body := rr.Body.String(); body != "data: first\n\ndata: second\n\n" {
|
||||
t.Fatalf("body = %q, want both SSE chunks", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesMarksBrokenStreamAsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("data: first\n\n"))
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Fatal("response writer does not support hijacking")
|
||||
}
|
||||
conn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
t.Fatalf("Hijack() error = %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := New(&config.Config{
|
||||
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
|
||||
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
|
||||
Aliases: []config.Alias{{
|
||||
Alias: "gpt-5.4",
|
||||
Enabled: true,
|
||||
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}},
|
||||
}},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
|
||||
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
srv.handleResponses(rr, req)
|
||||
|
||||
traces := srv.traces.List(10)
|
||||
if len(traces) != 1 {
|
||||
t.Fatalf("trace count = %d, want 1", len(traces))
|
||||
}
|
||||
if traces[0].Success {
|
||||
t.Fatalf("trace = %#v, want failed trace", traces[0])
|
||||
}
|
||||
if traces[0].Error == "" {
|
||||
t.Fatalf("trace = %#v, want error", traces[0])
|
||||
}
|
||||
if len(traces[0].Attempts) != 1 {
|
||||
t.Fatalf("attempts = %#v, want 1", traces[0].Attempts)
|
||||
}
|
||||
if traces[0].Attempts[0].Success {
|
||||
t.Fatalf("attempt = %#v, want failed attempt", traces[0].Attempts[0])
|
||||
}
|
||||
if traces[0].Attempts[0].Result != "stream_error" {
|
||||
t.Fatalf("attempt result = %q, want stream_error", traces[0].Attempts[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUsesConfiguredTimeouts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := New(&config.Config{Server: config.Server{
|
||||
ConnectTimeoutMs: 12000,
|
||||
ResponseHeaderTimeoutMs: 21000,
|
||||
FirstByteTimeoutMs: 22000,
|
||||
RequestReadTimeoutMs: 33000,
|
||||
StreamIdleTimeoutMs: 70000,
|
||||
}})
|
||||
|
||||
transport, ok := srv.client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T", srv.client.Transport)
|
||||
}
|
||||
if transport.ResponseHeaderTimeout != 21*time.Second {
|
||||
t.Fatalf("ResponseHeaderTimeout = %s, want 21s", transport.ResponseHeaderTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
||||
|
||||
260
internal/proxy/traces.go
Normal file
260
internal/proxy/traces.go
Normal file
@ -0,0 +1,260 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultTraceLimit = 200
|
||||
|
||||
type TraceStore struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
traces []RequestTrace
|
||||
}
|
||||
|
||||
type RequestTrace struct {
|
||||
ID uint64 `json:"id"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
FirstByteMs int64 `json:"firstByteMs,omitempty"`
|
||||
RawModel string `json:"rawModel,omitempty"`
|
||||
Alias string `json:"alias,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
Success bool `json:"success"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
FinalProvider string `json:"finalProvider,omitempty"`
|
||||
FinalModel string `json:"finalModel,omitempty"`
|
||||
FinalURL string `json:"finalUrl,omitempty"`
|
||||
Failover bool `json:"failover"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
|
||||
RequestParams any `json:"requestParams,omitempty"`
|
||||
Attempts []TraceAttempt `json:"attempts"`
|
||||
}
|
||||
|
||||
type TraceAttempt struct {
|
||||
Attempt int `json:"attempt"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
FirstByteMs int64 `json:"firstByteMs,omitempty"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
|
||||
RequestParams any `json:"requestParams,omitempty"`
|
||||
ResponseHeaders map[string]string `json:"responseHeaders,omitempty"`
|
||||
ResponseBody string `json:"responseBody,omitempty"`
|
||||
}
|
||||
|
||||
func NewTraceStore(limit int) *TraceStore {
|
||||
if limit <= 0 {
|
||||
limit = defaultTraceLimit
|
||||
}
|
||||
return &TraceStore{limit: limit}
|
||||
}
|
||||
|
||||
func (s *TraceStore) Add(trace RequestTrace) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
clone := cloneTrace(trace)
|
||||
s.traces = append([]RequestTrace{clone}, s.traces...)
|
||||
if len(s.traces) > s.limit {
|
||||
s.traces = s.traces[:s.limit]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TraceStore) List(limit int) []RequestTrace {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if limit <= 0 || limit > len(s.traces) {
|
||||
limit = len(s.traces)
|
||||
}
|
||||
out := make([]RequestTrace, 0, limit)
|
||||
for _, trace := range s.traces[:limit] {
|
||||
out = append(out, cloneTrace(trace))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneTrace(trace RequestTrace) RequestTrace {
|
||||
trace.RequestHeaders = cloneStringMap(trace.RequestHeaders)
|
||||
trace.RequestParams = cloneJSONValue(trace.RequestParams)
|
||||
if len(trace.Attempts) == 0 {
|
||||
trace.Attempts = []TraceAttempt{}
|
||||
return trace
|
||||
}
|
||||
trace.Attempts = append([]TraceAttempt(nil), trace.Attempts...)
|
||||
for index := range trace.Attempts {
|
||||
trace.Attempts[index].RequestHeaders = cloneStringMap(trace.Attempts[index].RequestHeaders)
|
||||
trace.Attempts[index].RequestParams = cloneJSONValue(trace.Attempts[index].RequestParams)
|
||||
trace.Attempts[index].ResponseHeaders = cloneStringMap(trace.Attempts[index].ResponseHeaders)
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(typed))
|
||||
for key, nested := range typed {
|
||||
out[key] = cloneJSONValue(nested)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(typed))
|
||||
for index, nested := range typed {
|
||||
out[index] = cloneJSONValue(nested)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
var sensitiveHeaderNames = map[string]bool{
|
||||
"authorization": true,
|
||||
"proxy-authorization": true,
|
||||
"x-api-key": true,
|
||||
"api-key": true,
|
||||
"cookie": true,
|
||||
"set-cookie": true,
|
||||
}
|
||||
|
||||
var redactedPayloadKeys = map[string]bool{
|
||||
"content": true,
|
||||
"input": true,
|
||||
"instructions": true,
|
||||
"messages": true,
|
||||
"output": true,
|
||||
"output_text": true,
|
||||
"prompt": true,
|
||||
"response": true,
|
||||
"text": true,
|
||||
}
|
||||
|
||||
func sanitizeHeaderMap(header http.Header) map[string]string {
|
||||
if len(header) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(header))
|
||||
for key := range header {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
values := header.Values(key)
|
||||
joined := strings.Join(values, ", ")
|
||||
if sensitiveHeaderNames[strings.ToLower(key)] {
|
||||
joined = maskSensitiveValue(joined)
|
||||
}
|
||||
out[key] = joined
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeJSONValue(key string, value any) any {
|
||||
if redactedPayloadKeys[strings.ToLower(strings.TrimSpace(key))] {
|
||||
return redactedSummary(value)
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(typed))
|
||||
keys := make([]string, 0, len(typed))
|
||||
for nestedKey := range typed {
|
||||
keys = append(keys, nestedKey)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, nestedKey := range keys {
|
||||
out[nestedKey] = sanitizeJSONValue(nestedKey, typed[nestedKey])
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(typed))
|
||||
for index, nested := range typed {
|
||||
out[index] = sanitizeJSONValue("", nested)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
func redactedSummary(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return fmt.Sprintf("<redacted %d item(s)>", len(typed))
|
||||
case map[string]any:
|
||||
return fmt.Sprintf("<redacted object with %d key(s)>", len(typed))
|
||||
case string:
|
||||
if typed == "" {
|
||||
return "<redacted>"
|
||||
}
|
||||
return fmt.Sprintf("<redacted %d chars>", len(typed))
|
||||
default:
|
||||
return "<redacted>"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeResponseBody(contentType string, body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(strings.ToLower(contentType), "json") {
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err == nil {
|
||||
sanitized := sanitizeJSONValue("", payload)
|
||||
encoded, err := json.Marshal(sanitized)
|
||||
if err == nil {
|
||||
return truncate(string(encoded), 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
if redacted, ok := redactedSummary(trimmed).(string); ok {
|
||||
return redacted
|
||||
}
|
||||
return "<redacted>"
|
||||
}
|
||||
|
||||
func maskSensitiveValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "***"
|
||||
}
|
||||
if len(trimmed) <= 8 {
|
||||
return "***"
|
||||
}
|
||||
return trimmed[:4] + "..." + trimmed[len(trimmed)-4:]
|
||||
}
|
||||
9
release-please-config.json
Normal file
9
release-please-config.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "go",
|
||||
"changelog-path": "CHANGELOG.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user