diff --git a/.gitignore b/.gitignore index d98ffcd5..874c0998 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ electron.vite.config.*.mjs # Logs logs/ !src/renderer/src/components/logs/ +!frontend/src/components/logs/ *.log npm-debug.log* yarn-debug.log* diff --git a/backend/oauth/types.ts b/backend/oauth/types.ts index 226ac1b6..353f8f71 100644 --- a/backend/oauth/types.ts +++ b/backend/oauth/types.ts @@ -3,7 +3,7 @@ * Defines types and interfaces for provider authentication */ -import type { ProviderVendor } from '../../shared/types' +import type { ProviderVendor } from '../shared/types' export type ProviderType = Exclude @@ -22,7 +22,7 @@ export type OAuthStatus = 'idle' | 'pending' | 'success' | 'error' | 'cancelled' /** * Token type */ -export type TokenType = 'jwt' | 'refresh' | 'access' | 'cookie' +export type TokenType = 'jwt' | 'refresh' | 'access' | 'cookie' | 'token' /** * OAuth login result @@ -129,7 +129,7 @@ export interface ManualTokenConfig { /** * Manual input config for each provider */ -export const MANUAL_TOKEN_CONFIGS: Record = { +export const MANUAL_TOKEN_CONFIGS: Partial> = { deepseek: [ { providerType: 'deepseek', diff --git a/backend/providers/custom.ts b/backend/providers/custom.ts index 3f856d1e..58189585 100644 --- a/backend/providers/custom.ts +++ b/backend/providers/custom.ts @@ -1,5 +1,5 @@ import { storeManager } from '../store/store' -import type { Provider, AuthType } from '../../shared/types' +import type { Provider, AuthType } from '../shared/types' import type { CredentialField } from '../store/types' export interface CustomProviderData { diff --git a/backend/proxy/adapters/index.ts b/backend/proxy/adapters/index.ts index 9c24c4d9..cb654390 100644 --- a/backend/proxy/adapters/index.ts +++ b/backend/proxy/adapters/index.ts @@ -8,7 +8,8 @@ export { GLMAdapter, GLMStreamHandler, glmAdapter } from './glm' export { KimiAdapter, KimiStreamHandler, kimiAdapter } from './kimi' export { MimoAdapter, MimoStreamHandler, mimoAdapter } from './mimo' export { MiniMaxAdapter, MiniMaxStreamHandler, minimaxAdapter } from './minimax' -export { PerplexityAdapter, PerplexityStreamHandler, perplexityAdapter } from './perplexity' +export { PerplexityAdapter, perplexityAdapter } from './perplexity' +export { PerplexityStreamHandler } from './perplexity-stream' export { QwenAdapter, QwenStreamHandler, qwenAdapter } from './qwen' export { QwenAiAdapter, QwenAiStreamHandler, qwenAiAdapter } from './qwen-ai' export { ZaiAdapter, ZaiStreamHandler, zaiAdapter } from './zai' diff --git a/backend/proxy/adapters/prompt/BasePromptAdapter.ts b/backend/proxy/adapters/prompt/BasePromptAdapter.ts index ca4ce1ac..52945b72 100644 --- a/backend/proxy/adapters/prompt/BasePromptAdapter.ts +++ b/backend/proxy/adapters/prompt/BasePromptAdapter.ts @@ -118,7 +118,7 @@ export abstract class BasePromptAdapter implements PromptAdapter { return { messages, tools: undefined, injected: false } } - const variant = this.getPromptVariant(model, provider) + const variant = this.getPromptVariant(model, provider) ?? undefined const toolsPrompt = this.toolsToPrompt(tools, variant) const transformedMessages = this.injectPrompt(messages, toolsPrompt) @@ -166,7 +166,9 @@ export abstract class BasePromptAdapter implements PromptAdapter { if (typeof part === 'string') { parts.push(part) } else if (part && typeof part === 'object' && 'text' in part) { - parts.push(part.text) + if (typeof part.text === 'string') { + parts.push(part.text) + } } } } diff --git a/backend/proxy/adapters/prompt/CherryStudioPromptAdapter.ts b/backend/proxy/adapters/prompt/CherryStudioPromptAdapter.ts index 4daa7c34..c496caa0 100644 --- a/backend/proxy/adapters/prompt/CherryStudioPromptAdapter.ts +++ b/backend/proxy/adapters/prompt/CherryStudioPromptAdapter.ts @@ -176,7 +176,7 @@ export class CherryStudioPromptAdapter extends BasePromptAdapter { return { messages, tools: undefined, injected: false } } - const variant = this.getPromptVariant(model) + const variant = this.getPromptVariant(model) ?? undefined const toolsPrompt = this.toolsToPrompt(tools, variant) const transformedMessages = this.injectPrompt(messages, toolsPrompt) diff --git a/backend/proxy/adapters/prompt/DefaultPromptAdapter.ts b/backend/proxy/adapters/prompt/DefaultPromptAdapter.ts index 73f5f643..45123a8c 100644 --- a/backend/proxy/adapters/prompt/DefaultPromptAdapter.ts +++ b/backend/proxy/adapters/prompt/DefaultPromptAdapter.ts @@ -9,7 +9,8 @@ import { ChatMessage, ChatCompletionTool, ToolCall } from '../../types' import { BasePromptAdapter, PromptVariant, TransformResult, ParseResult, ToolCallFormat } from './BasePromptAdapter' import { ClientType } from '../../utils/promptSignatures' import { parseToolCallsFromText } from '../../utils/toolParser' -import { TOOL_PROMPT_SIGNATURES, hasGeneralToolPromptSignature } from '../../constants/signatures' +import { TOOL_PROMPT_SIGNATURES } from '../../utils/tools' +import { hasGeneralToolPromptSignature } from '../../constants/signatures' import { DEFAULT_VARIANT, XML_VARIANT } from '../../prompt/variants' /** diff --git a/backend/proxy/adapters/prompt/KiloCodePromptAdapter.ts b/backend/proxy/adapters/prompt/KiloCodePromptAdapter.ts index aa8577b3..4d7e2a2b 100644 --- a/backend/proxy/adapters/prompt/KiloCodePromptAdapter.ts +++ b/backend/proxy/adapters/prompt/KiloCodePromptAdapter.ts @@ -134,7 +134,7 @@ export class KiloCodePromptAdapter extends BasePromptAdapter { if (this.hasPromptInjected(messages)) { console.log('[KiloCodeAdapter] Kilo Code prompt detected, replacing with standard format') const cleanedMessages = this.cleanKiloCodePrompt(messages) - const variant = this.getPromptVariant(model, provider) + const variant = this.getPromptVariant(model, provider) ?? undefined const toolsPrompt = this.toolsToPrompt(tools, variant) const transformedMessages = this.injectPrompt(cleanedMessages, toolsPrompt) @@ -146,7 +146,7 @@ export class KiloCodePromptAdapter extends BasePromptAdapter { } } - const variant = this.getPromptVariant(model, provider) + const variant = this.getPromptVariant(model, provider) ?? undefined const toolsPrompt = this.toolsToPrompt(tools, variant) const transformedMessages = this.injectPrompt(messages, toolsPrompt) diff --git a/backend/proxy/adapters/prompt/PromptAdapterRegistry.ts b/backend/proxy/adapters/prompt/PromptAdapterRegistry.ts index d9a5b5fd..cede7f05 100644 --- a/backend/proxy/adapters/prompt/PromptAdapterRegistry.ts +++ b/backend/proxy/adapters/prompt/PromptAdapterRegistry.ts @@ -194,7 +194,9 @@ export class PromptAdapterRegistry { if (typeof part === 'string') { parts.push(part) } else if (part && typeof part === 'object' && 'text' in part) { - parts.push(part.text) + if (typeof part.text === 'string') { + parts.push(part.text) + } } } } diff --git a/backend/proxy/index.ts b/backend/proxy/index.ts index 516c9824..cd321a6a 100644 --- a/backend/proxy/index.ts +++ b/backend/proxy/index.ts @@ -10,4 +10,4 @@ export { LoadBalancer, loadBalancer } from './loadbalancer' export { ModelMapper, modelMapper } from './modelMapper' export { RequestForwarder, requestForwarder } from './forwarder' export { StreamHandler, streamHandler } from './stream' -export { routes } from './routes' +export { default as routes } from './routes' diff --git a/backend/proxy/server.ts b/backend/proxy/server.ts index 1140c918..f82fc714 100644 --- a/backend/proxy/server.ts +++ b/backend/proxy/server.ts @@ -80,7 +80,21 @@ export class ProxyServer { this.app.use(async (ctx, next) => { const origin = ctx.get('Origin') - if (ctx.path.startsWith('/v0/management')) { + // The bookmarklet ingest endpoint is intentionally cross-origin: + // operators run the bookmarklet from the provider's own page + // (e.g. https://chatglm.cn) and POST the captured token back to + // this server. The endpoint authenticates with a one-shot ticket + // baked into the bookmarklet, so it does not (and must not) accept + // cookies. We therefore allow any origin unconditionally — anything + // narrower would force every operator to whitelist every provider + // domain in CHAT2API_CORS_ORIGINS, which defeats the whole point. + const isBookmarkletIngest = + ctx.path === '/v0/management/oauth/bookmarklet/ingest' + + if (isBookmarkletIngest) { + ctx.set('Access-Control-Allow-Origin', '*') + ctx.set('Vary', 'Origin') + } else if (ctx.path.startsWith('/v0/management')) { // Management API: only allow explicitly configured origins. // CHAT2API_CORS_ORIGINS accepts a comma-separated list (e.g. "https://admin.example.com,http://localhost:3000"). // If unset, only same-origin requests (no Origin header) are permitted. @@ -138,6 +152,19 @@ export class ProxyServer { return } + // The API key gate is only meant to protect the OpenAI-compatible + // proxy endpoints. Don't gate the SPA's own assets / client-side + // routes, otherwise turning on "Enable API Key" makes the web UI + // itself unreachable (browser fetches /assets/*.js -> 401 -> blank + // page). Anything that isn't an explicit API path is allowed + // through; the static fallback (or 404) will handle it. + const isProxyApi = + ctx.path.startsWith('/v1/') || ctx.path === '/v1' + if (!isProxyApi) { + await next() + return + } + const config = storeManager.getConfig() if (config.enableApiKey && config.apiKeys && config.apiKeys.length > 0) { @@ -225,10 +252,15 @@ export class ProxyServer { this.router.use(route.allowedMethods()) } - this.router.get('/', async (ctx) => { - // When the SPA is mounted, let the static fallback render index.html. + this.router.get('/', async (ctx, next) => { + // When the SPA is mounted, hand off to the static fallback so it + // can serve index.html (and let client-side routing take over). + // We MUST call next(): koa-router will not run any later middleware + // if the handler returns without invoking it, so the previous + // `ctx.status = 404; return` short-circuited the SPA fallback and + // browsers got a bare 404 at the site root. if (this.staticFrontendDir) { - ctx.status = 404 + await next() return } ctx.body = { diff --git a/backend/proxy/utils/accountUtils.ts b/backend/proxy/utils/accountUtils.ts index 73d31486..bc8e15ba 100644 --- a/backend/proxy/utils/accountUtils.ts +++ b/backend/proxy/utils/accountUtils.ts @@ -77,13 +77,10 @@ export function createAccount( return { providerId, credentials, - name: accountInfo?.name, + name: accountInfo?.name || '', email: accountInfo?.email, - userId: accountInfo?.userId, status: 'active', lastUsed: undefined, - usageCount: 0, - metadata: {}, } } diff --git a/backend/proxy/utils/clientDetector.ts b/backend/proxy/utils/clientDetector.ts index 56e98f09..f6b33e05 100644 --- a/backend/proxy/utils/clientDetector.ts +++ b/backend/proxy/utils/clientDetector.ts @@ -269,8 +269,9 @@ export function removeToolPromptSection(content: string, clientType: ClientType) export function cleanToolPrompts(messages: ChatMessage[]): ChatMessage[] { const allContent = extractAllContent(messages) const clientResult = detectClientFromContent(allContent) + const config = CLIENT_SIGNATURES[clientResult.clientType] - if (!clientResult.promptSectionMarkers) { + if (!config?.promptSectionMarkers) { return messages } diff --git a/backend/proxy/utils/index.ts b/backend/proxy/utils/index.ts index 617ebcc4..4e646639 100644 --- a/backend/proxy/utils/index.ts +++ b/backend/proxy/utils/index.ts @@ -6,4 +6,9 @@ export * from './tools' // 新的统一工具解析模块 export * from './toolParser/index' // 保留旧的 streamToolHandler 以保持向后兼容 -export * from './streamToolHandler' +// 仅导出 streamToolHandler 中独有的成员,避免与 toolParser/index 重复导出冲突 +export { + ToolCallState, + createToolCallState, + processStreamContent, +} from './streamToolHandler' diff --git a/backend/shared/types.ts b/backend/shared/types.ts index 19b1312c..40ea48ab 100644 --- a/backend/shared/types.ts +++ b/backend/shared/types.ts @@ -30,11 +30,16 @@ export type LoadBalanceStrategy = 'round-robin' | 'fill-first' | 'failover' export type Theme = 'light' | 'dark' | 'system' -export type { +import type { LegacyToolPromptConfig, ToolCallingConfig, } from './toolCalling' +export type { + LegacyToolPromptConfig, + ToolCallingConfig, +} + export interface Account { id: string providerId: string @@ -68,6 +73,7 @@ export interface Provider { modelMappings?: Record status?: ProviderStatus lastStatusCheck?: number + credentialFields?: CredentialField[] } export interface ModelMapping { diff --git a/backend/store/index.ts b/backend/store/index.ts index b720115f..06a94c73 100644 --- a/backend/store/index.ts +++ b/backend/store/index.ts @@ -7,6 +7,7 @@ export * from './types' // Core storage +import { storeManager } from './store' export { storeManager, StoreManager } from './store' // Account management API diff --git a/backend/store/store.ts b/backend/store/store.ts index 846fa962..eb4a891c 100644 --- a/backend/store/store.ts +++ b/backend/store/store.ts @@ -58,7 +58,7 @@ type StoreType = JsonStore * Storage Manager Class * Responsible for data persistence and encryption */ -class StoreManager { +export class StoreManager { private store: StoreType | null = null private isInitialized: boolean = false private initializationError: Error | null = null diff --git a/backend/store/types.ts b/backend/store/types.ts index 75da6b45..6cffb557 100644 --- a/backend/store/types.ts +++ b/backend/store/types.ts @@ -154,6 +154,8 @@ export interface Provider { status?: ProviderStatus /** Last status check time */ lastStatusCheck?: number + /** Credential field configuration (optional, mainly used by built-in providers) */ + credentialFields?: CredentialField[] } /** diff --git a/frontend/src/components/logs/RequestLogList.tsx b/frontend/src/components/logs/RequestLogList.tsx new file mode 100644 index 00000000..85a6a841 --- /dev/null +++ b/frontend/src/components/logs/RequestLogList.tsx @@ -0,0 +1,371 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ApiService } from '@/services/api' +import type { RequestLogEntry } from '@shared/types' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { ScrollArea } from '@/components/ui/scroll-area' +import { useToast } from '@/hooks/use-toast' +import { RefreshCw, Search, Trash2 } from 'lucide-react' + +type StatusFilter = 'all' | 'success' | 'error' + +const REFRESH_INTERVAL_MS = 5000 + +function formatTimestamp(ts: number): string { + try { + return new Date(ts).toLocaleString() + } catch { + return String(ts) + } +} + +function formatLatency(ms: number): string { + if (!Number.isFinite(ms)) return '-' + if (ms < 1000) return `${Math.round(ms)} ms` + return `${(ms / 1000).toFixed(2)} s` +} + +function tryPrettyJson(raw?: string): string { + if (!raw) return '' + try { + return JSON.stringify(JSON.parse(raw), null, 2) + } catch { + return raw + } +} + +export function RequestLogList() { + const { t } = useTranslation() + const { toast } = useToast() + + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(false) + const [statusFilter, setStatusFilter] = useState('all') + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + + const loadLogs = useCallback(async () => { + setLoading(true) + try { + const filter: Record = { limit: 200 } + if (statusFilter !== 'all') filter.status = statusFilter + const data = await ApiService.requestLogs.get(filter) + setLogs(Array.isArray(data) ? data : []) + } catch (err) { + console.error('[RequestLogList] failed to load logs', err) + } finally { + setLoading(false) + } + }, [statusFilter]) + + useEffect(() => { + void loadLogs() + const id = window.setInterval(() => { + void loadLogs() + }, REFRESH_INTERVAL_MS) + return () => window.clearInterval(id) + }, [loadLogs]) + + const filteredLogs = useMemo(() => { + if (!search.trim()) return logs + const q = search.trim().toLowerCase() + return logs.filter((log) => { + const haystack = [ + log.model, + log.actualModel, + log.providerName, + log.accountName, + log.url, + log.userInput, + log.errorMessage, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + return haystack.includes(q) + }) + }, [logs, search]) + + const handleClear = useCallback(async () => { + if (!window.confirm(t('logs.clearConfirmDesc'))) return + try { + await ApiService.requestLogs.clear() + setLogs([]) + toast({ + title: t('logs.clearSuccess'), + description: t('logs.allLogsCleared'), + }) + } catch (err) { + console.error('[RequestLogList] clear failed', err) + toast({ + title: t('logs.clearFailed'), + description: t('logs.cannotClearLogs'), + variant: 'destructive', + }) + } + }, [t, toast]) + + return ( +
+
+
+ + setSearch(e.target.value)} + placeholder={t('logs.search')} + className="pl-8" + /> +
+ + + + + + +
+ +
+ + + + {t('logs.time')} + {t('logs.status')} + {t('logs.model')} + {t('logs.provider')} + {t('logs.account')} + {t('logs.latency')} + + + + {filteredLogs.length === 0 ? ( + + + {t('logs.noRequestLogs')} + + + ) : ( + filteredLogs.map((log) => ( + setSelected(log)} + > + + {formatTimestamp(log.timestamp)} + + + + {log.statusCode || log.responseStatus || log.status} + + + + {log.model} + {log.actualModel && log.actualModel !== log.model ? ( + + → {log.actualModel} + + ) : null} + + {log.providerName || '-'} + {log.accountName || '-'} + + {formatLatency(log.latency)} + + + )) + )} + +
+
+ + { + if (!open) setSelected(null) + }} + /> +
+ ) +} + +interface DetailProps { + log: RequestLogEntry | null + onOpenChange: (open: boolean) => void +} + +function RequestLogDetailDialog({ log, onOpenChange }: DetailProps) { + const { t } = useTranslation() + + return ( + + + + {t('logs.logDetails')} + {t('logs.viewCompleteLogInfo')} + + + {log ? ( + + + {t('logs.tabInfo')} + {t('logs.tabUserInput')} + {t('logs.tabRequest')} + {t('logs.tabResponse')} + {t('logs.tabError')} + + + + +
+
{t('logs.time')}
+
{formatTimestamp(log.timestamp)}
+
{t('logs.status')}
+
{log.status}
+
{t('logs.method')}
+
{log.method}
+
{t('logs.url')}
+
{log.url}
+
{t('logs.model')}
+
{log.model}
+ {log.actualModel ? ( + <> +
Actual Model
+
{log.actualModel}
+ + ) : null} +
{t('logs.provider')}
+
{log.providerName || log.providerId || '-'}
+
{t('logs.account')}
+
{log.accountName || log.accountId || '-'}
+
{t('logs.latency')}
+
{formatLatency(log.latency)}
+
{t('logs.stream')}
+
{log.isStream ? 'Yes' : 'No'}
+
{t('logs.responseStatus')}
+
{log.responseStatus}
+ {typeof log.webSearch === 'boolean' ? ( + <> +
{t('logs.webSearch')}
+
{log.webSearch ? 'Yes' : 'No'}
+ + ) : null} + {log.reasoningEffort ? ( + <> +
{t('logs.reasoningEffort')}
+
{log.reasoningEffort}
+ + ) : null} +
{t('logs.requestId')}
+
{log.id}
+
+
+
+ + + +
+                  {log.userInput || t('logs.noUserInput')}
+                
+
+
+ + + +
+                  {tryPrettyJson(log.requestBody) || t('logs.noRequestData')}
+                
+
+
+ + + +
+                  {tryPrettyJson(log.responseBody) ||
+                    log.responsePreview ||
+                    t('logs.noRequestData')}
+                
+
+
+ + + +
+
+ {t('logs.errorMessage')} +
+
+                    {log.errorMessage || t('logs.noError')}
+                  
+
+ {log.errorStack ? ( +
+
+ {t('logs.stackTrace')} +
+
+                      {log.errorStack}
+                    
+
+ ) : null} +
+
+
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/logs/index.ts b/frontend/src/components/logs/index.ts new file mode 100644 index 00000000..b1724cda --- /dev/null +++ b/frontend/src/components/logs/index.ts @@ -0,0 +1 @@ +export { RequestLogList } from './RequestLogList' diff --git a/frontend/src/components/oauth/ConsoleScriptPanel.tsx b/frontend/src/components/oauth/ConsoleScriptPanel.tsx new file mode 100644 index 00000000..c72ccd0f --- /dev/null +++ b/frontend/src/components/oauth/ConsoleScriptPanel.tsx @@ -0,0 +1,168 @@ +/** + * Console Script Panel + * + * Shows a simple one-line JS command for each provider that the user + * can copy, paste into the provider's browser Console (F12), and execute. + * The command returns the token value directly in the Console — the user + * then copies it and pastes it into the manual input field. + * + * No tickets, no fetch, no polling, no bookmarklets. Just: + * 1. Copy the one-liner + * 2. Go to provider page → F12 → Console → Paste → Enter + * 3. Right-click the result → "Copy string" + * 4. Paste into the token field back in Chat2API + */ + +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { + CheckCircle2, + ClipboardCopy, + ExternalLink, + Terminal, +} from 'lucide-react' + +/** + * Per-provider one-liner commands to extract the token from the browser. + * Each returns the token string directly in the Console. + */ +const PROVIDER_COMMANDS: Record = { + deepseek: { + command: `localStorage.getItem('userToken')`, + loginUrl: 'https://chat.deepseek.com', + }, + glm: { + command: `document.cookie.match(/chatglm_refresh_token=([^;]+)/)?.[1] || 'not found'`, + loginUrl: 'https://chatglm.cn', + }, + kimi: { + command: `document.cookie.match(/kimi-auth=([^;]+)/)?.[1] || 'not found'`, + loginUrl: 'https://www.kimi.com', + }, + minimax: { + command: `JSON.stringify({token: localStorage.getItem('_token'), userId: localStorage.getItem('_userId')})`, + loginUrl: 'https://chat.minimaxi.com', + }, + qwen: { + command: `document.cookie.match(/tongyi_sso_ticket=([^;]+)/)?.[1] || 'not found'`, + loginUrl: 'https://www.qianwen.com', + }, + 'qwen-ai': { + command: `localStorage.getItem('token')`, + loginUrl: 'https://chat.qwen.ai', + }, + zai: { + command: `localStorage.getItem('token')`, + loginUrl: 'https://chat.z.ai', + }, + perplexity: { + command: `document.cookie.match(/__Secure-next-auth\\.session-token=([^;]+)/)?.[1] || 'not found'`, + loginUrl: 'https://www.perplexity.ai', + }, + mimo: { + command: `JSON.stringify({service_token: document.cookie.match(/serviceToken=([^;]+)/)?.[1], user_id: document.cookie.match(/userId=([^;]+)/)?.[1], ph_token: document.cookie.match(/xiaomichatbot_ph=([^;]+)/)?.[1]})`, + loginUrl: 'https://aistudio.xiaomimimo.com', + }, +} + +interface ConsoleScriptPanelProps { + providerId: string + providerType: string + providerName?: string +} + +export function ConsoleScriptPanel({ + providerId, + providerType, + providerName, +}: ConsoleScriptPanelProps) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + const config = PROVIDER_COMMANDS[providerType] || PROVIDER_COMMANDS[providerId] + const displayName = providerName || providerType + + if (!config) { + return null + } + + const copyCommand = async () => { + try { + await navigator.clipboard.writeText(config.command) + setCopied(true) + setTimeout(() => setCopied(false), 3000) + } catch { + // Fallback + const ta = document.createElement('textarea') + ta.value = config.command + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + setCopied(true) + setTimeout(() => setCopied(false), 3000) + } + } + + return ( +
+ {/* Step-by-step instructions */} +
+

+ + {t('oauth.console.stepsTitle')} +

+
    +
  1. {t('oauth.console.step1', { provider: displayName })}
  2. +
  3. {t('oauth.console.step2')}
  4. +
  5. {t('oauth.console.step3')}
  6. +
  7. {t('oauth.console.step4')}
  8. +
+
+ + {/* Command display */} +
+

+ {t('oauth.console.commandLabel')} +

+ + {config.command} + +
+ + {/* Copy button */} + + + {/* Open login page */} + +
+ ) +} + +export default ConsoleScriptPanel diff --git a/frontend/src/components/oauth/TokenExtractionGuide.tsx b/frontend/src/components/oauth/TokenExtractionGuide.tsx index fa5b42d7..4e5072ae 100644 --- a/frontend/src/components/oauth/TokenExtractionGuide.tsx +++ b/frontend/src/components/oauth/TokenExtractionGuide.tsx @@ -28,7 +28,10 @@ import { Loader2, } from 'lucide-react' import { ApiService } from '@/services/api' -import { BookmarkletPanel } from './BookmarkletPanel' +import { ConsoleScriptPanel } from './ConsoleScriptPanel' + +// NOTE: BookmarkletPanel is no longer used in the UI. +// The ConsoleScriptPanel now just shows a simple one-liner command. type StorageType = 'localStorage' | 'cookie' | 'network' @@ -308,13 +311,11 @@ export function TokenExtractionGuide({ return (
- {/* ── Recommended: Bookmarklet ── */} - {/* ── Fallback: Manual DevTools paste (collapsed by default) ── */} @@ -328,7 +329,7 @@ export function TokenExtractionGuide({ ) : ( )} - {t('oauth.bookmarklet.manualFallbackAdvanced')} + {t('oauth.console.manualFallback')} {showManual && ( diff --git a/frontend/src/components/providers/AddAccountDialog.tsx b/frontend/src/components/providers/AddAccountDialog.tsx index 845a1dfb..931a680c 100644 --- a/frontend/src/components/providers/AddAccountDialog.tsx +++ b/frontend/src/components/providers/AddAccountDialog.tsx @@ -39,77 +39,88 @@ import type { Provider, CredentialField, Account, BuiltinProviderConfig } from ' function mapOAuthCredentials(providerId: string | undefined, credentials: Record): Record { if (!providerId) return credentials - const credentialKeyMap: Record = { - 'glm': 'chatglm_refresh_token', - 'deepseek': 'userToken', - 'qwen': 'tongyi_sso_ticket', - 'qwen-ai': 'tongyi_sso_ticket', - 'zai': 'tongyi_sso_ticket', - 'perplexity': '__Secure-next-auth.session-token', - 'mimo': 'serviceToken', + // For each provider, list every OAuth key that may carry the primary + // token (raw cookie names, the bookmarklet's relabelled "token" field, + // older snake_case names, and the camelCase keys the backend adapters + // emit today). The first key that has a non-empty value wins. + const primaryTokenCandidates: Record = { + glm: ['refreshToken', 'refresh_token', 'chatglm_refresh_token', 'token'], + deepseek: ['userToken', 'token'], + qwen: ['tongyi_sso_ticket', 'ticket', 'token'], + 'qwen-ai': ['tongyi_sso_ticket', 'ticket', 'token'], + zai: ['tongyi_sso_ticket', 'ticket', 'token'], + perplexity: ['__Secure-next-auth.session-token', 'next-auth.session-token', 'sessionToken', 'token'], + kimi: ['kimi-auth', 'token'], + minimax: ['_token', 'token'], + mimo: ['service_token', 'serviceToken', 'token'], } - const providerFieldNames: Record = { - 'glm': 'refresh_token', - 'deepseek': 'token', - 'qwen': 'ticket', + const formFieldName: Record = { + glm: 'refresh_token', + deepseek: 'token', + qwen: 'ticket', 'qwen-ai': 'ticket', - 'zai': 'ticket', - 'perplexity': 'sessionToken', - 'mimo': 'service_token', + zai: 'ticket', + perplexity: 'sessionToken', + kimi: 'token', + minimax: 'token', + mimo: 'service_token', } - const oauthKey = credentialKeyMap[providerId] - if (oauthKey && credentials[oauthKey]) { - const fieldName = providerFieldNames[providerId] - if (fieldName) { - // Handle JSON-wrapped tokens (DeepSeek stores token as {"value":"..."}) - let tokenValue = credentials[oauthKey] - if (providerId === 'deepseek' && tokenValue && tokenValue.startsWith('{') && tokenValue.endsWith('}')) { - try { - const parsed = JSON.parse(tokenValue) - if (parsed.value) { - tokenValue = parsed.value - } - } catch (e) { - console.error('[AddAccountDialog] Error parsing JSON token:', e) - } - } - return { [fieldName]: tokenValue } + const pickFirst = (keys: string[]): string | undefined => { + for (const k of keys) { + const v = credentials[k] + if (typeof v === 'string' && v.length > 0) return v } + return undefined } - // For Perplexity, if we have the secure token, map it - if (providerId === 'perplexity' && credentials['__Secure-next-auth.session-token']) { - return { sessionToken: credentials['__Secure-next-auth.session-token'] } - } - if (providerId === 'perplexity' && credentials['next-auth.session-token']) { - return { sessionToken: credentials['next-auth.session-token'] } - } + const fieldName = formFieldName[providerId] + const tokenCandidates = primaryTokenCandidates[providerId] - // For Mimo, map all three tokens - if (providerId === 'mimo') { - const result: Record = {} - // OAuth already returns credentials in correct format (service_token, user_id, ph_token) - // Check for final format first - if (credentials['service_token']) { - result['service_token'] = credentials['service_token'] - } else if (credentials['serviceToken']) { - result['service_token'] = credentials['serviceToken'] - } - if (credentials['user_id']) { - result['user_id'] = credentials['user_id'] - } else if (credentials['userId']) { - result['user_id'] = credentials['userId'] + if (fieldName && tokenCandidates) { + let tokenValue = pickFirst(tokenCandidates) + + // DeepSeek's bookmarklet captures the raw localStorage payload, which + // is itself a JSON-wrapped string: {"value":""}. + if (providerId === 'deepseek' && tokenValue && tokenValue.startsWith('{') && tokenValue.endsWith('}')) { + try { + const parsed = JSON.parse(tokenValue) + if (typeof parsed?.value === 'string') { + tokenValue = parsed.value + } + } catch (e) { + console.error('[AddAccountDialog] Error parsing JSON token:', e) + } } - if (credentials['ph_token']) { - result['ph_token'] = credentials['ph_token'] - } else if (credentials['xiaomichatbot_ph']) { - result['ph_token'] = credentials['xiaomichatbot_ph'] + + if (tokenValue) { + // MiniMax also requires realUserID, kept alongside the primary token. + if (providerId === 'minimax') { + const realUserID = credentials._userId || credentials.realUserID + const result: Record = { [fieldName]: tokenValue } + if (realUserID) result.realUserID = realUserID + return result + } + + // Mimo Studio needs three fields together; the helper below populates + // all of them and falls back to the (already mapped) primary token. + if (providerId === 'mimo') { + const result: Record = {} + const userId = credentials.user_id || credentials.userId || credentials.mimoUserId + const phToken = credentials.ph_token || credentials.xiaomichatbot_ph || credentials.mimoPhToken + result.service_token = tokenValue + if (userId) result.user_id = userId + if (phToken) result.ph_token = phToken + return result + } + + return { [fieldName]: tokenValue } } - return result } + // Unknown provider, or none of the candidate keys matched. Pass the + // raw credentials through so the user at least sees what arrived. return credentials } @@ -353,11 +364,19 @@ export function AddAccountDialog({ // Map raw OAuth credentials to the provider's credential // field names, then pre-fill the form so the user can // review (and adjust the account name) before saving. + console.log('[AddAccountDialog] OAuth raw creds:', JSON.stringify(creds)) const mapped = mapOAuthCredentials(provider.id, creds) - setCredentials(mapped) - if (accountInfo?.name) setName(accountInfo.name) - setValidationResult({ valid: true, userInfo: accountInfo }) + console.log('[AddAccountDialog] Mapped creds:', JSON.stringify(mapped)) + // Switch tab FIRST so the credential fields are mounted, + // then set credentials so React renders them filled. setActiveTab('manual') + // Use a microtask to ensure the tab content has mounted + // before we update the credential state that fills the inputs. + setTimeout(() => { + setCredentials(mapped) + if (accountInfo?.name) setName(accountInfo.name) + setValidationResult({ valid: true, userInfo: accountInfo }) + }, 0) }} /> )} diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 5d779c5b..4775b334 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -876,6 +876,29 @@ "tokenInvalid": "Server rejected the token", "manualFallback": "Manual paste (DevTools)", "manualFallbackAdvanced": "Manual paste from DevTools (advanced)" + }, + "console": { + "description": "One-click script to automatically extract and send the {{provider}} token — just paste it in the browser console.", + "generate": "Generate Script", + "generating": "Generating…", + "stepsTitle": "How to use:", + "step1": "Open {{provider}} in a new tab and make sure you are logged in.", + "step2": "Press F12 to open DevTools, switch to the Console tab.", + "step3": "Click \"Copy Command\" below, then paste (Ctrl+V) in the Console and press Enter.", + "step4": "Right-click the returned value → \"Copy string\" → paste it into the token field above.", + "copyCommand": "Copy Command to Clipboard", + "commandLabel": "Console command:", + "copied": "Command Copied!", + "openLogin": "Open {{host}} login page", + "waiting": "Waiting for token…", + "timeLeft": "time left", + "success": "Token received and validated. Saving account…", + "expired": "Script expired. Click \"Generate Script\" to get a new one.", + "ticketUsed": "Script has already been used or expired.", + "tryAgain": "Try Again (new script)", + "networkError": "Network error while generating script", + "tokenInvalid": "Server rejected the token", + "manualFallback": "Or paste token manually (advanced)" } }, "models": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 2b56dee9..f0602175 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -876,6 +876,29 @@ "tokenInvalid": "服务端拒绝了 Token", "manualFallback": "手动粘贴 (DevTools)", "manualFallbackAdvanced": "手动从开发者工具粘贴(高级)" + }, + "console": { + "description": "一键脚本自动获取 {{provider}} 的 Token — 只需在浏览器控制台粘贴即可。", + "generate": "生成脚本", + "generating": "正在生成…", + "stepsTitle": "使用方法:", + "step1": "在新标签页中打开 {{provider}} 并确保已登录。", + "step2": "按 F12 打开开发者工具,切换到 Console(控制台)标签页。", + "step3": "点击下方「复制命令」,然后在控制台中粘贴(Ctrl+V)并按回车。", + "step4": "右键点击返回的值 → 「Copy string」→ 粘贴到上方的令牌输入框中。", + "copyCommand": "复制命令到剪贴板", + "commandLabel": "控制台命令:", + "copied": "命令已复制!", + "openLogin": "打开 {{host}} 登录页面", + "waiting": "等待 Token 中…", + "timeLeft": "剩余时间", + "success": "Token 已接收并验证,正在保存账户…", + "expired": "脚本已过期,请点击「生成脚本」重新获取。", + "ticketUsed": "脚本已使用或过期。", + "tryAgain": "重试(生成新脚本)", + "networkError": "生成脚本时网络错误", + "tokenInvalid": "服务端拒绝了 Token", + "manualFallback": "或手动粘贴 Token(高级)" } }, "models": { diff --git a/frontend/src/shared/types.ts b/frontend/src/shared/types.ts index 19b1312c..638047f5 100644 --- a/frontend/src/shared/types.ts +++ b/frontend/src/shared/types.ts @@ -209,6 +209,32 @@ export interface RequestLogConfig { redactSensitiveData: boolean } +export interface RequestLogEntry { + id: string + timestamp: number + status: 'success' | 'error' + statusCode: number + method: string + url: string + model: string + actualModel?: string + providerId?: string + providerName?: string + accountId?: string + accountName?: string + requestBody?: string + userInput?: string + webSearch?: boolean + reasoningEffort?: 'low' | 'medium' | 'high' + responseStatus: number + responsePreview?: string + responseBody?: string + latency: number + isStream: boolean + errorMessage?: string + errorStack?: string +} + export interface ManagementApiConfig { enableManagementApi: boolean managementApiSecret: string diff --git a/tsconfig.backend.json b/tsconfig.backend.json index 8db45945..ba642d00 100644 --- a/tsconfig.backend.json +++ b/tsconfig.backend.json @@ -9,7 +9,6 @@ "esModuleInterop": true, "skipLibCheck": true, "noImplicitAny": false, - "suppressImplicitAnyIndexErrors": true, "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": {