diff --git a/.github/workflows/build-whisper-stt.yml b/.github/workflows/build-whisper-stt.yml index 46b528460..abc07fb5b 100644 --- a/.github/workflows/build-whisper-stt.yml +++ b/.github/workflows/build-whisper-stt.yml @@ -182,7 +182,10 @@ jobs: name: whisper-stt-${{ matrix.tag }} path: whisper-stt-${{ matrix.tag }}.tar.gz if-no-files-found: error - retention-days: 30 + # build.yml now stages these into the installers + # (scripts/stage-whisper-stt.sh), so an expired artifact fails a + # release build. 90 days instead of 30 to make that rarer. + retention-days: 90 - name: Workflow summary if: always() diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ba60c0cf..744c1f7c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,9 +38,16 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup - # ponytail: STT is handled by the bundled whisper-stt-server (whisper.cpp - # with native DTW token timestamps). No separate VAD model is fetched - # here. See technical-documentation/architecture/transcription-and-captions.md for the full architecture. + # STT is the bundled whisper-stt-server (whisper.cpp with native DTW token + # timestamps); no VAD model is fetched here. The binary is built by + # build-whisper-stt.yml and staged below — without that step the installer + # ships without speech-to-text. See + # technical-documentation/architecture/transcription-and-captions.md. + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows app run: npm run build:win -- --publish never @@ -63,9 +70,11 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup - # ponytail: STT is handled by the bundled whisper-stt-server (whisper.cpp - # with native DTW token timestamps). No separate VAD model is fetched - # here. See technical-documentation/architecture/transcription-and-captions.md for the full architecture. + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows Store package run: npm run build:win:store -- --publish never @@ -102,8 +111,12 @@ jobs: env: npm_config_build_from_source: "false" - # ponytail: STT is handled by the bundled whisper-stt-server (see the - # build-windows comment); no VAD model is fetched here. + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh darwin-${{ matrix.arch }} + - name: Resolve macOS signing id: signing env: @@ -261,7 +274,12 @@ jobs: - name: Install pacman build dependencies run: sudo apt-get update && sudo apt-get install -y libarchive-tools - # ponytail: no VAD fetch here either (see build-windows comment). + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh linux-x64 + - name: Build Linux app run: npm run build:linux -- --publish never diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95d9d0c22..adbb039db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,81 +1,123 @@ -name: CI - -# feat/ai-edition is a long-lived integration branch that PRs land on for -# months at a time. Without it listed here, every one of those PRs merged with -# no lint, no typecheck, no tests and no PR-title check. -on: - pull_request: - branches: [main, feat/ai-edition] - push: - branches: [main, feat/ai-edition] - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npm run lint - - typecheck: - name: Type Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npx tsc --noEmit - - docs: - name: Docs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - # No ./.github/actions/setup: check-docs.mjs imports only node builtins, - # so npm ci would be a minute of install for nothing. Node 22 is here for - # import.meta.dirname (needs >= 20.11). - - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: npm run docs:check - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npm run test - - run: npm run test:browser:install - - run: npm run test:browser - - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npx vite build - - semantic-pr: - name: Validate PR title (semantic) - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: amannn/action-semantic-pull-request@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - types: | - feat - fix - chore - refactor - perf - docs - test - build - ci - style - revert - requireScope: false +name: CI + +# feat/ai-edition is a long-lived integration branch that PRs land on for +# months at a time. Without it listed here, every one of those PRs merged with +# no lint, no typecheck, no tests and no PR-title check. +# +# release/** is here for the same reason and it cost more: PRs #167, #168 and +# #169 — 30k+ lines of deletion and refactor — merged into release/1.8.0 with +# every one of those jobs skipped, because a release branch matched neither +# pattern. A release branch is the LAST place to run a build unguarded. +on: + pull_request: + branches: [main, feat/ai-edition, "release/**"] + push: + branches: [main, feat/ai-edition, "release/**"] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npm run lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npx tsc --noEmit + + typecheck-tests: + name: Typecheck (tests) + runs-on: ubuntu-latest + # A RATCHET, not a gate. tsconfig.json includes only src + electron and + # excludes **/*.test.ts, so no test file has ever been typechecked, and + # vitest transpiles without checking — fixture types have drifted from the + # schemas they claim to build for years. Failing on the whole backlog would + # put a red X on every PR that nobody can fix, and a check everyone ignores + # is worse than no check. So: fail only if the count GROWS. Lower BASELINE + # whenever you fix some; delete this job's baseline logic at zero. + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - name: Typecheck tests against a baseline + shell: bash + env: + BASELINE: 80 + run: | + set -uo pipefail + COUNT=$(npx tsc -p tsconfig.test.json --noEmit 2>&1 | grep -c 'error TS' || true) + { + echo "## Test-file typecheck" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Errors now | ${COUNT} |" + echo "| Baseline | ${BASELINE} |" + } >> "$GITHUB_STEP_SUMMARY" + if [ "${COUNT}" -gt "${BASELINE}" ]; then + echo "::error::Test-file type errors went ${BASELINE} -> ${COUNT}. Fix the new ones, or raise BASELINE in ci.yml with a reason." + npx tsc -p tsconfig.test.json --noEmit 2>&1 | grep 'error TS' || true + exit 1 + fi + if [ "${COUNT}" -lt "${BASELINE}" ]; then + echo "::notice::Test-file type errors down to ${COUNT} (baseline ${BASELINE}). Lower BASELINE in ci.yml to lock the win in." + fi + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # No ./.github/actions/setup: check-docs.mjs imports only node builtins, + # so npm ci would be a minute of install for nothing. Node 22 is here for + # import.meta.dirname (needs >= 20.11). + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm run docs:check + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npm run test + - run: npm run test:browser:install + - run: npm run test:browser + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npx vite build + + semantic-pr: + name: Validate PR title (semantic) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + chore + refactor + perf + docs + test + build + ci + style + revert + requireScope: false diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 000000000..a1c2de27f --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,61 @@ +# Third-party notices + +OpenScreen is MIT licensed (see [LICENSE](LICENSE)). The installers additionally +bundle the pre-built native components below. This file ships inside the +application resources and satisfies the attribution and source-offer obligations +that come with them. + +npm dependencies are not listed here: they are resolved from `package.json` and +distributed by their own registries, not redistributed inside our binaries. + +--- + +## FFmpeg — shared libraries (Windows only) + +- **Components**: `avcodec-*.dll`, `avformat-*.dll`, `avutil-*.dll`, + `swresample-*.dll`, `swscale-*.dll` and their siblings, under + `resources/electron/native/bin/win32-*/`. +- **Used by**: the native D3D11 compositor addon, which links against them at + load time. +- **License**: **GNU Lesser General Public License v2.1 or later** + (). FFmpeg's own + licensing page: . +- **This is an LGPL build, not a GPL one.** It is configured without + `--enable-gpl` and without `--enable-nonfree`, and links no GPL-only library + (x264, x265, xvid, vidstab, rubberband, frei0r, …). `scripts/fetch-ffmpeg.mjs` + verifies this before vendoring — it reads `ffmpeg -L`, `-buildconf` and + `-encoders` and refuses any binary that reports otherwise. +- **Upstream binaries**: BtbN/FFmpeg-Builds, release + `autobuild-2026-07-15-14-01`, the `*-lgpl-shared-8.1` assets. Pinned by + SHA-256 in `scripts/fetch-ffmpeg.mjs`; the digests there identify the exact + artifacts we ship. + +- **Corresponding source**: FFmpeg n8.1.2, commit `94138f6973`, from + . The build configuration and scripts that + produced these exact binaries are published at + . +- **Relinking**: as required by the LGPL, these are dynamic libraries. You may + replace them with your own build of the same FFmpeg version by overwriting the + DLLs in `resources/electron/native/bin/win32-*/`. + +## whisper.cpp and ggml + +- **Components**: `whisper-stt-server` and its ggml backend sidecars, under + `resources/electron/native/bin/-/`. +- **License**: MIT — and + . +- Built from source by `scripts/build-whisper-stt.sh`; the pinned upstream + revision is in `electron/native/whisper-stt/CMakeLists.txt`. +- The speech model (`ggml-*.bin`) is **not** bundled — it is downloaded into the + user's data directory on first use by `electron/stt/modelManager.ts`. + +## OpenScreen native helpers + +`wgc-capture` (Windows Graphics Capture), the ScreenCaptureKit helper (macOS) +and the D3D11 compositor addon are part of this repository and are covered by +[LICENSE](LICENSE). + +--- + +To report an omission or request source for anything bundled here, open an issue +at . diff --git a/electron-builder.json5 b/electron-builder.json5 index cbce12e3e..1a2f8d9d4 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -1,140 +1,158 @@ -// @see - https://www.electron.build/configuration/configuration -{ - "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", - "appId": "com.etiennelescot.openscreen", - "asar": true, - // .node binaries can't be dlopen'd from inside an asar — must live unpacked. - // onnxruntime-node distributes as a `.node` shared object that whisper.cpp - // helpers run from process spawn, so they all unpack via the same glob. - "asarUnpack": [ - "**/*.node" - ], - "productName": "Openscreen", - "npmRebuild": true, - // sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source - // needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we - // let electron-builder use the prebuilt instead of recompiling. - "buildDependenciesFromSource": false, - "compression": "normal", - "directories": { - "output": "release/${version}" - }, - "files": [ - "dist", - "dist-electron", - "!*.png", - "!preview*.png", - "!*.md", - "!README.md", - "!CONTRIBUTING.md", - "!LICENSE" - ], - // Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must - // align with assetBaseDir in electron/preload.ts (packaged branch). - // Native capture helpers + whisper-stt-server binaries (and their ggml - // backend sidecars) live under `electron/native/bin/-/` - // and the per-platform sections below limit them to the matching arch in - // each installer. - // - // ponytail: the STT model (ggml-small-q8_0.bin) is downloaded into the - // userData cache on first use by electron/stt/modelManager.ts, so no model - // files are bundled at build time. - "extraResources": [ - { - "from": "public/wallpapers", - "to": "wallpapers" - }, - { - "from": "public/cursors", - "to": "cursors" - } - ], - - "mac": { - "notarize": false, - "hardenedRuntime": true, - "entitlements": "macos.entitlements", - "entitlementsInherit": "macos.entitlements", - "target": [ - { - "target": "dmg", - "arch": ["x64", "arm64"] - } - ], - "icon": "icons/icons/mac/icon.icns", - "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", - "extraResources": [ - { - "from": "electron/native/bin", - "to": "electron/native/bin", - "filter": ["darwin-*/*"] - } - ], - "extendInfo": { - "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", - "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", - "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", - "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.", - "NSCameraUseContinuityCameraDeviceType": true - } - }, - "linux": { - "target": [ - "AppImage", - "deb", - "pacman" - ], - "icon": "icons/icons/png", - "artifactName": "${productName}-Linux-${version}.${ext}", - "maintainer": "Etienne Lescot ", - "category": "AudioVideo", - "extraResources": [ - { - "from": "electron/native/bin", - "to": "electron/native/bin", - "filter": ["linux-*/*"] - } - ] - }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico", - "artifactName": "${productName}.Setup.${version}.${ext}", - "extraResources": [ - { - "from": "electron/native/bin", - "to": "electron/native/bin", - "filter": ["win32-*/*"] - } - ], - // Native D3D11 compositor addon (Windows-only). Built by - // `npm run build:native:compositor` (scripts/build-windows-compositor-addon.mjs) - // before packaging. Packed into asar, then auto-unpacked by the top-level - // `asarUnpack: ["**/*.node"]` rule to app.asar.unpacked/... — matches the - // `.asar` -> `.asar.unpacked` rewrite compositorViewService.ts's - // buildCandidatePaths() already applies for packaged builds. Appends to - // (doesn't replace) the top-level `files` list. - "files": [ - "electron/native/compositor-view/build" - ] - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true - }, - // Microsoft Store packaging identity, from Partner Center's "Product identity" page - // (Applications et jeux > OpenScreen > Product identity). - "appx": { - "identityName": "EtienneLescot.OpenScreen", - "publisher": "CN=03B0FC8C-2067-45D9-BE82-9F15CE264B4A", - "publisherDisplayName": "Etienne Lescot", - "applicationId": "Openscreen", - "displayName": "OpenScreen", - "backgroundColor": "transparent", - "capabilities": ["runFullTrust", "microphone", "webcam"], - "languages": ["en-US", "fr-FR"], - "showNameOnTiles": true - } -} +// @see - https://www.electron.build/configuration/configuration +{ + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "appId": "com.etiennelescot.openscreen", + "asar": true, + // .node binaries can't be dlopen'd from inside an asar — must live unpacked. + // onnxruntime-node distributes as a `.node` shared object that whisper.cpp + // helpers run from process spawn, so they all unpack via the same glob. + "asarUnpack": [ + "**/*.node" + ], + "productName": "Openscreen", + "npmRebuild": true, + // sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source + // needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we + // let electron-builder use the prebuilt instead of recompiling. + "buildDependenciesFromSource": false, + "compression": "normal", + "directories": { + "output": "release/${version}" + }, + "files": [ + "dist", + "dist-electron", + "!*.png", + "!preview*.png", + "!*.md", + "!README.md", + "!CONTRIBUTING.md", + "!LICENSE" + ], + // Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must + // align with assetBaseDir in electron/preload.ts (packaged branch). + // Native capture helpers + whisper-stt-server binaries (and their ggml + // backend sidecars) live under `electron/native/bin/-/` + // and the per-platform sections below limit them to the matching arch in + // each installer. + // + // ponytail: the STT model (ggml-small-q8_0.bin) is downloaded into the + // userData cache on first use by electron/stt/modelManager.ts, so no model + // files are bundled at build time. + "extraResources": [ + // Attribution + LGPL source offer for the bundled ffmpeg DLLs and + // whisper.cpp binaries. Redistributing LGPL libraries obliges us to ship + // the notice with them, so it goes in resources/, not just in the repo. + { + "from": "THIRD-PARTY-NOTICES.md", + "to": "THIRD-PARTY-NOTICES.md" + }, + { + "from": "LICENSE", + "to": "LICENSE" + }, + { + "from": "public/wallpapers", + "to": "wallpapers" + }, + { + "from": "public/cursors", + "to": "cursors" + } + ], + + "mac": { + "notarize": false, + "hardenedRuntime": true, + "entitlements": "macos.entitlements", + "entitlementsInherit": "macos.entitlements", + "target": [ + { + "target": "dmg", + "arch": ["x64", "arm64"] + } + ], + "icon": "icons/icons/mac/icon.icns", + "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", + "to": "electron/native/bin", + "filter": ["darwin-*/*"] + } + ], + "extendInfo": { + "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", + "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", + "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.", + "NSCameraUseContinuityCameraDeviceType": true + } + }, + "linux": { + "target": [ + "AppImage", + "deb", + "pacman" + ], + "icon": "icons/icons/png", + "artifactName": "${productName}-Linux-${version}.${ext}", + "maintainer": "Etienne Lescot ", + "category": "AudioVideo", + "extraResources": [ + { + "from": "electron/native/bin", + "to": "electron/native/bin", + "filter": ["linux-*/*"] + } + ] + }, + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico", + "artifactName": "${productName}.Setup.${version}.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", + "to": "electron/native/bin", + // The static ffmpeg.exe is excluded on purpose. scripts/fetch-ffmpeg.mjs + // vendors two artifacts into this directory: the shared av*.dll set, + // which the D3D11 compositor addon dlopens at require() time and + // therefore MUST ship, and a standalone static ffmpeg.exe that nothing + // in the app spawns — it exists for scripts/bench-export.mjs. Shipping + // it added a large binary to every Windows installer, plus an LGPL + // redistribution obligation, for a file no shipped code opens. + "filter": ["win32-*/*", "!win32-*/ffmpeg.exe"] + } + ], + // Native D3D11 compositor addon (Windows-only). Built by + // `npm run build:native:compositor` (scripts/build-windows-compositor-addon.mjs) + // before packaging. Packed into asar, then auto-unpacked by the top-level + // `asarUnpack: ["**/*.node"]` rule to app.asar.unpacked/... — matches the + // `.asar` -> `.asar.unpacked` rewrite compositorViewService.ts's + // buildCandidatePaths() already applies for packaged builds. Appends to + // (doesn't replace) the top-level `files` list. + "files": [ + "electron/native/compositor-view/build" + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + }, + // Microsoft Store packaging identity, from Partner Center's "Product identity" page + // (Applications et jeux > OpenScreen > Product identity). + "appx": { + "identityName": "EtienneLescot.OpenScreen", + "publisher": "CN=03B0FC8C-2067-45D9-BE82-9F15CE264B4A", + "publisherDisplayName": "Etienne Lescot", + "applicationId": "Openscreen", + "displayName": "OpenScreen", + "backgroundColor": "transparent", + "capabilities": ["runFullTrust", "microphone", "webcam"], + "languages": ["en-US", "fr-FR"], + "showNameOnTiles": true + } +} diff --git a/electron/ai-edition/caption-translate.ts b/electron/ai-edition/caption-translate.ts index 02706aff4..2a4db3d3d 100644 --- a/electron/ai-edition/caption-translate.ts +++ b/electron/ai-edition/caption-translate.ts @@ -31,7 +31,6 @@ export interface CaptionTranslateOptions { apiKey: string; baseUrl?: string; reasoningEffort?: string; - accountId?: string; /** Segments per request. Keeps each call small enough to stay reliable while * still giving the model surrounding context to translate coherently. */ batchSize?: number; @@ -128,10 +127,8 @@ export async function translateCaptionSegments( const out: Record = {}; let done = 0; - // Built once, not per batch: for Copilot this call swaps the PAT for a - // runtime token over the network, and a long transcript is a lot of - // batches. ponytail: the Copilot runtime token is short-lived, so a - // translation running longer than its TTL would need a rebuild here. + // Built once, not per batch: a long transcript is a lot of batches and the + // model object is reusable across all of them. let model: Awaited>; try { model = await createOpenScreenChatModel({ @@ -140,7 +137,6 @@ export async function translateCaptionSegments( apiKey: options.apiKey, baseUrl: options.baseUrl, reasoningEffort: options.reasoningEffort, - accountId: options.accountId, }); } catch (error) { return { diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index d714258c6..a2f0f7ccf 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -253,8 +253,6 @@ export async function runChat( error: `No API key for ${def.label}. Add one in Settings → AI.`, }; } - const accountId = - credential?.entry.kind === "codex" ? (credential.entry.accountId ?? undefined) : undefined; const sessions = getProjectSessions(projectId); let session = sessions.get(sessionId); @@ -314,7 +312,6 @@ export async function runChat( model: config.model, baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }); } @@ -349,7 +346,6 @@ export async function runChat( apiKey: apiKey ?? undefined, baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }, history, userMessage: message, @@ -527,8 +523,6 @@ export async function compactSessionNow( if (!def) return null; const credential = llmConfig.getCredential(def.id, def.envKeys); const apiKey = credential?.value ?? ""; - const accountId = - credential?.entry.kind === "codex" ? (credential.entry.accountId ?? undefined) : undefined; const decision = shouldCompact(session.messages); if (!decision || !decision.compact) return null; @@ -541,7 +535,6 @@ export async function compactSessionNow( model: config.model, baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }); if (!ok) return null; return { @@ -658,8 +651,6 @@ export async function compactSession( const def = PROVIDER_DEFINITIONS.find((d) => d.id === config.provider); const credential = def ? llmConfig.getCredential(def.id, def.envKeys) : null; const apiKey = credential?.value ?? ""; - const accountId = - credential?.entry.kind === "codex" ? (credential.entry.accountId ?? undefined) : undefined; const decision = shouldCompact(session.messages); if (!decision) return { summaryMessageId: null, summary: "" }; @@ -672,7 +663,6 @@ export async function compactSession( model: config.model, baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }); return ok; } @@ -685,10 +675,8 @@ async function tryCompactSession(opts: { model: string; baseUrl?: string; reasoningEffort?: string; - accountId?: string; }): Promise<{ summaryMessageId: string | null; summary: string } | null> { - const { session, splitIndex, apiKey, provider, model, baseUrl, reasoningEffort, accountId } = - opts; + const { session, splitIndex, apiKey, provider, model, baseUrl, reasoningEffort } = opts; const oldMessages = session.messages.slice(0, splitIndex); if (oldMessages.length === 0) return null; @@ -704,7 +692,6 @@ async function tryCompactSession(opts: { apiKey, baseUrl, reasoningEffort, - accountId, }); const result = await chatModel.invoke([ new SystemMessage(COMPACTION_SYSTEM_PROMPT), diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts index 5d5090595..0b6581ed4 100644 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts +++ b/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts @@ -10,13 +10,6 @@ import { } from "./agent-provider-capabilities"; describe("getReasoningCapability", () => { - it("gives openai-oauth the full 6-step Codex effort range", () => { - const cap = getReasoningCapability("openai-oauth", "gpt-5"); - expect(cap.supported).toBe(true); - expect(cap.strategy).toBe("custom-openai-account"); - expect(cap.efforts).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]); - }); - it("turns reasoning off for non-reasoning OpenAI models", () => { expect(getReasoningCapability("openai", "gpt-4o").supported).toBe(false); expect(getReasoningCapability("openai", "o3-mini").supported).toBe(true); diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.ts index b1e419fe8..fde62ca7e 100644 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts +++ b/electron/ai-edition/deep-agent/agent-provider-capabilities.ts @@ -60,14 +60,6 @@ export function getReasoningCapability(provider: string, model?: string): Reason const def: ProviderDefinition | undefined = getProviderDefinition(provider); const normalizedModel = normalizeModelName(model); - if (provider === "openai-oauth") { - return { - supported: true, - efforts: AGENT_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "custom-openai-account", - }; - } if ( (provider === "openai" || provider === "openai-compatible") && isOpenAIReasoningModel(normalizedModel) diff --git a/electron/ai-edition/deep-agent/chat-model.test.ts b/electron/ai-edition/deep-agent/chat-model.test.ts index 0efcaf20e..d659c41e9 100644 --- a/electron/ai-edition/deep-agent/chat-model.test.ts +++ b/electron/ai-edition/deep-agent/chat-model.test.ts @@ -1,65 +1,18 @@ -// The Codex account header and the provider-alias normalization are both -// invisible to tsc (an optional field that nothing reads still typechecks), -// so they get a runtime check. +// Provider-alias normalization is invisible to tsc (every branch takes the same +// string type), so it gets a runtime check. +// +// The openai-oauth / copilot-proxy suites that lived here went with those +// providers in 1.8.0 — see provider-registry.ts. -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createOpenScreenChatModel, messageContentToText } from "./chat-model"; -vi.mock("../llm-provider-auth", () => ({ - exchangeGithubCopilotRuntimeToken: vi.fn(async () => ({ - token: "runtime-token", - expiresAt: Date.now() + 60_000, - baseUrl: "https://api.business.githubcopilot.com", - })), - GITHUB_COPILOT_USER_AGENT: "GitHubCopilotChat/0.26.7", - GITHUB_COPILOT_EDITOR_VERSION: "vscode/1.96.2", - GITHUB_COPILOT_PLUGIN_VERSION: "copilot-chat/0.26.7", -})); - /** ChatOpenAI keeps the `configuration` bag it was constructed with on * `clientConfig`; that is where the base URL and default headers land. */ function clientConfig(model: unknown): Record { return (model as { clientConfig?: Record }).clientConfig ?? {}; } -describe("createOpenScreenChatModel — openai-oauth (Codex)", () => { - it("sends chatgpt-account-id, which the gateway requires for an OAuth token", async () => { - const model = await createOpenScreenChatModel({ - provider: "openai-oauth", - model: "gpt-5", - apiKey: "oauth-access-token", - accountId: "acct_123", - }); - expect(clientConfig(model).defaultHeaders).toMatchObject({ - "chatgpt-account-id": "acct_123", - }); - }); - - it("omits the header entirely when there is no account id", async () => { - const model = await createOpenScreenChatModel({ - provider: "openai-oauth", - model: "gpt-5", - apiKey: "oauth-access-token", - }); - expect(clientConfig(model).defaultHeaders).toBeUndefined(); - }); -}); - -describe("createOpenScreenChatModel — copilot-proxy", () => { - it("uses the exchanged runtime token and the base URL it names", async () => { - const model = await createOpenScreenChatModel({ - provider: "copilot-proxy", - model: "gpt-4.1", - apiKey: "github-pat", - }); - expect(clientConfig(model).baseURL).toBe("https://api.business.githubcopilot.com"); - expect(clientConfig(model).defaultHeaders).toMatchObject({ - "Editor-Version": "vscode/1.96.2", - "Openai-Intent": "copilot-gpt-chat-completions", - }); - }); -}); - describe("createOpenScreenChatModel — provider aliases", () => { it("routes the `claude` alias to the Anthropic SDK, not the OpenAI fallback", async () => { const model = await createOpenScreenChatModel({ diff --git a/electron/ai-edition/deep-agent/chat-model.ts b/electron/ai-edition/deep-agent/chat-model.ts index 3d9ddf7a4..80e1be757 100644 --- a/electron/ai-edition/deep-agent/chat-model.ts +++ b/electron/ai-edition/deep-agent/chat-model.ts @@ -1,19 +1,15 @@ // ponytail: port of axcut's createAxcutChatModel (apps/server/src/llm/create-chat-model.ts). // Picks the right @langchain/* chat model class for the configured provider, -// honoring MiniMax/OpenAI-OAuth/GitHub Copilot as "local" providers (Anthropic- -// SDK or OpenAI-SDK shaped) and routing native Anthropic/OpenAI/Mistral calls -// through their first-party SDKs. +// honoring MiniMax as a "local" provider (Anthropic-SDK shaped) and routing +// native Anthropic/OpenAI/Mistral calls through their first-party SDKs. +// +// The openai-oauth (Codex) and copilot-proxy branches were removed in 1.8.0 +// along with their providers — see the note in provider-registry.ts. import { ChatAnthropic } from "@langchain/anthropic"; import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { ChatMistralAI } from "@langchain/mistralai"; import { ChatOpenAI } from "@langchain/openai"; -import { - exchangeGithubCopilotRuntimeToken, - GITHUB_COPILOT_EDITOR_VERSION, - GITHUB_COPILOT_PLUGIN_VERSION, - GITHUB_COPILOT_USER_AGENT, -} from "../llm-provider-auth"; import { normalizeProviderId } from "../provider-registry"; import { buildLangChainReasoningOptions, @@ -26,7 +22,6 @@ export interface OpenScreenChatModelConfig { apiKey?: string; baseUrl?: string; reasoningEffort?: string; - accountId?: string; } // ponytail: placeholder API key for self-hosted OpenAI-compatible endpoints @@ -77,16 +72,9 @@ export async function createOpenScreenChatModel( config.reasoningEffort as never, ); - // ponytail: OpenAI-OAuth (Codex), GitHub Copilot, and MiniMax all ride - // their non-default SDK path (or a base-URL swap). Anthropic-shaped wire - // for MiniMax, ChatGPT-OAuth-shaped for Codex, runtime-token swap for - // Copilot. axcut has the same split. - if ( - config.provider === "openai-oauth" || - config.provider === "copilot-proxy" || - config.provider === "minimax" || - config.provider === "minimax-token-plan" - ) { + // ponytail: MiniMax rides a non-default SDK path — its wire format is + // Anthropic's, not OpenAI's, despite the OpenAI-looking model names. + if (config.provider === "minimax" || config.provider === "minimax-token-plan") { return createLocalProviderChatModel(config, reasoningOptions); } @@ -140,46 +128,6 @@ async function createLocalProviderChatModel( reasoningOptions: ReturnType, ): Promise { switch (config.provider) { - case "openai-oauth": - // ponytail: ChatGPT device-flow OAuth (Codex). axcut has a hand-rolled - // ChatCodexOAuth class for this; for v1 we fall back to a generic - // ChatOpenAI with the chatgpt.com/backend-api base URL. Streaming - // + tool calls work on the gateway, so this is enough for the chat. - // `chatgpt-account-id` is not optional — the gateway rejects an - // OAuth token without the account it was minted for. The rest of - // the Codex header set (originator, x-codex-window-id, the - // OpenAI-Beta Responses opt-in) belongs to the Responses dialect - // this path does not speak; add it with a real ChatCodexOAuth. - return new ChatOpenAI({ - apiKey: config.apiKey, - model: config.model, - configuration: { - baseURL: config.baseUrl || "https://chatgpt.com/backend-api", - ...(config.accountId - ? { defaultHeaders: { "chatgpt-account-id": config.accountId } } - : {}), - }, - }); - case "copilot-proxy": { - // Copilot does not accept the PAT directly: it is exchanged for a - // short-lived runtime token, which also names the base URL to use. - // The editor-identifying headers are part of the contract — the - // endpoint rejects requests without them. - const runtime = await exchangeGithubCopilotRuntimeToken(config.apiKey ?? ""); - return new ChatOpenAI({ - apiKey: runtime.token, - model: config.model, - configuration: { - baseURL: config.baseUrl || runtime.baseUrl || "https://api.individual.githubcopilot.com", - defaultHeaders: { - "User-Agent": GITHUB_COPILOT_USER_AGENT, - "Editor-Version": GITHUB_COPILOT_EDITOR_VERSION, - "Editor-Plugin-Version": GITHUB_COPILOT_PLUGIN_VERSION, - "Openai-Intent": "copilot-gpt-chat-completions", - }, - }, - }); - } case "minimax": case "minimax-token-plan": // ponytail: MiniMax is Anthropic-API-shaped. ChatAnthropic wraps diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 15adbd515..1a2915751 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -247,6 +247,11 @@ describe("DocumentService", () => { // one long/short race spliced the file ~5 times out of 10. A single attempt // passed against the very bug it was written for. 12 rounds miss it once in // ~4000 runs; the loop is the test, not decoration. + // + // Explicit timeout: 12 rounds of real create + fsync + rename take ~1.7s on + // an idle machine but blow vitest's 5s default when the full suite runs its + // files in parallel — observed failing there and passing alone. 30s is the + // "this actually hung" threshold, not the expected runtime. it("leaves valid JSON when a long and a short save race", async () => { for (let round = 0; round < 12; round++) { const doc = await service.createProject(`Race ${round}`); @@ -267,7 +272,7 @@ describe("DocumentService", () => { expect([1, 400]).toContain(parsed.annotations.length); expect(raw.trimEnd().endsWith("}")).toBe(true); } - }); + }, 30_000); it("applies racing saves in call order, last one winning", async () => { const doc = await service.createProject("Order"); diff --git a/electron/ai-edition/llm-config-store.ts b/electron/ai-edition/llm-config-store.ts index 3c4fdcbc3..4db7822b0 100644 --- a/electron/ai-edition/llm-config-store.ts +++ b/electron/ai-edition/llm-config-store.ts @@ -22,38 +22,20 @@ export interface LlmConfig { allowAgentEdits?: boolean; } -export type LlmCredentialKind = "api-key" | "codex" | "github-device" | "github-pat"; +// Only `api-key` remains: the OAuth kinds ("codex", "github-device", +// "github-pat") belonged to the ChatGPT and Copilot providers removed in 1.8.0 +// — see the note in provider-registry.ts. Blobs written by an earlier build may +// still carry them; getCredential() reads any entry with a usable `apiKey` +// rather than matching on `kind`, so old rows neither crash nor need a +// migration. They simply resolve to nothing, since no provider claims them. +export type LlmCredentialKind = "api-key"; export interface ApiKeyCredential { kind: "api-key"; apiKey: string; } -export interface CodexCredential { - kind: "codex"; - apiKey: string; // access token - refreshToken?: string; - accountId?: string; - expiresAt?: number; -} - -export interface GithubDeviceCredential { - kind: "github-device"; - apiKey: string; // github pat/secondary token - accountLogin?: string; - expiresAt?: number; -} - -export interface GithubPatCredential { - kind: "github-pat"; - apiKey: string; // user-pasted GitHub PAT -} - -export type LlmCredential = - | ApiKeyCredential - | CodexCredential - | GithubDeviceCredential - | GithubPatCredential; +export type LlmCredential = ApiKeyCredential; export type LlmCredentials = { [providerId: string]: LlmCredential | string }; @@ -121,13 +103,11 @@ export class LlmConfigStore { if (typeof stored === "string") { return { value: stored, entry: { kind: "api-key", apiKey: stored } }; } - if ( - stored.kind === "api-key" || - stored.kind === "codex" || - stored.kind === "github-device" || - stored.kind === "github-pat" - ) { - return { value: stored.apiKey, entry: stored }; + // Match on a usable secret, not on `kind`: a blob written before the + // OAuth providers were removed still carries kind: "codex" etc., and + // narrowing on the current union would make those rows unreadable. + if (typeof stored.apiKey === "string" && stored.apiKey) { + return { value: stored.apiKey, entry: { kind: "api-key", apiKey: stored.apiKey } }; } return null; } diff --git a/electron/ai-edition/llm-provider-auth.test.ts b/electron/ai-edition/llm-provider-auth.test.ts index 19b406c66..b6621bb23 100644 --- a/electron/ai-edition/llm-provider-auth.test.ts +++ b/electron/ai-edition/llm-provider-auth.test.ts @@ -1,293 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { - beginCodexDeviceAuth, - beginGithubDeviceAuth, - completeCodexDeviceAuth, - completeGithubDeviceAuth, - exchangeGithubCopilotRuntimeToken, - listGithubCopilotModels, - listOpenAiAccountModels, - probeMiniMaxModels, -} from "./llm-provider-auth"; +import { probeMiniMaxModels } from "./llm-provider-auth"; const ORIGINAL_FETCH = globalThis.fetch; -function mockFetchSequence( - responses: Array<{ ok: boolean; status?: number; body: unknown; delayMs?: number }>, -) { - const queue = [...responses]; - vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - const next = queue.shift(); - if (!next) throw new Error("mock fetch: no response queued"); - const init = next.delayMs ? await new Promise((r) => setTimeout(r, next.delayMs)) : null; - void init; - const status = next.status ?? (next.ok ? 200 : 500); - return new Response(JSON.stringify(next.body), { - status, - headers: { "Content-Type": "application/json" }, - }); - }); -} - afterEach(() => { vi.restoreAllMocks(); globalThis.fetch = ORIGINAL_FETCH; }); -describe("beginCodexDeviceAuth", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - it("POSTs to the Codex usercode endpoint and returns a normalized challenge", async () => { - mockFetchSequence([ - { - ok: true, - body: { - device_auth_id: "codex-id-abc", - user_code: "ABCD-EFGH", - interval: "7", - expires_in: 900, - }, - }, - ]); - - const challenge = await beginCodexDeviceAuth(); - - expect(globalThis.fetch).toHaveBeenCalledTimes(1); - const [url, init] = (globalThis.fetch as unknown as { mock: { calls: [unknown, unknown][] } }) - .mock.calls[0]!; - expect(url).toBe("https://auth.openai.com/api/accounts/deviceauth/usercode"); - expect(init).toMatchObject({ method: "POST" }); - - expect(challenge.userCode).toBe("ABCD-EFGH"); - expect(challenge.deviceAuthId).toBe("codex-id-abc"); - expect(challenge.verificationUri).toBe("https://auth.openai.com/codex/device"); - expect(challenge.intervalMs).toBe(7000); - expect(challenge.expiresAt).toBeGreaterThan(Date.now() + 100_000); - }); -}); - -describe("completeCodexDeviceAuth", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - it("exchanges the device code for tokens via OAuth token endpoint", async () => { - mockFetchSequence([ - { - ok: true, - body: { authorization_code: "auth-code-1", code_verifier: "verifier-1" }, - }, - { - ok: true, - body: { - access_token: "eyJhbGciOiJIUzI1NiJ9.payload.signature", - refresh_token: "rt-1", - expires_in: 3600, - }, - }, - ]); - - const tokens = await completeCodexDeviceAuth({ - deviceAuthId: "codex-id", - userCode: "USER", - intervalMs: 10, - expiresAt: Date.now() + 60_000, - }); - - expect(tokens.accessToken).toMatch(/^eyJ/); - expect(tokens.refreshToken).toBe("rt-1"); - expect(tokens.expiresAt).toBeGreaterThan(Date.now()); - }); - - it("throws when the device code expires", async () => { - mockFetchSequence([{ ok: false, status: 403, body: { error: "expired_token" } }]); - - await expect( - completeCodexDeviceAuth({ - deviceAuthId: "id", - userCode: "code", - intervalMs: 1, - expiresAt: Date.now() - 1, - }), - ).rejects.toThrow(/expired/); - }); -}); - -describe("beginGithubDeviceAuth", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - it("POSTs to GitHub /login/device/code with client_id and read:user scope", async () => { - mockFetchSequence([ - { - ok: true, - body: { - device_code: "gh-device-1", - user_code: "GH-USER", - verification_uri: "https://github.com/login/device", - interval: 5, - expires_in: 900, - }, - }, - ]); - - const challenge = await beginGithubDeviceAuth(); - - const [url, init] = (globalThis.fetch as unknown as { mock: { calls: [unknown, unknown][] } }) - .mock.calls[0]!; - expect(url).toBe("https://github.com/login/device/code"); - expect(init).toMatchObject({ method: "POST" }); - - const fetchBody = (init as RequestInit).body as URLSearchParams; - expect(fetchBody.get("client_id")).toBe("Iv1.b507a08c87ecfe98"); - expect(fetchBody.get("scope")).toBe("read:user"); - - expect(challenge.userCode).toBe("GH-USER"); - expect(challenge.deviceCode).toBe("gh-device-1"); - }); - - it("falls back to the GitHub login URL when the server omits verification_uri", async () => { - mockFetchSequence([ - { - ok: true, - body: { - device_code: "d-2", - user_code: "X", - interval: 5, - expires_in: 900, - }, - }, - ]); - - const challenge = await beginGithubDeviceAuth(); - expect(challenge.verificationUri).toBe("https://github.com/login/device"); - }); -}); - -describe("completeGithubDeviceAuth", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - it("returns the access token on the first poll", async () => { - mockFetchSequence([{ ok: true, body: { access_token: "ghp_token_1" } }]); - - const token = await completeGithubDeviceAuth({ - deviceCode: "d-1", - userCode: "U", - intervalMs: 1, - expiresAt: Date.now() + 60_000, - }); - expect(token).toBe("ghp_token_1"); - }); - - it("throws an error for non-pending failures", async () => { - mockFetchSequence([ - { ok: true, body: { error: "access_denied", error_description: "User rejected" } }, - ]); - - await expect( - completeGithubDeviceAuth({ - deviceCode: "d-3", - userCode: "U", - intervalMs: 1, - expiresAt: Date.now() + 60_000, - }), - ).rejects.toThrow(/User rejected/); - }); -}); - -describe("exchangeGithubCopilotRuntimeToken", () => { - it("exchanges the long-lived GitHub PAT for the short-lived Copilot bearer", async () => { - mockFetchSequence([ - { - ok: true, - body: { - token: "tid=copilot;exp=2026", - expires_at: Math.floor(Date.now() / 1000) + 1800, - }, - }, - ]); - - const out = await exchangeGithubCopilotRuntimeToken("ghp_x"); - expect(out.token).toMatch(/^tid=copilot;exp=/); - expect(out.expiresAt).toBeGreaterThan(Date.now()); - expect(out.baseUrl).toBe("https://api.individual.githubcopilot.com"); - }); -}); - -describe("listOpenAiAccountModels", () => { - const jwtForAccount = (accountId: string): string => { - const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); - const payload = Buffer.from( - JSON.stringify({ - "https://api.openai.com/auth": { chatgpt_account_id: accountId }, - }), - ).toString("base64url"); - return `${header}.${payload}.signature`; - }; - - it("returns the slug list visible to the account, sorted by priority", async () => { - mockFetchSequence([ - { - ok: true, - body: { - models: [ - { slug: "gpt-5.4", visibility: "list", priority: 10 }, - { slug: "gpt-4o", visibility: "list", priority: 1 }, - { slug: "secret", visibility: "private" }, - { slug: "internal", visibility: "hidden" }, - ], - }, - }, - ]); - - const models = await listOpenAiAccountModels(jwtForAccount("acc-1")); - expect(models).toEqual(["gpt-4o", "gpt-5.4"]); - }); -}); - -describe("listGithubCopilotModels", () => { - it("exchanges the PAT, lists the Copilot catalog, and sorts alphabetically", async () => { - // 1) Copilot token exchange - // 2) GET {baseUrl}/models - mockFetchSequence([ - { - ok: true, - body: { - token: "copilot-bearer", - expires_at: Math.floor(Date.now() / 1000) + 1800, - }, - }, - { - ok: true, - body: { - data: [{ id: "gpt-4o" }, { id: "claude-3.5-sonnet" }, { id: "gpt-3.5-turbo" }], - }, - }, - ]); - - const models = await listGithubCopilotModels("ghp_x"); - expect(models).toEqual(["claude-3.5-sonnet", "gpt-3.5-turbo", "gpt-4o"]); - }); -}); - -/** - * `probeMiniMaxModels` issues a parallel POST to the OpenAI-compat sibling of - * the Anthropic base URL. The Anthropic base used to be `…/anthropic` and was - * later corrected to `…/anthropic/v1` (see provider-registry.ts and - * `76d823f fix(ai-edition): correct MiniMax base URL to /anthropic/v1`). - * The earlier URL-construction only handled the no-`/v1` shape, so the - * registry default produced the malformed `…/anthropic/v1/v1/chat/completions` - * and every probe 404'd, returning an empty list with no surfaced error. - * - * These tests pin the URL construction across both shapes and assert the - * filtering + error-surfacing behavior. - */ describe("probeMiniMaxModels", () => { /** * Per-model mock: `globalThis.fetch` is called once per candidate model in diff --git a/electron/ai-edition/llm-provider-auth.ts b/electron/ai-edition/llm-provider-auth.ts index b9d1e0952..0317ca268 100644 --- a/electron/ai-edition/llm-provider-auth.ts +++ b/electron/ai-edition/llm-provider-auth.ts @@ -1,412 +1,16 @@ -// Device-flow + GitHub Copilot runtime-token helpers for the AI provider -// feature. Ported from axcut (apps/server/src/llm/provider-runtime/openai-account.ts, -// copilot-account.ts, and the inline functions in services/llm-config-service.ts). +// Model-list discovery for the API-key providers in the registry. // -// Credentials returned by `complete*` flows land in the same -// `safeStorage` blob as API keys via `LlmConfigStore`. No plain-text auth -// files on disk. +// This file used to also carry the Codex (ChatGPT) and GitHub Copilot device +// flows. They were removed for 1.8.0: reaching a user's subscription that way +// meant shipping GitHub's and OpenAI's own OAuth client IDs and an +// `Editor-Version: vscode/…` User-Agent against endpoints reserved for +// first-party clients, inside a signed installer. Both vendors now expose a +// sanctioned surface instead — GitHub's Copilot SDK (we register our own OAuth +// App and pass the user's `gho_` token) and `codex app-server` (we drive the +// user's own `codex login`, so no client ID ships at all). Those are separate +// integrations, not a header swap, so they land in their own PR. // -// Endpoints are not proxied through OpenScreen itself — the Electron -// main process has direct internet access via `app.requestSingleInstanceLock`. - -export interface CodexDeviceChallenge { - verificationUri: string; - verificationUriComplete?: string; - userCode: string; - deviceAuthId: string; - intervalMs: number; - expiresAt: number; -} - -export interface GithubDeviceChallenge { - verificationUri: string; - userCode: string; - deviceCode: string; - intervalMs: number; - expiresAt: number; -} - -export interface CodexTokens { - accessToken: string; - refreshToken?: string; - idToken?: string; - expiresAt?: number; - accountId?: string; -} - -export interface GithubCopilotRuntimeToken { - token: string; - expiresAt: number; - baseUrl: string; -} - -const CODEX_ISSUER = "https://auth.openai.com"; -const CODEX_TOKEN_ENDPOINT = `${CODEX_ISSUER}/oauth/token`; -const CODEX_DEVICE_AUTHORIZATION_ENDPOINT = `${CODEX_ISSUER}/api/accounts/deviceauth/usercode`; -const CODEX_DEVICE_TOKEN_ENDPOINT = `${CODEX_ISSUER}/api/accounts/deviceauth/token`; -const CODEX_DEVICE_REDIRECT_URI = `${CODEX_ISSUER}/deviceauth/callback`; -const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const CODEX_DEVICE_POLLING_SAFETY_MARGIN_MS = 3_000; -export const OPENAI_ACCOUNT_BASE_URL = "https://chatgpt.com/backend-api"; -const CODEX_MODELS_PATH = "/codex/models"; -const GITHUB_DEVICE_CODE_ENDPOINT = "https://github.com/login/device/code"; -const GITHUB_DEVICE_TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token"; -const GITHUB_DEVICE_CLIENT_ID = "Iv1.b507a08c87ecfe98"; -const GITHUB_DEVICE_SCOPE = "read:user"; -const GITHUB_DEVICE_POLLING_SAFETY_MARGIN_MS = 3_000; -const COPILOT_TOKEN_ENDPOINT = "https://api.github.com/copilot_internal/v2/token"; -export const GITHUB_COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"; -export const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.96.2"; -export const GITHUB_COPILOT_PLUGIN_VERSION = "copilot-chat/0.26.7"; - -/** - * Begin the Codex (ChatGPT) device flow. Returns the user code and the - * polling parameters needed by {@link completeCodexDeviceAuth}. - */ -export async function beginCodexDeviceAuth(): Promise { - const response = await fetch(CODEX_DEVICE_AUTHORIZATION_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), - }); - - if (!response.ok) { - const detail = await safeErrorDetail(response); - throw new Error( - detail - ? `OpenAI device login failed: ${detail}` - : `OpenAI device login failed: HTTP ${response.status}`, - ); - } - - const payload = (await response.json()) as { - device_auth_id?: string; - user_code?: string; - interval?: string; - expires_in?: number; - }; - - if (!payload.device_auth_id || !payload.user_code) { - throw new Error("OpenAI device login returned an incomplete challenge."); - } - - const intervalSeconds = Number.parseInt(payload.interval ?? "5", 10); - const intervalMs = Math.max(Number.isFinite(intervalSeconds) ? intervalSeconds : 5, 1) * 1000; - - return { - verificationUri: `${CODEX_ISSUER}/codex/device`, - userCode: payload.user_code, - deviceAuthId: payload.device_auth_id, - intervalMs, - expiresAt: Date.now() + (payload.expires_in ?? 600) * 1000, - }; -} - -/** - * Poll the Codex device-token endpoint until we get an authorization code, - * exchange it at the OAuth token endpoint, and return the access + refresh - * tokens. The caller persists them via `LlmConfigStore.setCredential`. - */ -export async function completeCodexDeviceAuth( - challenge: Omit, -): Promise { - const intervalMs = Math.max(1000, challenge.intervalMs); - - while (Date.now() < challenge.expiresAt) { - const response = await fetch(CODEX_DEVICE_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - device_auth_id: challenge.deviceAuthId, - user_code: challenge.userCode, - }), - }); - - if (response.ok) { - const deviceToken = (await response.json()) as { - authorization_code?: string; - code_verifier?: string; - }; - - if (!deviceToken.authorization_code || !deviceToken.code_verifier) { - throw new Error("OpenAI device login returned an incomplete authorization result."); - } - - const body = new URLSearchParams(); - body.set("grant_type", "authorization_code"); - body.set("code", deviceToken.authorization_code); - body.set("redirect_uri", CODEX_DEVICE_REDIRECT_URI); - body.set("client_id", CODEX_CLIENT_ID); - body.set("code_verifier", deviceToken.code_verifier); - - const tokenResponse = await fetch(CODEX_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - if (!tokenResponse.ok) { - const detail = await safeErrorDetail(tokenResponse); - throw new Error( - detail - ? `OpenAI token exchange failed: ${detail}` - : `OpenAI token exchange failed: HTTP ${tokenResponse.status}`, - ); - } - - const tokens = (await tokenResponse.json()) as { - access_token?: string; - refresh_token?: string; - id_token?: string; - expires_in?: number; - }; - - if (!tokens.access_token) { - throw new Error("OpenAI device login returned no access_token."); - } - - const expiresAt = tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined; - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - expiresAt, - accountId: extractChatgptAccountId(tokens.access_token, tokens.id_token), - }; - } - - if (response.status !== 403 && response.status !== 404) { - const detail = await safeErrorDetail(response); - throw new Error(detail || `OpenAI device flow failed: HTTP ${response.status}`); - } - - await delay(intervalMs + CODEX_DEVICE_POLLING_SAFETY_MARGIN_MS); - } - - throw new Error("OpenAI device code expired. Retry setup."); -} - -/** - * Begin the GitHub Device Flow. Same shape as {@link beginCodexDeviceAuth} - * but the next step is {@link completeGithubDeviceAuth}, which returns - * a Copilot-personal-access-token-style string stored as the user's - * GitHub PAT against the `copilot-proxy` provider. - */ -export async function beginGithubDeviceAuth(): Promise { - const response = await fetch(GITHUB_DEVICE_CODE_ENDPOINT, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ client_id: GITHUB_DEVICE_CLIENT_ID, scope: GITHUB_DEVICE_SCOPE }), - }); - - if (!response.ok) { - throw new Error(`GitHub device code failed: HTTP ${response.status}`); - } - - const payload = (await response.json()) as Record; - const verificationUri = - typeof payload.verification_uri === "string" - ? payload.verification_uri - : "https://github.com/login/device"; - const userCode = typeof payload.user_code === "string" ? payload.user_code : ""; - const deviceCode = typeof payload.device_code === "string" ? payload.device_code : ""; - const intervalSeconds = Number(payload.interval ?? 5); - const expiresInSeconds = Number(payload.expires_in ?? 900); - - if (!userCode || !deviceCode) { - throw new Error("GitHub device code returned an incomplete challenge."); - } - - return { - verificationUri, - userCode, - deviceCode, - intervalMs: Math.max(1000, Number.isFinite(intervalSeconds) ? intervalSeconds * 1000 : 5_000), - expiresAt: Date.now() + (Number.isFinite(expiresInSeconds) ? expiresInSeconds : 900) * 1000, - }; -} - -/** - * Poll the GitHub access-token endpoint and return the PAT. Axcut's - * `copilot-account.ts` is a thin wrapper around the same `completeGitHubDeviceAuth`, - * which is what we expose here. - */ -export async function completeGithubDeviceAuth( - challenge: Omit, -): Promise { - while (Date.now() < challenge.expiresAt) { - const response = await fetch(GITHUB_DEVICE_TOKEN_ENDPOINT, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: GITHUB_DEVICE_CLIENT_ID, - device_code: challenge.deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - const payload = (await response.json().catch(() => ({}))) as Record; - const accessToken = typeof payload.access_token === "string" ? payload.access_token : ""; - if (accessToken) { - return accessToken; - } - - const error = typeof payload.error === "string" ? payload.error : ""; - if (error && error !== "authorization_pending" && error !== "slow_down") { - const description = - typeof payload.error_description === "string" ? payload.error_description : error; - throw new Error(description); - } - - await delay(challenge.intervalMs + GITHUB_DEVICE_POLLING_SAFETY_MARGIN_MS); - } - - throw new Error("GitHub device login expired."); -} - -/** - * Exchange a long-lived GitHub PAT (or OAuth-derived token) for the - * short-lived Copilot API bearer used to call the models endpoint. - * Caller is responsible for caching this; we do not persist the - * Copilot runtime token here. - */ -export async function exchangeGithubCopilotRuntimeToken( - githubToken: string, -): Promise { - const response = await fetch(COPILOT_TOKEN_ENDPOINT, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${githubToken}`, - "User-Agent": "GitHubCopilotChat/0.26.7", - }, - }); - - if (!response.ok) { - throw new Error(`Copilot token exchange failed: HTTP ${response.status}`); - } - - const payload = (await response.json()) as { - token?: string; - expires_at?: number; - expires_in?: number; - }; - if (!payload.token) { - throw new Error("Copilot token exchange returned no token."); - } - - const expiresAt = - typeof payload.expires_at === "number" - ? payload.expires_at * 1000 - : Date.now() + (typeof payload.expires_in === "number" ? payload.expires_in : 300) * 1000; - - return { - token: payload.token, - expiresAt, - baseUrl: deriveCopilotApiBaseUrl(payload.token) ?? "https://api.individual.githubcopilot.com", - }; -} - -function deriveCopilotApiBaseUrl(token: string): string | undefined { - try { - const [, payload] = token.split("."); - if (!payload) return undefined; - const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as { - endpoints?: { api?: string }; - }; - const api = json.endpoints?.api; - return typeof api === "string" ? api : undefined; - } catch { - return undefined; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Fetch the model list for an already-authenticated ChatGPT (Codex) account. - * Returns the slug list visible to the account, sorted by ascending priority. - * On any error, returns an empty list (the caller should fall back to the - * default model in the form). - */ -export async function listOpenAiAccountModels(accessToken: string): Promise { - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }; - try { - const accountId = extractChatgptAccountId(accessToken); - if (accountId) headers["chatgpt-account-id"] = accountId; - } catch { - // best effort - } - - const response = await fetch( - `${OPENAI_ACCOUNT_BASE_URL}${CODEX_MODELS_PATH}?client_version=1.0.0`, - { - headers, - }, - ); - - if (!response.ok) { - throw new Error(`Codex model discovery failed: HTTP ${response.status}`); - } - - const data = (await response.json()) as { - models?: Array<{ - slug?: string; - visibility?: string; - supported_in_api?: boolean; - priority?: number; - }>; - }; - - return (data.models ?? []) - .filter((model) => typeof model.slug === "string" && model.slug!.trim().length > 0) - .filter((model) => (model.visibility ?? "list") === "list") - .sort( - (left, right) => - (left.priority ?? Number.MAX_SAFE_INTEGER) - (right.priority ?? Number.MAX_SAFE_INTEGER), - ) - .map((model) => model.slug!.trim()); -} - -/** - * Fetch the model list for an already-authenticated GitHub Copilot account. - * Exchanges the stored PAT for a short-lived Copilot API bearer, then calls - * `{baseUrl}/models`. The result is sorted alphabetically for stable rendering. - */ -export async function listGithubCopilotModels(githubToken: string): Promise { - const runtime = await exchangeGithubCopilotRuntimeToken(githubToken); - const response = await fetch(`${runtime.baseUrl}/models`, { - headers: { - Authorization: `Bearer ${runtime.token}`, - Accept: "application/json", - "User-Agent": GITHUB_COPILOT_USER_AGENT, - "Editor-Version": GITHUB_COPILOT_EDITOR_VERSION, - "Editor-Plugin-Version": GITHUB_COPILOT_PLUGIN_VERSION, - }, - }); - - if (!response.ok) { - throw new Error(`Copilot model discovery failed: HTTP ${response.status}`); - } - - const payload = (await response.json()) as { data?: Array<{ id?: string }> }; - return (payload.data ?? []) - .map((entry) => entry.id?.trim() ?? "") - .filter((id) => id.length > 0) - .sort((left, right) => left.localeCompare(right)); -} +// Credentials still live in the `safeStorage` blob via `LlmConfigStore`. /** * Generic `GET {url}` model-list fetch shared by the OpenAI-shaped @@ -484,6 +88,9 @@ const MINIMAX_DISCOVERY_CANDIDATE_MODELS = [ * error. Uses the OpenAI-compatible `/v1/chat/completions` path (a sibling of * the Anthropic-shaped `/anthropic` base actually used for chat) purely as a * cheap existence check. + * + * ponytail: these probes spend the user's own key — 9 requests at max_tokens 1, + * once per discovery click. Cache per key if it ever runs on a hot path. */ export async function probeMiniMaxModels(apiKey: string, baseUrl?: string): Promise { const resolvedBaseUrl = baseUrl || "https://api.minimax.io/anthropic"; @@ -537,47 +144,3 @@ export async function probeMiniMaxModels(apiKey: string, baseUrl?: string): Prom } return reachable; } - -async function safeErrorDetail(response: Response): Promise { - try { - const data = (await response.json()) as { - error?: string; - error_description?: string; - message?: string; - }; - return data.error_description ?? data.message ?? data.error; - } catch { - try { - return (await response.text()).trim() || undefined; - } catch { - return undefined; - } - } -} - -/** - * Pull the `chatgpt_account_id` claim from a ChatGPT JWT (access token or - * id token). Returns undefined if the claim is missing — model discovery - * then omits the `chatgpt-account-id` header. - */ -export function extractChatgptAccountId(accessToken: string, idToken?: string): string | undefined { - for (const token of [accessToken, idToken ?? ""]) { - if (!token) continue; - const segments = token.split("."); - if (segments.length !== 3) continue; - try { - const json = Buffer.from(segments[1]!, "base64").toString("utf8"); - const payload = JSON.parse(json) as Record; - const claim = payload["https://api.openai.com/auth"]; - if (claim && typeof claim === "object") { - const accountId = (claim as Record).chatgpt_account_id; - if (typeof accountId === "string" && accountId) { - return accountId; - } - } - } catch { - // fall through and try next token - } - } - return undefined; -} diff --git a/electron/ai-edition/provider-registry.test.ts b/electron/ai-edition/provider-registry.test.ts new file mode 100644 index 000000000..d4918bfc1 --- /dev/null +++ b/electron/ai-edition/provider-registry.test.ts @@ -0,0 +1,37 @@ +// Guards the 1.8.0 provider removal. `openai-oauth` and `copilot-proxy` reached +// a user's subscription by presenting GitHub's and OpenAI's own OAuth client IDs +// and an editor User-Agent against first-party-only endpoints, inside a signed +// installer. They come back on the Copilot SDK / `codex app-server` instead. +// +// This is a lint, not a unit test: it fails if someone re-adds a provider that +// points at one of those endpoints, or an auth kind the app no longer implements. + +import { describe, expect, it } from "vitest"; +import { PROVIDER_DEFINITIONS } from "./provider-registry"; + +const FIRST_PARTY_ONLY_HOSTS = [ + "chatgpt.com/backend-api", + "copilot_internal", + "githubcopilot.com", + "api.individual.githubcopilot.com", +]; + +describe("PROVIDER_DEFINITIONS", () => { + it("ships only API-key providers", () => { + const others = PROVIDER_DEFINITIONS.filter((def) => def.authKind !== "api-key"); + expect(others.map((d) => d.id)).toEqual([]); + }); + + it("points at no endpoint reserved for a vendor's own clients", () => { + const offenders = PROVIDER_DEFINITIONS.filter((def) => + FIRST_PARTY_ONLY_HOSTS.some((host) => def.baseUrl?.includes(host)), + ); + expect(offenders.map((d) => `${d.id} -> ${d.baseUrl}`)).toEqual([]); + }); + + it("no longer defines the two removed providers", () => { + const ids = PROVIDER_DEFINITIONS.map((def) => def.id); + expect(ids).not.toContain("openai-oauth"); + expect(ids).not.toContain("copilot-proxy"); + }); +}); diff --git a/electron/ai-edition/provider-registry.ts b/electron/ai-edition/provider-registry.ts index c8fb047e2..458334c26 100644 --- a/electron/ai-edition/provider-registry.ts +++ b/electron/ai-edition/provider-registry.ts @@ -10,7 +10,10 @@ export interface ProviderDefinition { id: string; label: string; defaultModel: string; - authKind: "api-key" | "oauth-device" | "pat"; + /** Only API-key providers ship today — see the removal note in + * PROVIDER_DEFINITIONS. Widen this again when the Copilot SDK / Codex + * app-server providers land. */ + authKind: "api-key"; supportsReasoningEffort: boolean; /** True when this provider always requires the user to enter a base URL * (e.g. openai-compatible). False when the default is implicit. */ @@ -86,26 +89,14 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ envKeys: ["OPENROUTER_LLM_API_KEY", "OPENROUTER_API_KEY"], setupHint: "Use OPENROUTER_API_KEY or paste an OpenRouter API key.", }, - { - id: "openai-oauth", - label: "ChatGPT (OAuth)", - defaultModel: "gpt-5.4", - authKind: "oauth-device", - supportsReasoningEffort: true, - envKeys: [], - baseUrl: "https://chatgpt.com/backend-api", - setupHint: "Connect a ChatGPT account with the device login flow.", - }, - { - id: "copilot-proxy", - label: "GitHub Copilot", - defaultModel: "gpt-4.1", - authKind: "pat", - supportsReasoningEffort: true, - envKeys: ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], - baseUrl: "https://api.individual.githubcopilot.com", - setupHint: "Use a GitHub Copilot token or connect with the device login flow.", - }, + // REMOVED for 1.8.0: "openai-oauth" (ChatGPT) and "copilot-proxy" (GitHub + // Copilot). Both reached a user's subscription by presenting GitHub's and + // OpenAI's own client IDs and editor User-Agents against endpoints reserved + // for first-party clients (api.github.com/copilot_internal, chatgpt.com/ + // backend-api). Both vendors now offer a sanctioned surface — GitHub's + // Copilot SDK (register our own OAuth App) and `codex app-server` (drives + // the user's own `codex login`, no client ID shipped at all) — so these come + // back on those, not on borrowed credentials. Tracked in the follow-up PR. { id: "minimax", label: "MiniMax API", diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3fdbc681e..d2facad8f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2663,9 +2663,21 @@ export function registerIpcHandlers( return readCursorTelemetryFile(targetVideoPath); }); + // Protocol allowlist. `shell.openExternal` hands the string to the OS handler, + // so `file:`, `ms-msdt:`, a UNC path, or any registered custom scheme is a + // launch primitive — the renderer runs with webSecurity:false and now renders + // model-generated content, so "the renderer is trusted" is not a strong enough + // premise to skip this. http/https/mailto is everything the app actually opens. + const EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + ipcMain.handle("open-external-url", async (_, url: string) => { try { - await shell.openExternal(url); + const parsed = new URL(url); + if (!EXTERNAL_URL_PROTOCOLS.has(parsed.protocol)) { + console.warn(`Refused to open external URL with protocol ${parsed.protocol}`); + return { success: false, error: `Unsupported URL protocol: ${parsed.protocol}` }; + } + await shell.openExternal(parsed.toString()); return { success: true }; } catch (error) { console.error("Failed to open URL:", error); @@ -3343,6 +3355,16 @@ export function registerIpcHandlers( }, ); + // One instance each, not one per call. DocumentService serialises saves of a + // project through a per-INSTANCE queue (see its writeProject comment — this + // race destroyed two real project files), so a second instance means a second + // queue racing for the same path: temp+rename still keeps the file valid, but + // a save can land under a concurrent one and be silently lost. LlmConfigStore + // is hoisted for a duller reason: its constructor does two sync readFileSync + // plus a safeStorage decrypt, and it was running on every chat message. + const aiEditionDocuments = new DocumentService(path.join(app.getPath("userData"), "projects")); + const aiEditionLlmConfig = new LlmConfigStore(app.getPath("userData")); + registerNativeBridgeHandlers({ getPlatform: () => process.platform, getCurrentProjectPath: () => currentProjectPath, @@ -3376,18 +3398,10 @@ export function registerIpcHandlers( return null; } }, - getAiEditionDocuments: () => - new DocumentService(path.join(app.getPath("userData"), "projects")), - getAiEditionLlmConfig: () => new LlmConfigStore(app.getPath("userData")), + getAiEditionDocuments: () => aiEditionDocuments, + getAiEditionLlmConfig: () => aiEditionLlmConfig, runAiEditionChat: (projectId, sessionId, message, document, sink) => - runChat( - projectId, - sessionId, - message, - new LlmConfigStore(app.getPath("userData")), - document, - sink, - ), + runChat(projectId, sessionId, message, aiEditionLlmConfig, document, sink), undoAiEditionToolBatch: (_projectId, _sessionId) => ({ success: false, error: "Per-tool-batch undo retired in favor of per-message rewind.", @@ -3395,18 +3409,12 @@ export function registerIpcHandlers( rewindToMessage: (projectId, sessionId, messageId) => rewindToMessage(projectId, sessionId, messageId), compactNow: (projectId, sessionId) => - compactSessionNow(projectId, sessionId, new LlmConfigStore(app.getPath("userData"))), + compactSessionNow(projectId, sessionId, aiEditionLlmConfig), runTimelineOperation: (projectId, sessionId, op, conversationMessage) => - runTimelineOperation( - projectId, - sessionId, - op, - conversationMessage, - new DocumentService(path.join(app.getPath("userData"), "projects")), - ), + runTimelineOperation(projectId, sessionId, op, conversationMessage, aiEditionDocuments), getContextUsage: getSessionContextUsage, runAiEditionChatDefault: (projectId, message, sink) => - runChatDefault(projectId, message, new LlmConfigStore(app.getPath("userData")), sink), + runChatDefault(projectId, message, aiEditionLlmConfig, sink), getAiEditionChatHistoryDefault: (projectId) => getDefaultChatHistory(projectId), clearAiEditionChatHistoryDefault: (projectId) => clearDefaultChatHistory(projectId), listAiEditionChatSessions: (projectId) => listSessions(projectId), diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 88b90092b..c6e7a228f 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -164,6 +164,19 @@ function createErrorResponse( } satisfies NativeBridgeResponse; } +/** + * Strips absolute filesystem paths out of an error message before it crosses to + * the renderer. Main-process errors quote the path they failed on (`ENOENT: no + * such file or directory, open 'C:\Users\alice\…'`), and that string is rendered + * verbatim in toasts and the chat error rail. Keeps the basename, which is the + * part a user can act on. The full message still goes to the main-process log. + */ +function redactPaths(message: string): string { + return message + .replace(/[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*([^\\/:*?"<>|\r\n]*)/g, "…\\$1") + .replace(/\/(?:[^/\0\s'"]+\/)+([^/\0\s'"]*)/g, "…/$1"); +} + function isBridgeRequest(value: unknown): value is NativeBridgeRequest { if (!value || typeof value !== "object") { return false; @@ -493,23 +506,6 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { requestId, await aiEditionService.llmRemoveApiKey(request.payload.providerId), ); - case "llm.beginDeviceAuth": - return createSuccessResponse( - requestId, - await aiEditionService.llmBeginDeviceAuth( - request.payload.providerId, - request.payload.model, - ), - ); - case "llm.completeDeviceAuth": - return createSuccessResponse( - requestId, - await aiEditionService.llmCompleteDeviceAuth( - request.payload.providerId, - request.payload.challenge, - request.payload.model, - ), - ); case "llm.disconnect": return createSuccessResponse( requestId, @@ -675,11 +671,15 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { ); } } catch (error) { + // Not retryable by default: most failures here are permanent (a missing + // file, a bad payload, an unavailable addon), and a blanket `true` tells + // the client to spin on them. The message keeps the reason but drops any + // absolute path — it crosses to the renderer and ends up in UI strings. + console.error(`native bridge ${domain}.${request.action} failed:`, error); return createErrorResponse( requestId, "INTERNAL_ERROR", - error instanceof Error ? error.message : "Unknown native bridge error.", - true, + redactPaths(error instanceof Error ? error.message : "Unknown native bridge error."), ); } }); diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index 1d5933970..49b2a3aab 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -9,8 +9,6 @@ import type { AiEditionChatRewindResult, AiEditionChatSession, AiEditionChatSessionSummary, - AiEditionDeviceChallenge, - AiEditionDeviceCompletionResult, AiEditionDocumentResult, AiEditionLlmConfig, AiEditionLlmDisconnectResult, @@ -26,15 +24,9 @@ import type { ChatEventSink } from "../../ai-edition/chat-service"; import type { DocumentService } from "../../ai-edition/document-service"; import type { LlmConfigStore, LlmCredential } from "../../ai-edition/llm-config-store"; import { - beginCodexDeviceAuth, - beginGithubDeviceAuth, - completeCodexDeviceAuth, - completeGithubDeviceAuth, listAnthropicModels, - listGithubCopilotModels, listGoogleModels, listMistralModels, - listOpenAiAccountModels, listOpenAiCompatibleModels, listOpenRouterModels, probeMiniMaxModels, @@ -220,80 +212,6 @@ export class AiEditionService { } } - async llmBeginDeviceAuth( - providerId: "openai-oauth" | "copilot-proxy", - _model?: string, - ): Promise { - if (providerId === "openai-oauth") { - return await beginCodexDeviceAuth(); - } - if (providerId === "copilot-proxy") { - return await beginGithubDeviceAuth(); - } - throw new Error(`Provider ${providerId} does not support device flow.`); - } - - async llmCompleteDeviceAuth( - providerId: "openai-oauth" | "copilot-proxy", - challenge: AiEditionDeviceChallenge, - model?: string, - ): Promise { - try { - if (providerId === "openai-oauth") { - const tokens = await completeCodexDeviceAuth({ - deviceAuthId: challenge.deviceAuthId ?? "", - userCode: challenge.userCode, - intervalMs: challenge.intervalMs, - expiresAt: challenge.expiresAt, - }); - const entry: LlmCredential = { - kind: "codex", - apiKey: tokens.accessToken, - refreshToken: tokens.refreshToken, - accountId: tokens.accountId, - expiresAt: tokens.expiresAt, - }; - await this.options.llmConfig.setCredential(providerId, entry); - } else if (providerId === "copilot-proxy") { - const token = await completeGithubDeviceAuth({ - deviceCode: challenge.deviceCode ?? "", - userCode: challenge.userCode, - intervalMs: challenge.intervalMs, - expiresAt: challenge.expiresAt, - }); - const entry: LlmCredential = { kind: "github-device", apiKey: token }; - await this.options.llmConfig.setCredential(providerId, entry); - } else { - throw new Error(`Provider ${providerId} does not support device flow.`); - } - - // Persist the selected model so the chat path knows which model to use. - const existing = this.options.llmConfig.getConfig(); - if (model && (!existing || existing.provider !== providerId)) { - await this.options.llmConfig.setConfig({ - provider: providerId, - model, - baseUrl: existing?.provider === providerId ? existing.baseUrl : undefined, - reasoningEffort: existing?.provider === providerId ? existing.reasoningEffort : undefined, - }); - } else if (!existing) { - const def = PROVIDER_DEFINITIONS.find((d) => d.id === providerId); - if (def) { - await this.options.llmConfig.setConfig({ - provider: providerId, - model: def.defaultModel, - }); - } - } - return { success: true, snapshot: await this.llmGetSnapshot() }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } - async llmDisconnect(providerId: string): Promise { await this.options.llmConfig.removeCredential(providerId); const active = this.options.llmConfig.getConfig(); @@ -308,18 +226,6 @@ export class AiEditionService { async llmListProviderModels(providerId: string): Promise<{ models: string[]; error?: string }> { try { - if (providerId === "openai-oauth") { - const cred = this.options.llmConfig.getCredential(providerId, []); - if (!cred) return { models: [], error: "Not connected" }; - const models = await listOpenAiAccountModels(cred.value); - return { models }; - } - if (providerId === "copilot-proxy") { - const cred = this.options.llmConfig.getCredential(providerId, []); - if (!cred) return { models: [], error: "Not connected" }; - const models = await listGithubCopilotModels(cred.value); - return { models }; - } const def = PROVIDER_DEFINITIONS.find((d) => d.id === providerId); if (!def) return { models: [], error: `Unknown provider ${providerId}` }; const cred = this.options.llmConfig.getCredential(providerId, def.envKeys); @@ -478,9 +384,6 @@ export class AiEditionService { } const def = PROVIDER_DEFINITIONS.find((d) => d.id === config.provider); const credential = def ? this.options.llmConfig.getCredential(def.id, def.envKeys) : null; - const accountId = - credential?.entry.kind === "codex" ? (credential.entry.accountId ?? undefined) : undefined; - const result = await translateCaptionSegments({ segments: input.segments, targetLanguage: input.targetLanguage, @@ -490,7 +393,6 @@ export class AiEditionService { apiKey: credential?.value ?? "", baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }); return { ...result, model: config.model }; } diff --git a/electron/native/whisper-stt/src/main.cpp b/electron/native/whisper-stt/src/main.cpp index e4a70c95d..1cc067697 100644 --- a/electron/native/whisper-stt/src/main.cpp +++ b/electron/native/whisper-stt/src/main.cpp @@ -207,13 +207,14 @@ struct Word { int main(int argc, char** argv) { std::string model_path; std::string host = "127.0.0.1"; + bool host_from_flag = false; int port = 0; int threads = std::max(1u, std::thread::hardware_concurrency()); for (int i = 1; i < argc; ++i) { const std::string a = argv[i]; if (a == "--model" && i + 1 < argc) model_path = argv[++i]; - else if (a == "--host" && i + 1 < argc) host = argv[++i]; + else if (a == "--host" && i + 1 < argc) { host = argv[++i]; host_from_flag = true; } else if (a == "--port" && i + 1 < argc) port = std::atoi(argv[++i]); else if (a == "--threads" && i + 1 < argc) threads = std::atoi(argv[++i]); } @@ -226,7 +227,19 @@ int main(int argc, char** argv) { if (const char* p = std::getenv("OPENSCREEN_WHISPER_PORT")) port = std::atoi(p); } if (const char* p = std::getenv("OPENSCREEN_WHISPER_THREADS")) threads = std::atoi(p); - if (const char* p = std::getenv("OPENSCREEN_WHISPER_HOST")) host = p; + // Only a fallback, never an override: the app always passes --host 127.0.0.1, + // and an env var that can widen a shipped binary's bind address behind an + // explicit flag is a hole, not a knob. Anything but loopback is refused + // outright — this server has no authentication of any kind. + if (!host_from_flag) { + if (const char* p = std::getenv("OPENSCREEN_WHISPER_HOST")) host = p; + } + if (host != "127.0.0.1" && host != "::1" && host != "localhost") { + std::cerr << "FATAL: refusing to bind " << host + << " — whisper-stt-server is unauthenticated and loopback-only" + << std::endl; + return 2; + } if (model_path.empty()) { std::cerr << "FATAL: --model or " diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index 0584d03bf..1f438242d 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -29,6 +29,8 @@ // tag, asset name and digest are one unit. // Source approved by Etienne (2026-07-16). BtbN is linked from ffmpeg.org's // download page and published on winget as BtbN.FFmpeg.LGPL. +// Attribution + source offer for what we redistribute: THIRD-PARTY-NOTICES.md, +// which electron-builder ships into resources/. // // LICENSING — the other thing this script exists to protect: // ffmpeg is LGPL *by default*. It becomes GPL only when built with @@ -38,6 +40,12 @@ // we got before vendoring. Never swap in a "gpl" asset for the extra encoders: // there is nothing in them we need — the hardware encoders are all LGPL. // +// WHAT ACTUALLY SHIPS: only the shared av*.dll set, which the compositor addon +// dlopens. electron-builder excludes the static ffmpeg.exe from the installer +// ("!win32-*/ffmpeg.exe") — it is a bench tool, and shipping a large binary no +// runtime code opens is cost with no benefit. It still lands in this directory +// so scripts/bench-export.mjs can use a local checkout. +// // NOTE: the plan this was vendored for is REFUTED. Feeding native ffmpeg from // the renderer measured 2.1x SLOWER than the WebCodecs path it was to replace — // the wall is the compositor, not the encoder. See @@ -133,9 +141,10 @@ function run(cmd, args, opts = {}) { } /** - * Refuses to vendor anything that would relicense the app. Mirrors isLgplBuild() - * in electron/media/ffmpegCapabilities.ts on purpose: that one guards at - * runtime, this one at build time. If they ever disagree, one of them drifted. + * Refuses to vendor anything that would relicense the app. This is now the only + * licence gate — the runtime twin it used to mirror + * (electron/media/ffmpegCapabilities.ts) went with the web export pipeline, and + * nothing in the app spawns ffmpeg any more. */ function assertLgpl(exePath) { const problems = []; diff --git a/scripts/stage-whisper-stt.sh b/scripts/stage-whisper-stt.sh new file mode 100755 index 000000000..226024111 --- /dev/null +++ b/scripts/stage-whisper-stt.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Stages the whisper-stt-server binary (plus its ggml backend sidecars) into +# electron/native/bin// so electron-builder's extraResources picks it up. +# +# WHY THIS EXISTS. electron/native/bin/ is gitignored and build-whisper-stt.yml +# publishes the binaries as GitHub *artifacts* of its own workflow run. Nothing +# carried them into build.yml, so every installer built before this script +# shipped with no STT binary at all: resolveBinaryPath() found nothing, +# transcription and captions failed with "whisper-stt-server binary not found; +# build it via scripts/build-whisper-stt.sh" — a developer message, shown to +# end users. The model is fetched at runtime by modelManager.ts; the binary is +# not, and never was. +# +# Fails loudly on purpose. A release without STT is worse than a red build, and +# the failure mode this replaces was completely silent. +# +# Usage: bash scripts/stage-whisper-stt.sh +# tag: darwin-arm64 | darwin-x64 | linux-x64 | win32-x64 +# Requires: gh (preinstalled on GitHub runners), GH_TOKEN in the environment. + +set -euo pipefail + +TAG="${1:?usage: stage-whisper-stt.sh }" +ARTIFACT="whisper-stt-${TAG}" +DEST="electron/native/bin/${TAG}" +REPO="${GITHUB_REPOSITORY:-getopenscreen/openscreen}" + +# A locally built binary wins: `npm run build:whisper-binaries` puts one here, +# and a developer testing a change should not have it silently replaced by CI's. +if compgen -G "${DEST}/whisper-stt-server*" > /dev/null; then + echo "whisper-stt-server already present in ${DEST} — leaving it alone." + exit 0 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +echo "Fetching ${ARTIFACT} from the latest successful build-whisper-stt run..." +# No run id: gh resolves the most recent run that published this artifact. +# Artifacts expire (retention-days in build-whisper-stt.yml), so a stale branch +# can legitimately find nothing — say so in terms someone can act on. +if ! gh run download --repo "${REPO}" --name "${ARTIFACT}" --dir "${TMP}" 2>"${TMP}/err"; then + cat "${TMP}/err" >&2 + cat >&2 <&2; exit 1; } + +tar -xzf "${ARCHIVE}" -C "${TMP}" +mkdir -p "${DEST}" +cp -v "${TMP}/${ARTIFACT}"/* "${DEST}/" + +# Verify what we actually staged rather than trusting the copy: this is the +# check whose absence is the whole reason for this script. +BIN="$(find "${DEST}" -maxdepth 1 -name 'whisper-stt-server*' -print -quit)" +[ -n "${BIN}" ] || { echo "FATAL: no whisper-stt-server binary in ${DEST}" >&2; exit 1; } +[ "${TAG#win32}" = "${TAG}" ] && chmod +x "${BIN}" + +echo "Staged $(basename "${BIN}") + $(( $(ls -1 "${DEST}" | wc -l) - 1 )) sidecar(s) -> ${DEST}" diff --git a/src/components/ai-edition/ProviderSettings.tsx b/src/components/ai-edition/ProviderSettings.tsx index 5dd4a0ded..77000be35 100644 --- a/src/components/ai-edition/ProviderSettings.tsx +++ b/src/components/ai-edition/ProviderSettings.tsx @@ -2,44 +2,26 @@ // // UI: 3 screens stacked in the modal, navigated by URL-less state // (mirroring axcut apps/web/src/App.tsx _p modal): -// 1. **list** — 2-col grid of provider cards (8 of them), -// each shows label, default model, an auth-kind -// pill (CONNECTED / OAUTH / TOKEN / API KEY). -// 2. **connect-form** — single form per provider: model + optional -// baseUrl + optional reasoning effort + optional -// api-key field + Connect/Save/Disconnect buttons. -// 3. **device-challenge** — opens when an oauth-device / PAT provider -// finishes its begin step: the user-code block, -// "Open login page" link, and a copy-code button. -// On completion the snapshot refreshes. +// 1. **list** — grid of provider cards, each showing label, default +// model, and a CONNECTED / API KEY pill. +// 2. **connect-form** — single form per provider: model + optional baseUrl + +// optional reasoning effort + api-key field + +// Save/Disconnect buttons. // -// Credentials are stored in the same safeStorage blob (LLMConfigStore), -// including OAuth session tokens. The renderer never sees raw keys — -// only `kind` and the user code / verification URL. +// Every provider is API-key based since 1.8.0 dropped the ChatGPT and Copilot +// OAuth providers (see provider-registry.ts); the device-challenge screen went +// with them. Credentials live in the safeStorage blob (LlmConfigStore) — the +// renderer never sees raw keys, only `kind`. // // ponytail: existing consumers (ProviderSettings used as ) // must keep working. Internal state is local-only. -import { - AlertCircle, - Check, - Copy, - ExternalLink, - Loader2, - LogIn, - Plug, - Unplug, - X, -} from "lucide-react"; +import { AlertCircle, Check, Loader2, Unplug, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { nativeBridgeClient } from "@/native/client"; -import type { - AiEditionDeviceChallenge, - AiEditionLlmConfig, - AiEditionLlmSnapshot, -} from "@/native/contracts"; +import type { AiEditionLlmConfig, AiEditionLlmSnapshot } from "@/native/contracts"; import { getReasoningEffortLabel, getReasoningEffortOptions, @@ -69,7 +51,6 @@ export function ProviderSettings({ const [config, setConfig] = useState(null); const [apiKey, setApiKey] = useState(""); const [busy, setBusy] = useState(false); - const [challenge, setChallenge] = useState(null); const [error, setError] = useState(null); const refreshSnapshot = useCallback(async (): Promise => { @@ -96,7 +77,6 @@ export function ProviderSettings({ setMode("list"); setActive(null); setApiKey(""); - setChallenge(null); setError(null); } }, [open]); @@ -106,7 +86,6 @@ export function ProviderSettings({ setMode("list"); setActive(null); setApiKey(""); - setChallenge(null); setError(null); }, [busy]); @@ -125,7 +104,6 @@ export function ProviderSettings({ const openForm = (def: ProviderDefinition) => { setActive(def); setApiKey(""); - setChallenge(null); setError(null); setConfig((prev) => { const existing = prev?.provider === def.id ? prev : null; @@ -146,7 +124,6 @@ export function ProviderSettings({ setMode("list"); setActive(null); setApiKey(""); - setChallenge(null); setError(null); }; @@ -172,79 +149,6 @@ export function ProviderSettings({ } }; - const startDeviceFlow = async () => { - if (!active) return; - setBusy(true); - setError(null); - try { - const challengeResult = await nativeBridgeClient.aiEdition.llmBeginDeviceAuth( - active.id as "openai-oauth" | "copilot-proxy", - config?.model, - ); - setChallenge(challengeResult); - if (active.id === "copilot-proxy") { - // GitHub device flow accepts an optional PAT. If the user has pasted one, - // pass it through as if they were connecting with the classic token path. - if (apiKey.trim() && active.id === "copilot-proxy") { - // Skipped for now — Copilot device flow uses its own token. - } - } - // Auto-complete poll: kick off completion in the background, the UI shows - // "Completing sign-in…" and the snapshot refresh when the call returns. - void completeDeviceFlowInBackground(challengeResult); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }; - - const completeDeviceFlowInBackground = async (challengeParam: AiEditionDeviceChallenge) => { - if (!active) return; - setBusy(true); - try { - const result = await nativeBridgeClient.aiEdition.llmCompleteDeviceAuth( - active.id as "openai-oauth" | "copilot-proxy", - challengeParam, - config?.model, - ); - if (result.success) { - const snap = await refreshSnapshot(); - onActiveProviderChanged?.(snap?.config?.provider ?? null); - toast.success(te("providerSettings.connected", { provider: active.label })); - setMode("list"); - setActive(null); - setChallenge(null); - } else { - setError(result.error ?? te("providerSettings.deviceFlowFailed")); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }; - - const startPatConnect = async () => { - if (!active || !apiKey.trim() || !config) return; - setBusy(true); - setError(null); - try { - await nativeBridgeClient.aiEdition.llmSetApiKey(active.id, apiKey.trim()); - await nativeBridgeClient.aiEdition.llmSetConfig(config); - const snap = await refreshSnapshot(); - onActiveProviderChanged?.(snap?.config?.provider ?? null); - toast.success(te("providerSettings.connected", { provider: active.label })); - setApiKey(""); - setMode("list"); - setActive(null); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }; - const disconnect = async () => { if (!active) return; setBusy(true); @@ -288,15 +192,10 @@ export function ProviderSettings({ config={config} setConfig={setConfig} busy={busy} - challenge={challenge} error={error} onBack={goBackToList} - onSave={active.authKind === "api-key" ? saveApiKey : startDeviceFlow} - onPatConnect={active.authKind === "pat" ? startPatConnect : undefined} + onSave={saveApiKey} onDisconnect={disconnect} - onOpenLoginPage={(uri) => - typeof window !== "undefined" && window.open(uri, "_blank", "noopener,noreferrer") - } listProviderModels={nativeBridgeClient.aiEdition.llmListProviderModels} /> ) : null} @@ -333,16 +232,6 @@ function ProviderList({ {te("providerSettings.pillConnected")} - ) : authKindPillKind(def.authKind) === "auth" ? ( - - - {te("providerSettings.pillOAuth")} - - ) : def.authKind === "pat" ? ( - - - {te("providerSettings.pillToken")} - ) : ( @@ -358,17 +247,6 @@ function ProviderList({ ); } -function authKindPillKind(kind: ProviderDefinition["authKind"]): "auth" | "pat" | "key" { - switch (kind) { - case "oauth-device": - return "auth"; - case "pat": - return "pat"; - case "api-key": - return "key"; - } -} - function KeyIcon() { // tiny single-stroke "••• " icon as a span, avoids pulling in another lucide dep. return ( @@ -395,13 +273,10 @@ function ProviderForm({ config, setConfig, busy, - challenge, error, onBack, onSave, - onPatConnect, onDisconnect, - onOpenLoginPage, listProviderModels, }: { def: ProviderDefinition; @@ -412,31 +287,22 @@ function ProviderForm({ config: AiEditionLlmConfig | null; setConfig: (c: AiEditionLlmConfig | null) => void; busy: boolean; - challenge: AiEditionDeviceChallenge | null; error: string | null; onBack: () => void; onSave: () => void; - onPatConnect?: () => void; onDisconnect: () => void; - onOpenLoginPage: (uri: string) => void; listProviderModels: (providerId: string) => Promise<{ models: string[]; error?: string }>; }) { const te = useScopedT("editor"); - const showApiKeyField = def.authKind === "api-key" || (def.authKind === "pat" && !isConnected); const showBaseUrl = def.id === "openai-compatible" || Boolean(def.baseUrl); - const isCodexOrCopilot = def.authKind === "oauth-device"; - // Every provider now exposes a live model list once connected (matching - // axcut's `screen === 'models' && activeProvider?.connected` gate) — API-key - // providers hit their own /models endpoint (or, for MiniMax, a probe call) - // same as OAuth/PAT providers do. - const supportsDynamicModels = true; - + // Every provider exposes a live model list once connected: each hits its own + // /models endpoint, or a probe call for MiniMax. const [modelOptions, setModelOptions] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); useEffect(() => { - if (!supportsDynamicModels || !isConnected) { + if (!isConnected) { setModelOptions([]); setModelsError(null); return; @@ -461,7 +327,7 @@ function ProviderForm({ return () => { cancelled = true; }; - }, [def.id, isConnected, supportsDynamicModels, listProviderModels]); + }, [def.id, isConnected, listProviderModels]); const modelSelectable = modelOptions.length > 0; @@ -500,7 +366,7 @@ function ProviderForm({ ? te("providerSettings.modelHintLive") : modelsError ? te("providerSettings.modelHintError", { error: modelsError }) - : supportsDynamicModels && isConnected + : isConnected ? te("providerSettings.modelHintLoading") : undefined } @@ -603,30 +469,18 @@ function ProviderForm({ ) : null} - {showApiKeyField ? ( - - setApiKey(e.target.value)} - disabled={busy} - /> - - ) : null} + + setApiKey(e.target.value)} + disabled={busy} + /> + - {challenge ? ( - - ) : null} {error ? (

@@ -704,26 +550,6 @@ function ProviderForm({ {busy ? : } {te("providerSettings.save")} - ) : isCodexOrCopilot ? ( - - ) : def.authKind === "pat" && onPatConnect ? ( - ) : ( - -

- - ); -} - function Field({ label, hint, diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index a9f931f15..9fb33a0ee 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -382,10 +382,7 @@ "saved": "تم حفظ {{provider}}", "connected": "تم الاتصال بـ {{provider}}", "disconnected": "تم قطع الاتصال بـ {{provider}}", - "deviceFlowFailed": "فشل تدفق مصادقة الجهاز.", "pillConnected": "متصل", - "pillOAuth": "OAuth", - "pillToken": "رمز مميز", "pillApiKey": "مفتاح API", "notConnected": "غير متصل", "back": "رجوع", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "مستوى الاستدلال", "apiKeyLabel": "مفتاح API", "apiKeyHintStored": "محفوظ في safeStorage. اتركه فارغًا للاحتفاظ بالإدخال الحالي.", - "patLabel": "رمز وصول شخصي من GitHub", - "patHint": "الصق رمز PAT من GitHub بنطاق `copilot`.", "projectEditsLabel": "تعديلات المشروع", "projectEditsHint": "عند الإيقاف، يجب أن يستأذن الوكيل قبل تغيير المخطط الزمني. يمكن دائمًا التراجع عن التعديلات.", "allowAgentEdits": "السماح للوكيل بتعديل المشروع", "disconnect": "قطع الاتصال", "cancel": "إلغاء", "save": "حفظ", - "saveAndUse": "حفظ واستخدام", - "reconnect": "إعادة الاتصال", - "startLogin": "بدء تسجيل الدخول", - "connect": "اتصال", - "completingSignIn": "جارٍ إتمام تسجيل الدخول…", - "browserLoginPending": "في انتظار تسجيل الدخول عبر المتصفح", - "deviceCodeInstructions": "افتح صفحة تسجيل الدخول الخاصة بـ {{provider}} وأدخل هذا الرمز. سنُكمل الاتصال تلقائيًا بمجرد منحك الإذن.", - "copyCode": "نسخ الرمز", - "copied": "تم النسخ", - "openLoginPage": "فتح صفحة تسجيل الدخول" + "saveAndUse": "حفظ واستخدام" } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 4e1c39a3b..71a4a4d50 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} saved", "connected": "{{provider}} connected", "disconnected": "{{provider}} disconnected", - "deviceFlowFailed": "Device flow failed.", "pillConnected": "Connected", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "API key", "notConnected": "Not connected", "back": "Back", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Reasoning effort", "apiKeyLabel": "API key", "apiKeyHintStored": "Stored in safeStorage. Leave blank to keep the existing entry.", - "patLabel": "GitHub personal access token", - "patHint": "Paste a GitHub PAT with `copilot` scope.", "projectEditsLabel": "Project edits", "projectEditsHint": "When off, the agent must ask before changing the timeline. Edits are always undoable.", "allowAgentEdits": "Allow the agent to edit the project", "disconnect": "Disconnect", "cancel": "Cancel", "save": "Save", - "saveAndUse": "Save & use", - "reconnect": "Reconnect", - "startLogin": "Start login", - "connect": "Connect", - "completingSignIn": "Completing sign-in…", - "browserLoginPending": "Browser login pending", - "deviceCodeInstructions": "Open the {{provider}} login page and enter this code. We'll finish connecting automatically once you authorize.", - "copyCode": "Copy code", - "copied": "Copied", - "openLoginPage": "Open login page" + "saveAndUse": "Save & use" } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index da5379322..531e25f94 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} guardado", "connected": "{{provider}} conectado", "disconnected": "{{provider}} desconectado", - "deviceFlowFailed": "Error en el flujo de dispositivo.", "pillConnected": "Conectado", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "Clave API", "notConnected": "No conectado", "back": "Atrás", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Esfuerzo de razonamiento", "apiKeyLabel": "Clave API", "apiKeyHintStored": "Guardada en safeStorage. Déjalo en blanco para conservar la entrada existente.", - "patLabel": "Token de acceso personal de GitHub", - "patHint": "Pega un PAT de GitHub con el ámbito `copilot`.", "projectEditsLabel": "Ediciones del proyecto", "projectEditsHint": "Si está desactivado, el agente debe preguntar antes de cambiar la línea de tiempo. Las ediciones siempre se pueden deshacer.", "allowAgentEdits": "Permitir que el agente edite el proyecto", "disconnect": "Desconectar", "cancel": "Cancelar", "save": "Guardar", - "saveAndUse": "Guardar y usar", - "reconnect": "Reconectar", - "startLogin": "Iniciar sesión", - "connect": "Conectar", - "completingSignIn": "Completando el inicio de sesión…", - "browserLoginPending": "Inicio de sesión en el navegador pendiente", - "deviceCodeInstructions": "Abre la página de inicio de sesión de {{provider}} e introduce este código. Terminaremos de conectar automáticamente en cuanto autorices.", - "copyCode": "Copiar código", - "copied": "Copiado", - "openLoginPage": "Abrir página de inicio de sesión" + "saveAndUse": "Guardar y usar" } } diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 1d3ea011f..917419d07 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} enregistré", "connected": "{{provider}} connecté", "disconnected": "{{provider}} déconnecté", - "deviceFlowFailed": "Échec de l'authentification par appareil.", "pillConnected": "Connecté", - "pillOAuth": "OAuth", - "pillToken": "Jeton", "pillApiKey": "Clé API", "notConnected": "Non connecté", "back": "Retour", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Effort de raisonnement", "apiKeyLabel": "Clé API", "apiKeyHintStored": "Stockée dans safeStorage. Laissez vide pour conserver l'entrée existante.", - "patLabel": "Jeton d'accès personnel GitHub", - "patHint": "Collez un PAT GitHub avec la portée `copilot`.", "projectEditsLabel": "Modifications du projet", "projectEditsHint": "Si désactivé, l'agent doit demander avant de modifier la timeline. Les modifications sont toujours annulables.", "allowAgentEdits": "Autoriser l'agent à modifier le projet", "disconnect": "Déconnecter", "cancel": "Annuler", "save": "Enregistrer", - "saveAndUse": "Enregistrer et utiliser", - "reconnect": "Reconnecter", - "startLogin": "Démarrer la connexion", - "connect": "Connecter", - "completingSignIn": "Finalisation de la connexion…", - "browserLoginPending": "Connexion navigateur en attente", - "deviceCodeInstructions": "Ouvrez la page de connexion {{provider}} et saisissez ce code. La connexion se terminera automatiquement dès que vous aurez autorisé l'accès.", - "copyCode": "Copier le code", - "copied": "Copié", - "openLoginPage": "Ouvrir la page de connexion" + "saveAndUse": "Enregistrer et utiliser" } } diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 079a0ef27..fe1680aed 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} salvato", "connected": "{{provider}} connesso", "disconnected": "{{provider}} disconnesso", - "deviceFlowFailed": "Flusso dispositivo non riuscito.", "pillConnected": "Connesso", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "Chiave API", "notConnected": "Non connesso", "back": "Indietro", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Sforzo di ragionamento", "apiKeyLabel": "Chiave API", "apiKeyHintStored": "Salvata in safeStorage. Lascia vuoto per mantenere la voce esistente.", - "patLabel": "Token di accesso personale GitHub", - "patHint": "Incolla un PAT GitHub con ambito `copilot`.", "projectEditsLabel": "Modifiche al progetto", "projectEditsHint": "Se disattivato, l'agente deve chiedere prima di cambiare la timeline. Le modifiche sono sempre annullabili.", "allowAgentEdits": "Consenti all'agente di modificare il progetto", "disconnect": "Disconnetti", "cancel": "Annulla", "save": "Salva", - "saveAndUse": "Salva e usa", - "reconnect": "Riconnetti", - "startLogin": "Avvia accesso", - "connect": "Connetti", - "completingSignIn": "Completamento accesso…", - "browserLoginPending": "Accesso dal browser in sospeso", - "deviceCodeInstructions": "Apri la pagina di accesso di {{provider}} e inserisci questo codice. Completeremo la connessione automaticamente non appena autorizzi.", - "copyCode": "Copia codice", - "copied": "Copiato", - "openLoginPage": "Apri pagina di accesso" + "saveAndUse": "Salva e usa" } } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 445e7f163..da5d61d18 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} を保存しました", "connected": "{{provider}} に接続しました", "disconnected": "{{provider}} の接続を解除しました", - "deviceFlowFailed": "デバイス認証に失敗しました。", "pillConnected": "接続済み", - "pillOAuth": "OAuth", - "pillToken": "トークン", "pillApiKey": "API キー", "notConnected": "未接続", "back": "戻る", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "推論の深さ", "apiKeyLabel": "API キー", "apiKeyHintStored": "safeStorage に保存されています。空欄のままにすると既存の値を保持します。", - "patLabel": "GitHub 個人用アクセストークン", - "patHint": "`copilot` スコープを持つ GitHub PAT を貼り付けてください。", "projectEditsLabel": "プロジェクトの編集", "projectEditsHint": "オフの場合、エージェントはタイムラインを変更する前に確認します。編集はいつでも元に戻せます。", "allowAgentEdits": "エージェントによるプロジェクトの編集を許可", "disconnect": "接続を解除", "cancel": "キャンセル", "save": "保存", - "saveAndUse": "保存して使用", - "reconnect": "再接続", - "startLogin": "ログインを開始", - "connect": "接続", - "completingSignIn": "サインインを完了しています…", - "browserLoginPending": "ブラウザーでのログイン待ちです", - "deviceCodeInstructions": "{{provider}} のログインページを開き、このコードを入力してください。承認が完了すると自動的に接続されます。", - "copyCode": "コードをコピー", - "copied": "コピーしました", - "openLoginPage": "ログインページを開く" + "saveAndUse": "保存して使用" } } diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 60012d55f..97c63ffa7 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} 저장됨", "connected": "{{provider}} 연결됨", "disconnected": "{{provider}} 연결 해제됨", - "deviceFlowFailed": "기기 인증에 실패했습니다.", "pillConnected": "연결됨", - "pillOAuth": "OAuth", - "pillToken": "토큰", "pillApiKey": "API 키", "notConnected": "연결되지 않음", "back": "뒤로", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "추론 강도", "apiKeyLabel": "API 키", "apiKeyHintStored": "safeStorage에 저장되어 있습니다. 기존 항목을 유지하려면 비워 두세요.", - "patLabel": "GitHub 개인 액세스 토큰", - "patHint": "`copilot` 범위를 가진 GitHub PAT를 붙여넣으세요.", "projectEditsLabel": "프로젝트 편집", "projectEditsHint": "끄면 에이전트가 타임라인을 변경하기 전에 확인을 요청합니다. 편집은 항상 실행 취소할 수 있습니다.", "allowAgentEdits": "에이전트의 프로젝트 편집 허용", "disconnect": "연결 해제", "cancel": "취소", "save": "저장", - "saveAndUse": "저장 후 사용", - "reconnect": "다시 연결", - "startLogin": "로그인 시작", - "connect": "연결", - "completingSignIn": "로그인 완료 중…", - "browserLoginPending": "브라우저 로그인 대기 중", - "deviceCodeInstructions": "{{provider}} 로그인 페이지를 열고 이 코드를 입력하세요. 승인하면 자동으로 연결이 완료됩니다.", - "copyCode": "코드 복사", - "copied": "복사됨", - "openLoginPage": "로그인 페이지 열기" + "saveAndUse": "저장 후 사용" } } diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 82534fac8..adf637eb0 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} salvo", "connected": "{{provider}} conectado", "disconnected": "{{provider}} desconectado", - "deviceFlowFailed": "Falha no fluxo de dispositivo.", "pillConnected": "Conectado", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "Chave de API", "notConnected": "Não conectado", "back": "Voltar", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Esforço de raciocínio", "apiKeyLabel": "Chave de API", "apiKeyHintStored": "Armazenada no safeStorage. Deixe em branco para manter a entrada existente.", - "patLabel": "Token de acesso pessoal do GitHub", - "patHint": "Cole um PAT do GitHub com o escopo `copilot`.", "projectEditsLabel": "Edições do projeto", "projectEditsHint": "Quando desativado, o agente precisa perguntar antes de alterar a linha do tempo. As edições sempre podem ser desfeitas.", "allowAgentEdits": "Permitir que o agente edite o projeto", "disconnect": "Desconectar", "cancel": "Cancelar", "save": "Salvar", - "saveAndUse": "Salvar e usar", - "reconnect": "Reconectar", - "startLogin": "Iniciar login", - "connect": "Conectar", - "completingSignIn": "Concluindo o login…", - "browserLoginPending": "Login no navegador pendente", - "deviceCodeInstructions": "Abra a página de login do {{provider}} e digite este código. Terminaremos de conectar automaticamente assim que você autorizar.", - "copyCode": "Copiar código", - "copied": "Copiado", - "openLoginPage": "Abrir página de login" + "saveAndUse": "Salvar e usar" } } diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 6e3c4199e..18de9b298 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} сохранён", "connected": "{{provider}} подключён", "disconnected": "{{provider}} отключён", - "deviceFlowFailed": "Не удалось выполнить вход через устройство.", "pillConnected": "Подключено", - "pillOAuth": "OAuth", - "pillToken": "Токен", "pillApiKey": "API-ключ", "notConnected": "Не подключено", "back": "Назад", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Глубина рассуждений", "apiKeyLabel": "API-ключ", "apiKeyHintStored": "Хранится в safeStorage. Оставьте пустым, чтобы сохранить текущее значение.", - "patLabel": "Персональный токен доступа GitHub", - "patHint": "Вставьте GitHub PAT с областью `copilot`.", "projectEditsLabel": "Правки проекта", "projectEditsHint": "Если выключено, агент должен спрашивать перед изменением таймлайна. Правки всегда можно отменить.", "allowAgentEdits": "Разрешить агенту редактировать проект", "disconnect": "Отключить", "cancel": "Отмена", "save": "Сохранить", - "saveAndUse": "Сохранить и использовать", - "reconnect": "Переподключить", - "startLogin": "Начать вход", - "connect": "Подключить", - "completingSignIn": "Завершаем вход…", - "browserLoginPending": "Ожидается вход через браузер", - "deviceCodeInstructions": "Откройте страницу входа {{provider}} и введите этот код. Как только вы подтвердите доступ, подключение завершится автоматически.", - "copyCode": "Скопировать код", - "copied": "Скопировано", - "openLoginPage": "Открыть страницу входа" + "saveAndUse": "Сохранить и использовать" } } diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index ba94a2f00..2aacd4b9b 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} kaydedildi", "connected": "{{provider}} bağlandı", "disconnected": "{{provider}} bağlantısı kesildi", - "deviceFlowFailed": "Cihaz akışı başarısız oldu.", "pillConnected": "Bağlı", - "pillOAuth": "OAuth", - "pillToken": "Belirteç", "pillApiKey": "API anahtarı", "notConnected": "Bağlı değil", "back": "Geri", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Akıl yürütme düzeyi", "apiKeyLabel": "API anahtarı", "apiKeyHintStored": "safeStorage'da saklanıyor. Mevcut kaydı korumak için boş bırakın.", - "patLabel": "GitHub kişisel erişim belirteci", - "patHint": "`copilot` kapsamına sahip bir GitHub PAT yapıştırın.", "projectEditsLabel": "Proje düzenlemeleri", "projectEditsHint": "Kapalıyken, aracı zaman çizelgesini değiştirmeden önce izin ister. Düzenlemeler her zaman geri alınabilir.", "allowAgentEdits": "Aracının projeyi düzenlemesine izin ver", "disconnect": "Bağlantıyı kes", "cancel": "İptal", "save": "Kaydet", - "saveAndUse": "Kaydet ve kullan", - "reconnect": "Yeniden bağlan", - "startLogin": "Girişi başlat", - "connect": "Bağlan", - "completingSignIn": "Oturum açma tamamlanıyor…", - "browserLoginPending": "Tarayıcı girişi bekleniyor", - "deviceCodeInstructions": "{{provider}} giriş sayfasını açın ve bu kodu girin. İzin verdiğinizde bağlantıyı otomatik olarak tamamlayacağız.", - "copyCode": "Kodu kopyala", - "copied": "Kopyalandı", - "openLoginPage": "Giriş sayfasını aç" + "saveAndUse": "Kaydet ve kullan" } } diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 1589b6258..f54dc9d15 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -382,10 +382,7 @@ "saved": "Đã lưu {{provider}}", "connected": "Đã kết nối {{provider}}", "disconnected": "Đã ngắt kết nối {{provider}}", - "deviceFlowFailed": "Luồng xác thực thiết bị thất bại.", "pillConnected": "Đã kết nối", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "Khóa API", "notConnected": "Chưa kết nối", "back": "Quay lại", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Mức độ suy luận", "apiKeyLabel": "Khóa API", "apiKeyHintStored": "Được lưu trong safeStorage. Để trống để giữ mục hiện có.", - "patLabel": "Mã truy cập cá nhân GitHub", - "patHint": "Dán một GitHub PAT có phạm vi `copilot`.", "projectEditsLabel": "Chỉnh sửa dự án", "projectEditsHint": "Khi tắt, tác nhân phải hỏi trước khi thay đổi dòng thời gian. Mọi chỉnh sửa đều có thể hoàn tác.", "allowAgentEdits": "Cho phép tác nhân chỉnh sửa dự án", "disconnect": "Ngắt kết nối", "cancel": "Hủy", "save": "Lưu", - "saveAndUse": "Lưu và dùng", - "reconnect": "Kết nối lại", - "startLogin": "Bắt đầu đăng nhập", - "connect": "Kết nối", - "completingSignIn": "Đang hoàn tất đăng nhập…", - "browserLoginPending": "Đang chờ đăng nhập trên trình duyệt", - "deviceCodeInstructions": "Mở trang đăng nhập {{provider}} và nhập mã này. Chúng tôi sẽ hoàn tất kết nối tự động ngay khi bạn cấp quyền.", - "copyCode": "Sao chép mã", - "copied": "Đã sao chép", - "openLoginPage": "Mở trang đăng nhập" + "saveAndUse": "Lưu và dùng" } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 82643672a..c9e3dde1b 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -382,10 +382,7 @@ "saved": "已保存 {{provider}}", "connected": "已连接 {{provider}}", "disconnected": "已断开 {{provider}}", - "deviceFlowFailed": "设备授权流程失败。", "pillConnected": "已连接", - "pillOAuth": "OAuth", - "pillToken": "令牌", "pillApiKey": "API 密钥", "notConnected": "未连接", "back": "返回", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "推理强度", "apiKeyLabel": "API 密钥", "apiKeyHintStored": "已存储在 safeStorage 中。留空可保留现有条目。", - "patLabel": "GitHub 个人访问令牌", - "patHint": "粘贴具有 `copilot` 权限范围的 GitHub PAT。", "projectEditsLabel": "项目编辑", "projectEditsHint": "关闭时,智能体在更改时间轴前必须先询问。所有编辑始终可以撤销。", "allowAgentEdits": "允许智能体编辑项目", "disconnect": "断开连接", "cancel": "取消", "save": "保存", - "saveAndUse": "保存并使用", - "reconnect": "重新连接", - "startLogin": "开始登录", - "connect": "连接", - "completingSignIn": "正在完成登录…", - "browserLoginPending": "等待浏览器登录", - "deviceCodeInstructions": "打开 {{provider}} 登录页面并输入此代码。您授权后,我们会自动完成连接。", - "copyCode": "复制代码", - "copied": "已复制", - "openLoginPage": "打开登录页面" + "saveAndUse": "保存并使用" } } diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index d8e0487a2..3fece046f 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -382,10 +382,7 @@ "saved": "已儲存 {{provider}}", "connected": "已連線 {{provider}}", "disconnected": "已中斷 {{provider}}", - "deviceFlowFailed": "裝置授權流程失敗。", "pillConnected": "已連線", - "pillOAuth": "OAuth", - "pillToken": "權杖", "pillApiKey": "API 金鑰", "notConnected": "未連線", "back": "返回", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "推理強度", "apiKeyLabel": "API 金鑰", "apiKeyHintStored": "已儲存在 safeStorage 中。留空可保留現有項目。", - "patLabel": "GitHub 個人存取權杖", - "patHint": "貼上具有 `copilot` 範圍的 GitHub PAT。", "projectEditsLabel": "專案編輯", "projectEditsHint": "關閉時,代理在變更時間軸前必須先詢問。所有編輯都可以復原。", "allowAgentEdits": "允許代理編輯專案", "disconnect": "中斷連線", "cancel": "取消", "save": "儲存", - "saveAndUse": "儲存並使用", - "reconnect": "重新連線", - "startLogin": "開始登入", - "connect": "連線", - "completingSignIn": "正在完成登入…", - "browserLoginPending": "等待瀏覽器登入", - "deviceCodeInstructions": "開啟 {{provider}} 登入頁面並輸入此代碼。您授權後,我們會自動完成連線。", - "copyCode": "複製代碼", - "copied": "已複製", - "openLoginPage": "開啟登入頁面" + "saveAndUse": "儲存並使用" } } diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index c86d512fe..c990df81c 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -424,14 +424,6 @@ function createShimBridgeClient() { saveLlmState(); return Promise.resolve({ success: true, snapshot: buildLlmSnapshot() }); }, - // ponytail: OAuth/PAT device flows need a real network round-trip to - // the provider — nothing meaningful to fake here. Reject with a clear - // message instead of silently returning undefined (which crashed - // callers expecting a challenge object). - llmBeginDeviceAuth: () => - Promise.reject(new Error("Device login isn't available in the browser preview.")), - llmCompleteDeviceAuth: () => - Promise.reject(new Error("Device login isn't available in the browser preview.")), llmListProviderModels: (providerId: string) => Promise.resolve({ models: [`${providerId}-demo-model-1`, `${providerId}-demo-model-2`], diff --git a/src/native/client.ts b/src/native/client.ts index a40a5100e..3f96495f6 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -8,8 +8,6 @@ import { type AiEditionChatRewindResult, type AiEditionChatSession, type AiEditionChatSessionSummary, - type AiEditionDeviceChallenge, - type AiEditionDeviceCompletionResult, type AiEditionDocumentResult, type AiEditionLlmConfig, type AiEditionLlmDisconnectResult, @@ -220,22 +218,6 @@ export const nativeBridgeClient = { action: "llm.removeApiKey", payload: { providerId }, }), - llmBeginDeviceAuth: (providerId: "openai-oauth" | "copilot-proxy", model?: string) => - requireNativeBridgeData({ - domain: "aiEdition", - action: "llm.beginDeviceAuth", - payload: { providerId, model }, - }), - llmCompleteDeviceAuth: ( - providerId: "openai-oauth" | "copilot-proxy", - challenge: AiEditionDeviceChallenge, - model?: string, - ) => - requireNativeBridgeData({ - domain: "aiEdition", - action: "llm.completeDeviceAuth", - payload: { providerId, challenge, model }, - }), llmDisconnect: (providerId: string) => requireNativeBridgeData({ domain: "aiEdition", diff --git a/src/native/contracts.ts b/src/native/contracts.ts index b2b66f8eb..5580984e5 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -206,22 +206,6 @@ export interface AiEditionLlmSnapshot { }>; } -export interface AiEditionDeviceChallenge { - verificationUri: string; - verificationUriComplete?: string; - userCode: string; - deviceCode?: string; - deviceAuthId?: string; - intervalMs: number; - expiresAt: number; -} - -export interface AiEditionDeviceCompletionResult { - success: boolean; - snapshot?: AiEditionLlmSnapshot; - error?: string; -} - export interface AiEditionLlmDisconnectResult { success: boolean; snapshot: AiEditionLlmSnapshot; @@ -529,26 +513,6 @@ export type NativeBridgeRequest = payload: { providerId: string }; requestId?: string; } - | { - domain: "aiEdition"; - action: "llm.beginDeviceAuth"; - payload: { - providerId: "openai-oauth" | "copilot-proxy"; - /** Optional model — recorded into the config when the device flow completes. */ - model?: string; - }; - requestId?: string; - } - | { - domain: "aiEdition"; - action: "llm.completeDeviceAuth"; - payload: { - providerId: "openai-oauth" | "copilot-proxy"; - challenge: AiEditionDeviceChallenge; - model?: string; - }; - requestId?: string; - } | { domain: "aiEdition"; action: "llm.disconnect"; diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts index f7ea7dd29..64e877b63 100644 --- a/src/native/useNativePlaybackSync.ts +++ b/src/native/useNativePlaybackSync.ts @@ -62,7 +62,6 @@ export function useNativePlaybackSync( const lastSyncedWallTimeRef = useRef(0); const lastActiveClipIdRef = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: ref values useEffect(() => { if (!active || sourceTimeSec === null || !activeClipId) { return; diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 000000000..654a9f6fa --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,23 @@ +// Typechecks what tsconfig.json deliberately leaves out. +// +// The main config is `include: ["src", "electron"]` with +// `exclude: ["**/*.test.ts", "**/*.test.tsx"]`, so no test file and nothing +// under tests/ ever reaches `tsc --noEmit` — and vitest transpiles without +// checking. A schema refactor on release/1.8.0 rewrote fixtures by hand and +// left a `preview.playbackRate` that was never in the schema; the typecheck +// gate it leaned on could not see the files it had edited. +// +// Kept separate from tsconfig.json rather than folded in: test files use +// vitest globals and `any`-ish mock shapes that would have to be loosened for +// the whole app if they shared one config. Same strictness, wider include. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // Vitest's `describe`/`it`/`expect` are ambient in test files only. + "types": ["vitest/globals", "node"], + // Mocks legitimately shadow parameters they never read. + "noUnusedParameters": false + }, + "include": ["src", "electron", "tests", "scripts"], + "exclude": [] +} diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index 5cc38b0b3..1b02713b7 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -182,7 +182,6 @@ export default function Home() {
{MINI_WAVEFORM_A.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static decorative bars ))}
@@ -190,7 +189,6 @@ export default function Home() {
{MINI_WAVEFORM_B.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static decorative bars ))}