Refactor proxyFetch and enhance MediaProviderDetailPage layout
- Removed the isCloud check from proxyFetch.js, simplifying the fetch patching logic. - Updated MediaProviderDetailPage to include a new section for API key retrieval, improving user experience with clearer layout and additional notice text. - Enhanced ConnectionRow to better handle email display names. - Improved ProviderDetailPage to conditionally render provider notices and API key links. - Refactored localDb, requestDetailsDb, and usageDb to remove unnecessary isCloud checks, streamlining database interactions. - Updated OAuthModal to combine waiting and manual input steps for a more cohesive user flow. - Added API key URLs to several providers in providers.js for better accessibility.
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import { Readable } from "stream";
|
||||
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
|
||||
|
||||
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const proxyDispatchers = new Map();
|
||||
|
||||
@@ -263,8 +261,8 @@ async function patchedFetch(url, options = {}) {
|
||||
}
|
||||
|
||||
// Idempotency guard — only patch once to avoid wrapping multiple times
|
||||
if (!isCloud && globalThis.fetch !== patchedFetch) {
|
||||
if (globalThis.fetch !== patchedFetch) {
|
||||
globalThis.fetch = patchedFetch;
|
||||
}
|
||||
|
||||
export default isCloud ? originalFetch : patchedFetch;
|
||||
export default patchedFetch;
|
||||
|
||||
@@ -1408,7 +1408,20 @@ export default function MediaProviderDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
|
||||
{!isCustom && provider.notice?.apiKeyUrl && (
|
||||
<a
|
||||
href={provider.notice.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">open_in_new</span>
|
||||
Get API Key
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
{isCustom && <Badge variant="default" size="sm">Custom · {customNode?.prefix}</Badge>}
|
||||
{kinds.map((k) => (
|
||||
@@ -1439,6 +1452,24 @@ export default function MediaProviderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider notice text (only when there's actual text content) */}
|
||||
{!isCustom && provider.notice?.text && !provider.deprecated && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2 sm:flex-row sm:items-center">
|
||||
<span className="material-symbols-outlined text-[16px] text-blue-500 shrink-0">info</span>
|
||||
<p className="min-w-0 flex-1 text-xs leading-relaxed text-blue-600 dark:text-blue-400">{provider.notice.text}</p>
|
||||
{provider.notice.apiKeyUrl && (
|
||||
<a
|
||||
href={provider.notice.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex justify-center rounded bg-blue-500 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-600 sm:py-0.5"
|
||||
>
|
||||
Get API Key →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections */}
|
||||
{!isCustom && provider.noAuth ? (
|
||||
<NoAuthProxyCard providerId={id} />
|
||||
|
||||
@@ -65,8 +65,9 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
}
|
||||
};
|
||||
|
||||
const isEmail = (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
|
||||
const displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||
? (isEmail(connection.email) ? connection.email : (isEmail(connection.name) ? connection.name : (connection.name || connection.email || connection.displayName || "OAuth Account")))
|
||||
: connection.name;
|
||||
|
||||
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
||||
|
||||
@@ -760,7 +760,20 @@ export default function ProviderDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight sm:text-3xl">{providerInfo.name}</h1>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight sm:text-3xl">{providerInfo.name}</h1>
|
||||
{providerInfo.notice?.apiKeyUrl && !providerInfo.deprecated && (
|
||||
<a
|
||||
href={providerInfo.notice.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">open_in_new</span>
|
||||
Get API Key
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted">
|
||||
{connections.length} connection{connections.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
@@ -775,7 +788,7 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providerInfo.notice && !providerInfo.deprecated && (
|
||||
{providerInfo.notice?.text && !providerInfo.deprecated && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2 sm:flex-row sm:items-center">
|
||||
<span className="material-symbols-outlined text-[16px] text-blue-500 shrink-0">info</span>
|
||||
<p className="min-w-0 flex-1 text-xs leading-relaxed text-blue-600 dark:text-blue-400">{providerInfo.notice.text}</p>
|
||||
|
||||
+3
-18
@@ -7,10 +7,9 @@ import lockfile from "proper-lockfile";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
|
||||
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
|
||||
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json");
|
||||
const DB_FILE = path.join(DATA_DIR, "db.json");
|
||||
|
||||
if (!isCloud && !fs.existsSync(DATA_DIR)) {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -57,7 +56,7 @@ function cloneDefaultData() {
|
||||
};
|
||||
}
|
||||
|
||||
if (!isCloud && DB_FILE && !fs.existsSync(DB_FILE)) {
|
||||
if (!fs.existsSync(DB_FILE)) {
|
||||
fs.writeFileSync(DB_FILE, JSON.stringify(cloneDefaultData(), null, 2));
|
||||
}
|
||||
|
||||
@@ -144,11 +143,6 @@ class LocalMutex {
|
||||
const localMutex = new LocalMutex();
|
||||
|
||||
async function withFileLock(db, operation) {
|
||||
if (isCloud) {
|
||||
await operation();
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseLocal = await localMutex.acquire();
|
||||
let release = null;
|
||||
try {
|
||||
@@ -176,15 +170,6 @@ async function safeWrite(db) {
|
||||
}
|
||||
|
||||
export async function getDb() {
|
||||
if (isCloud) {
|
||||
if (!dbInstance) {
|
||||
const data = cloneDefaultData();
|
||||
dbInstance = new Low({ read: async () => { }, write: async () => { } }, data);
|
||||
dbInstance.data = data;
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
if (!dbInstance) {
|
||||
dbInstance = new Low(new JSONFile(DB_FILE), cloneDefaultData());
|
||||
}
|
||||
|
||||
@@ -4,24 +4,21 @@ import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
||||
|
||||
const DEFAULT_MAX_RECORDS = 200;
|
||||
const DEFAULT_BATCH_SIZE = 20;
|
||||
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
|
||||
const DEFAULT_MAX_JSON_SIZE = 5 * 1024; // 5KB default, configurable via settings
|
||||
const CONFIG_CACHE_TTL_MS = 5000;
|
||||
const MAX_TOTAL_DB_SIZE = 50 * 1024 * 1024; // 50MB hard limit for total DB file
|
||||
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "request-details.json");
|
||||
const DB_FILE = path.join(DATA_DIR, "request-details.json");
|
||||
|
||||
if (!isCloud && !fs.existsSync(DATA_DIR)) {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
let dbInstance = null;
|
||||
|
||||
async function getDb() {
|
||||
if (isCloud) return null;
|
||||
if (!dbInstance) {
|
||||
const adapter = new JSONFile(DB_FILE);
|
||||
const db = new Low(adapter, { records: [] });
|
||||
@@ -107,7 +104,7 @@ function generateDetailId(model) {
|
||||
}
|
||||
|
||||
async function flushToDatabase() {
|
||||
if (isCloud || isFlushing || writeBuffer.length === 0) return;
|
||||
if (isFlushing || writeBuffer.length === 0) return;
|
||||
|
||||
isFlushing = true;
|
||||
try {
|
||||
@@ -178,8 +175,6 @@ async function flushToDatabase() {
|
||||
}
|
||||
|
||||
export async function saveRequestDetail(detail) {
|
||||
if (isCloud) return;
|
||||
|
||||
const config = await getObservabilityConfig();
|
||||
if (!config.enabled) return;
|
||||
|
||||
@@ -197,10 +192,6 @@ export async function saveRequestDetail(detail) {
|
||||
}
|
||||
|
||||
export async function getRequestDetails(filter = {}) {
|
||||
if (isCloud) {
|
||||
return { details: [], pagination: { page: 1, pageSize: 50, totalItems: 0, totalPages: 0, hasNext: false, hasPrev: false } };
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
let records = [...db.data.records];
|
||||
|
||||
@@ -228,8 +219,6 @@ export async function getRequestDetails(filter = {}) {
|
||||
}
|
||||
|
||||
export async function getRequestDetailById(id) {
|
||||
if (isCloud) return null;
|
||||
|
||||
const db = await getDb();
|
||||
return db.data.records.find(r => r.id === id) || null;
|
||||
}
|
||||
@@ -241,8 +230,6 @@ const _shutdownHandler = async () => {
|
||||
};
|
||||
|
||||
function ensureShutdownHandler() {
|
||||
if (isCloud) return;
|
||||
|
||||
// Remove any previously registered listeners from this module (hot-reload safety)
|
||||
process.off("beforeExit", _shutdownHandler);
|
||||
process.off("SIGINT", _shutdownHandler);
|
||||
|
||||
+3
-19
@@ -5,12 +5,11 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
|
||||
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "usage.json");
|
||||
const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt");
|
||||
const DB_FILE = path.join(DATA_DIR, "usage.json");
|
||||
const LOG_FILE = path.join(DATA_DIR, "log.txt");
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!isCloud && fs && typeof fs.existsSync === "function") {
|
||||
if (fs && typeof fs.existsSync === "function") {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -231,15 +230,6 @@ export async function getActiveRequests() {
|
||||
* Get usage database instance (singleton)
|
||||
*/
|
||||
export async function getUsageDb() {
|
||||
if (isCloud) {
|
||||
// Return in-memory DB for Workers
|
||||
if (!dbInstance) {
|
||||
dbInstance = new Low({ read: async () => {}, write: async () => {} }, defaultData);
|
||||
dbInstance.data = defaultData;
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
if (!dbInstance) {
|
||||
const adapter = new JSONFile(DB_FILE);
|
||||
dbInstance = new Low(adapter, defaultData);
|
||||
@@ -279,8 +269,6 @@ export async function getUsageDb() {
|
||||
* @param {object} entry - Usage entry { provider, model, tokens: { prompt_tokens, completion_tokens, ... }, connectionId?, apiKey? }
|
||||
*/
|
||||
export async function saveRequestUsage(entry) {
|
||||
if (isCloud) return; // Skip saving in Workers
|
||||
|
||||
try {
|
||||
const db = await getUsageDb();
|
||||
|
||||
@@ -366,8 +354,6 @@ function formatLogDate(date = new Date()) {
|
||||
* Format: datetime(dd-mm-yyyy h:m:s) | model | provider | account | tokens sent | tokens received | status
|
||||
*/
|
||||
export async function appendRequestLog({ model, provider, connectionId, tokens, status }) {
|
||||
if (isCloud) return; // Skip logging in Workers
|
||||
|
||||
try {
|
||||
const timestamp = formatLogDate();
|
||||
const p = provider?.toUpperCase() || "-";
|
||||
@@ -406,8 +392,6 @@ export async function appendRequestLog({ model, provider, connectionId, tokens,
|
||||
* Get last N lines of log.txt
|
||||
*/
|
||||
export async function getRecentLogs(limit = 200) {
|
||||
if (isCloud) return []; // Skip in Workers
|
||||
|
||||
// Runtime check: ensure fs module is available
|
||||
if (!fs || typeof fs.existsSync !== "function") {
|
||||
console.error("[usageDb] fs module not available in this environment");
|
||||
|
||||
@@ -362,22 +362,59 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={handleClose} size="lg">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Waiting Step (Localhost - popup mode) */}
|
||||
{step === "waiting" && !isDeviceCode && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
{/* Waiting + Manual Input combined (non-device-code) */}
|
||||
{(step === "waiting" || step === "input") && !isDeviceCode && (
|
||||
<>
|
||||
{/* Option A: Auto via popup */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">
|
||||
<span className="material-symbols-outlined text-base text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
<span className="text-sm">Waiting for popup authorization…</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Waiting for Authorization</h3>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Complete the authorization in the popup window.
|
||||
</p>
|
||||
<Button variant="ghost" onClick={() => setStep("input")}>
|
||||
Popup blocked? Enter URL manually
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-1">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-text-muted uppercase tracking-wider">Or paste callback URL manually</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Option B: Manual paste */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
|
||||
<div className="flex gap-2">
|
||||
<Input value={authData?.authUrl || ""} readOnly className="flex-1 font-mono text-xs" />
|
||||
<Button variant="secondary" icon={copied === "auth_url" ? "check" : "content_copy"} onClick={() => copy(authData?.authUrl, "auth_url")} disabled={!authData?.authUrl}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
After authorization, copy the full URL from your browser.
|
||||
</p>
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={placeholderUrl}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
|
||||
Connect
|
||||
</Button>
|
||||
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Device Code Flow - Waiting */}
|
||||
@@ -431,45 +468,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Manual Input Step */}
|
||||
{step === "input" && !isDeviceCode && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
|
||||
<div className="flex gap-2">
|
||||
<Input value={authData?.authUrl || ""} readOnly className="flex-1 font-mono text-xs" />
|
||||
<Button variant="secondary" icon={copied === "auth_url" ? "check" : "content_copy"} onClick={() => copy(authData?.authUrl, "auth_url")}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
After authorization, copy the full URL from your browser.
|
||||
</p>
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={placeholderUrl}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
|
||||
Connect
|
||||
</Button>
|
||||
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Success Step */}
|
||||
{step === "success" && (
|
||||
<div className="text-center py-6">
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user