mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix: preserve focus when changing voice auth
This commit is contained in:
1 parent
26f125c57c
commit
a79f58b1a4
6 files changed
+370
-22
No files matched your search
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider
|
||||||
|
Settings. API-key voice remains the default. Local sign-in can start from
|
||||||
|
the auth button and reports completion, failure, timeout, or expired
|
||||||
|
credentials. OAuth credentials stay on the server; local routes require
|
||||||
|
loopback access and same-site requests. OAuth sessions retain usage data
|
||||||
|
and show unknown cost. The auth button keeps keyboard focus after a mode
|
||||||
|
change, and pending sign-in does not discard text in the key fields.
|
||||||
|
|
||||||
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
||||||
list is one keyless GeoJSON request to the agency's OGC API view of its
|
list is one keyless GeoJSON request to the agency's OGC API view of its
|
||||||
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
||||||
|
|||||||
@@ -3925,6 +3925,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
|
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
|
||||||
- **Degradation**: API-key mode without `OPENAI_API_KEY` returns 503 as before. Selecting OAuth without a usable local ChatGPT/Codex sign-in starts the local `codex login` browser flow, polls for the resulting local token, and selects OAuth when sign-in completes. If the Codex login flow cannot start or does not complete, the rest of the app is unaffected.
|
- **Degradation**: API-key mode without `OPENAI_API_KEY` returns 503 as before. Selecting OAuth without a usable local ChatGPT/Codex sign-in starts the local `codex login` browser flow, polls for the resulting local token, and selects OAuth when sign-in completes. If the Codex login flow cannot start or does not complete, the rest of the app is unaffected.
|
||||||
- **Login lifecycle and storage**: one pending Codex login is shared by the login and status routes. Process failure, success without readable credentials, and a two-minute timeout each report a clear failure; timeout and server shutdown cancel the owned process. The launcher resolves `CODEX_HOME` before changing directories and refuses a mismatched `CODEX_AUTH_JSON`. GEV reads file credentials only, never writes or refreshes them, and does not require the vendor refresh token. Expired access tokens report `CODEX_OAUTH_REAUTH_REQUIRED`. OAuth status and login routes also use the same-site request gate.
|
- **Login lifecycle and storage**: one pending Codex login is shared by the login and status routes. Process failure, success without readable credentials, and a two-minute timeout each report a clear failure; timeout and server shutdown cancel the owned process. The launcher resolves `CODEX_HOME` before changing directories and refuses a mismatched `CODEX_AUTH_JSON`. GEV reads file credentials only, never writes or refreshes them, and does not require the vendor refresh token. Expired access tokens report `CODEX_OAUTH_REAUTH_REQUIRED`. OAuth status and login routes also use the same-site request gate.
|
||||||
|
- **Auth button focus**: changing voice auth updates the existing controls in place. Enter, Space, and mouse selection keep focus on the auth button; Tab then follows the normal control order. Pending sign-in blocks repeated activation without removing the button from the focus order. Completion does not take focus from another control or discard text in the key fields. `scripts/qa-voice-auth-focus.mjs` checks these paths in Chromium with fixture sign-in responses.
|
||||||
- **Support boundary**: the local OAuth option is experimental. OpenAI's documented Sign in with ChatGPT plan-sharing inference endpoint is `/v1/responses`; support for third-party reuse of Codex credentials at `/v1/realtime/client_secrets` remains unconfirmed. Technical success does not establish a supported auth or billing contract.
|
- **Support boundary**: the local OAuth option is experimental. OpenAI's documented Sign in with ChatGPT plan-sharing inference endpoint is `/v1/responses`; support for third-party reuse of Codex credentials at `/v1/realtime/client_secrets` remains unconfirmed. Technical success does not establish a supported auth or billing contract.
|
||||||
|
|
||||||
### AI HUD Summary (June 2026)
|
### AI HUD Summary (June 2026)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
"src/main.js",
|
"src/main.js",
|
||||||
"docs/APPLICATION.md",
|
"docs/APPLICATION.md",
|
||||||
"scripts/qa-application.mjs",
|
"scripts/qa-application.mjs",
|
||||||
|
"scripts/qa-voice-auth.mjs",
|
||||||
|
"scripts/qa-voice-auth-focus.mjs",
|
||||||
|
"src/codexOauthRealtime.test.mjs",
|
||||||
|
"src/voice/cloudVoiceAuth.test.mjs",
|
||||||
"scripts/qa-terrain-429.mjs",
|
"scripts/qa-terrain-429.mjs",
|
||||||
"src/app/application.js",
|
"src/app/application.js",
|
||||||
"src/app/application.test.mjs",
|
"src/app/application.test.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Check auth-mode keyboard focus with the real settings template and controls.
|
||||||
|
* Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs
|
||||||
|
* Sign-in responses are fixtures. No credentials or live login are used.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const root = new URL('../', import.meta.url);
|
||||||
|
const template = await readFile(
|
||||||
|
new URL('src/ui/templates/provider-settings.html', root),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const assets = new Set([
|
||||||
|
'/src/keySetup.js',
|
||||||
|
'/src/voice/cloudVoiceAuth.js',
|
||||||
|
'/src/ui/surfaceKeyboard.js',
|
||||||
|
'/src/ui/styles/provider-settings.css',
|
||||||
|
]);
|
||||||
|
const observations = [];
|
||||||
|
let server;
|
||||||
|
let browser;
|
||||||
|
let origin;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = createServer(async (req, res) => {
|
||||||
|
const pathname = new URL(req.url, 'http://localhost').pathname;
|
||||||
|
if (pathname === '/') {
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.end(`<!doctype html><html><head><meta charset="utf-8">
|
||||||
|
<title>Voice auth keyboard focus test</title>
|
||||||
|
<link rel="stylesheet" href="/src/ui/styles/provider-settings.css">
|
||||||
|
</head><body>${template}</body></html>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!assets.has(pathname)) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Type',
|
||||||
|
pathname.endsWith('.css') ? 'text/css' : 'text/javascript',
|
||||||
|
);
|
||||||
|
res.end(await readFile(new URL(pathname.slice(1), root)));
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('Could not load the test asset');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
origin = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath:
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||||
|
(existsSync(chrome) ? chrome : await puppeteer.executablePath()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
if (server?.listening) await new Promise((resolve) => server.close(resolve));
|
||||||
|
const output = new URL('.gev-logs/voice-auth-focus/', root);
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
new URL('results.json', output),
|
||||||
|
`${JSON.stringify(observations, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
name: 'OAuth to API key',
|
||||||
|
initial: 'oauth',
|
||||||
|
expected: 'api-key',
|
||||||
|
flow: 'unused',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API key to existing OAuth',
|
||||||
|
initial: 'api-key',
|
||||||
|
expected: 'oauth',
|
||||||
|
flow: 'existing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API key to OAuth ready at login launch',
|
||||||
|
initial: 'api-key',
|
||||||
|
expected: 'oauth',
|
||||||
|
flow: 'login-ready',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API key to OAuth ready after polling',
|
||||||
|
initial: 'api-key',
|
||||||
|
expected: 'oauth',
|
||||||
|
flow: 'poll-ready',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function mountSettings(page, scenario) {
|
||||||
|
await page.goto(`${origin}/?setup=1`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
await page.evaluate(async ({ initial, flow }) => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem('godsEyeView.voice.cloudAuth', initial);
|
||||||
|
window.__focusTestCalls = [];
|
||||||
|
let statusReads = 0;
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
window.__focusTestCalls.push(url);
|
||||||
|
let payload;
|
||||||
|
let status = 200;
|
||||||
|
if (url === '/api/setup/status') {
|
||||||
|
payload = {
|
||||||
|
setCount: 0,
|
||||||
|
total: 1,
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
id: 'openai',
|
||||||
|
title: 'OPENAI',
|
||||||
|
tier: 'metered',
|
||||||
|
set: false,
|
||||||
|
unlocks: 'Voice control',
|
||||||
|
envVars: ['OPENAI_API_KEY'],
|
||||||
|
getUrl: 'https://example.invalid/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else if (url === '/api/realtime/oauth-status') {
|
||||||
|
statusReads += 1;
|
||||||
|
if (flow === 'deferred') {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
window.__resolveOAuthStatus = (available) =>
|
||||||
|
resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ available, error: 'Fixture failure' }),
|
||||||
|
{
|
||||||
|
status: available ? 200 : 503,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
available:
|
||||||
|
flow === 'existing' || (flow === 'poll-ready' && statusReads > 1),
|
||||||
|
};
|
||||||
|
} else if (url === '/api/realtime/oauth-login') {
|
||||||
|
const available = flow === 'login-ready';
|
||||||
|
status = available ? 200 : 202;
|
||||||
|
payload = { available, pending: !available, started: !available };
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unexpected request: ${url}`);
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(payload), { status });
|
||||||
|
};
|
||||||
|
const { initKeySetup } = await import('/src/keySetup.js');
|
||||||
|
window.__focusTestSettings = await initKeySetup({ fetchImpl });
|
||||||
|
}, scenario);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.getElementById('key-setup').classList.contains('visible') &&
|
||||||
|
document.activeElement.matches('input[data-env-var]'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
for (const key of ['Enter', 'Space', 'Mouse click']) {
|
||||||
|
test(`${scenario.name} keeps keyboard focus after ${key}`, async (t) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await mountSettings(page, scenario);
|
||||||
|
|
||||||
|
if (key !== 'Mouse click') {
|
||||||
|
// Tab from the initial key field to the real auth button.
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.activeElement.matches('[data-cloud-voice-auth-toggle]'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
'Keyboard navigation must reach the auth toggle before activation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const toggle = document.querySelector(
|
||||||
|
'[data-cloud-voice-auth-toggle]',
|
||||||
|
);
|
||||||
|
window.__originalFocusToggle = toggle;
|
||||||
|
window.__focusAtActivation = null;
|
||||||
|
toggle.addEventListener(
|
||||||
|
'click',
|
||||||
|
() => {
|
||||||
|
window.__focusAtActivation = document.activeElement === toggle;
|
||||||
|
},
|
||||||
|
{ capture: true, once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (key === 'Mouse click') {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.getElementById('key-setup')).opacity ===
|
||||||
|
'1',
|
||||||
|
);
|
||||||
|
await page.click('[data-cloud-voice-auth-toggle]');
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press(key);
|
||||||
|
}
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) =>
|
||||||
|
localStorage.getItem('godsEyeView.voice.cloudAuth') === expected &&
|
||||||
|
!document.querySelector('[data-cloud-voice-auth-toggle]')
|
||||||
|
.disabled &&
|
||||||
|
document
|
||||||
|
.querySelector('[data-cloud-voice-auth-toggle]')
|
||||||
|
.getAttribute('aria-disabled') !== 'true',
|
||||||
|
{},
|
||||||
|
scenario.expected,
|
||||||
|
);
|
||||||
|
const selection = await page.evaluate(() => ({
|
||||||
|
focusOnToggleAtActivation: window.__focusAtActivation,
|
||||||
|
focusOnToggle:
|
||||||
|
document.activeElement ===
|
||||||
|
document.querySelector('[data-cloud-voice-auth-toggle]'),
|
||||||
|
activeTag: document.activeElement.tagName,
|
||||||
|
originalToggleConnected: window.__originalFocusToggle.isConnected,
|
||||||
|
requests: window.__focusTestCalls,
|
||||||
|
}));
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
const nextTab = await page.evaluate(() => ({
|
||||||
|
focusOnSave: document.activeElement.matches('[data-key-setup-apply]'),
|
||||||
|
activeLabel:
|
||||||
|
document.activeElement.getAttribute('aria-label') ||
|
||||||
|
document.activeElement.textContent.trim(),
|
||||||
|
}));
|
||||||
|
const observed = { scenario: scenario.name, key, selection, nextTab };
|
||||||
|
observations.push(observed);
|
||||||
|
t.diagnostic(JSON.stringify(observed));
|
||||||
|
assert.equal(
|
||||||
|
selection.focusOnToggle,
|
||||||
|
true,
|
||||||
|
'Auth selection must keep focus on its usable toggle',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
nextTab.focusOnSave,
|
||||||
|
true,
|
||||||
|
'Tab must continue to SAVE KEYS rather than restart at Close',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const available of [true, false]) {
|
||||||
|
test(`pending OAuth ${available ? 'success' : 'failure'} blocks repeats without moving focus or losing a key draft`, async () => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await mountSettings(page, { initial: 'api-key', flow: 'deferred' });
|
||||||
|
await page.type('input[data-env-var]', 'test-draft-only');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof window.__resolveOAuthStatus === 'function',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.activeElement.matches('[data-cloud-voice-auth-toggle]'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__focusTestCalls.filter(
|
||||||
|
(url) => url === '/api/realtime/oauth-status',
|
||||||
|
).length,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.activeElement.matches('[data-key-setup-apply]'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.evaluate(
|
||||||
|
(available) => window.__resolveOAuthStatus(available),
|
||||||
|
available,
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-cloud-voice-auth-toggle]')
|
||||||
|
.getAttribute('aria-disabled') === 'false',
|
||||||
|
);
|
||||||
|
const observed = await page.evaluate(() => ({
|
||||||
|
mode: localStorage.getItem('godsEyeView.voice.cloudAuth'),
|
||||||
|
focusOnSave: document.activeElement.matches('[data-key-setup-apply]'),
|
||||||
|
draft: document.querySelector('input[data-env-var]').value,
|
||||||
|
requests: window.__focusTestCalls,
|
||||||
|
}));
|
||||||
|
observations.push({ scenario: 'pending OAuth', available, observed });
|
||||||
|
assert.equal(observed.mode, available ? 'oauth' : 'api-key');
|
||||||
|
assert.equal(
|
||||||
|
observed.focusOnSave,
|
||||||
|
true,
|
||||||
|
'Completing a request must not take focus from another control',
|
||||||
|
);
|
||||||
|
assert.equal(observed.draft, 'test-draft-only');
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+28
-21
@@ -111,6 +111,21 @@ export function stripKeylessBasemapFromHash(hash) {
|
|||||||
|
|
||||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||||
|
|
||||||
|
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||||
|
const mode = readStoredCloudVoiceAuthMode();
|
||||||
|
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||||
|
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||||
|
state.textContent =
|
||||||
|
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
||||||
|
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
|
||||||
|
toggle.title =
|
||||||
|
mode === 'oauth'
|
||||||
|
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
||||||
|
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
|
||||||
|
// Keep the control focusable while the delegated handler blocks repeats.
|
||||||
|
toggle.setAttribute('aria-disabled', String(busy));
|
||||||
|
}
|
||||||
|
|
||||||
/** Build one key row. All content is our own registry text, set via textContent. */
|
/** Build one key row. All content is our own registry text, set via textContent. */
|
||||||
function buildRow(documentRef, key) {
|
function buildRow(documentRef, key) {
|
||||||
const row = documentRef.createElement('section');
|
const row = documentRef.createElement('section');
|
||||||
@@ -197,21 +212,14 @@ function buildRow(documentRef, key) {
|
|||||||
if (key.id === 'openai') {
|
if (key.id === 'openai') {
|
||||||
const auth = documentRef.createElement('div');
|
const auth = documentRef.createElement('div');
|
||||||
auth.className = 'key-setup-cloud-auth';
|
auth.className = 'key-setup-cloud-auth';
|
||||||
const mode = readStoredCloudVoiceAuthMode();
|
|
||||||
const state = documentRef.createElement('span');
|
const state = documentRef.createElement('span');
|
||||||
state.className = 'key-setup-cloud-auth-state';
|
state.className = 'key-setup-cloud-auth-state';
|
||||||
state.textContent =
|
|
||||||
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
|
||||||
const toggle = documentRef.createElement('button');
|
const toggle = documentRef.createElement('button');
|
||||||
toggle.type = 'button';
|
toggle.type = 'button';
|
||||||
toggle.className = 'key-setup-cloud-auth-toggle';
|
toggle.className = 'key-setup-cloud-auth-toggle';
|
||||||
toggle.dataset.cloudVoiceAuthToggle = 'true';
|
toggle.dataset.cloudVoiceAuthToggle = 'true';
|
||||||
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
|
|
||||||
toggle.title =
|
|
||||||
mode === 'oauth'
|
|
||||||
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
|
||||||
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
|
|
||||||
auth.append(state, toggle);
|
auth.append(state, toggle);
|
||||||
|
updateCloudVoiceAuthControl(auth);
|
||||||
row.append(auth);
|
row.append(auth);
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
@@ -297,6 +305,11 @@ export async function initKeySetup({
|
|||||||
let oauthBusy = false;
|
let oauthBusy = false;
|
||||||
let open = false;
|
let open = false;
|
||||||
|
|
||||||
|
const syncCloudVoiceAuth = () => {
|
||||||
|
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
|
||||||
|
updateCloudVoiceAuthControl(auth, oauthBusy);
|
||||||
|
};
|
||||||
|
|
||||||
const render = (nextStatus) => {
|
const render = (nextStatus) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
status = nextStatus;
|
status = nextStatus;
|
||||||
@@ -310,10 +323,7 @@ export async function initKeySetup({
|
|||||||
rowsHost.textContent = '';
|
rowsHost.textContent = '';
|
||||||
for (const key of status.keys || [])
|
for (const key of status.keys || [])
|
||||||
rowsHost.append(buildRow(documentRef, key));
|
rowsHost.append(buildRow(documentRef, key));
|
||||||
for (const button of rowsHost.querySelectorAll(
|
syncCloudVoiceAuth();
|
||||||
'[data-cloud-voice-auth-toggle]',
|
|
||||||
))
|
|
||||||
button.disabled = oauthBusy;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const visible = () =>
|
const visible = () =>
|
||||||
@@ -451,12 +461,12 @@ export async function initKeySetup({
|
|||||||
const current = readStoredCloudVoiceAuthMode();
|
const current = readStoredCloudVoiceAuthMode();
|
||||||
if (current === 'oauth') {
|
if (current === 'oauth') {
|
||||||
writeStoredCloudVoiceAuthMode('api-key');
|
writeStoredCloudVoiceAuthMode('api-key');
|
||||||
render(status);
|
syncCloudVoiceAuth();
|
||||||
say('Cloud voice will use OPENAI_API_KEY on the next session.');
|
say('Cloud voice will use OPENAI_API_KEY on the next session.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
authButton.disabled = true;
|
|
||||||
oauthBusy = true;
|
oauthBusy = true;
|
||||||
|
syncCloudVoiceAuth();
|
||||||
say('Checking local ChatGPT OAuth sign-in…');
|
say('Checking local ChatGPT OAuth sign-in…');
|
||||||
try {
|
try {
|
||||||
const response = await doFetch('/api/realtime/oauth-status', {
|
const response = await doFetch('/api/realtime/oauth-status', {
|
||||||
@@ -471,7 +481,7 @@ export async function initKeySetup({
|
|||||||
);
|
);
|
||||||
if (payload.available) {
|
if (payload.available) {
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
render(status);
|
syncCloudVoiceAuth();
|
||||||
say(
|
say(
|
||||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||||
);
|
);
|
||||||
@@ -500,7 +510,7 @@ export async function initKeySetup({
|
|||||||
}
|
}
|
||||||
if (loginPayload.available) {
|
if (loginPayload.available) {
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
render(status);
|
syncCloudVoiceAuth();
|
||||||
say(
|
say(
|
||||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||||
);
|
);
|
||||||
@@ -522,7 +532,7 @@ export async function initKeySetup({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
render(status);
|
syncCloudVoiceAuth();
|
||||||
say(
|
say(
|
||||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||||
);
|
);
|
||||||
@@ -531,10 +541,7 @@ export async function initKeySetup({
|
|||||||
say(`OAuth check failed: ${error?.message || error}`);
|
say(`OAuth check failed: ${error?.message || error}`);
|
||||||
} finally {
|
} finally {
|
||||||
oauthBusy = false;
|
oauthBusy = false;
|
||||||
for (const button of rowsHost.querySelectorAll(
|
if (!disposed) syncCloudVoiceAuth();
|
||||||
'[data-cloud-voice-auth-toggle]',
|
|
||||||
))
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -332,7 +332,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-setup-cloud-auth-toggle:disabled {
|
.key-setup-cloud-auth-toggle[aria-disabled='true'] {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user