From c2f51107111e51ad62110a9343bbdfd97671f9b8 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 05:17:24 +0000 Subject: [PATCH 01/11] fix(tsconfig): remove deprecated suppressImplicitAnyIndexErrors option This option was removed in TypeScript 5.5+. Use noImplicitAny: false (already set) to suppress implicit any errors instead. Fixes: error TS5102: Option 'suppressImplicitAnyIndexErrors' has been removed. Co-authored-by: zhaiker01 <285887163+zhaiker01@users.noreply.github.com> --- tsconfig.backend.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.backend.json b/tsconfig.backend.json index 8db4594..ba642d0 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": { From 18b85031b866dbef2d79709d07c42b65ab9acdbc Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 05:23:46 +0000 Subject: [PATCH 02/11] fix(backend): resolve TypeScript build errors - Fix import paths: '../../shared/types' -> '../shared/types' in oauth/types and providers/custom (file is at backend/shared/types, not /shared/types). - shared/types: import LegacyToolPromptConfig and ToolCallingConfig as types before re-exporting so they're available in local scope (fixes TS2304). - shared/types: add credentialFields to Provider interface so providers/ custom.ts can construct Provider with credentialFields. - store/types: add credentialFields to Provider so accountUtils can read it. - oauth/types: add 'token' to TokenType union (used by deepseek/minimax ManualTokenConfig entries). - oauth/types: relax MANUAL_TOKEN_CONFIGS to Partial> so it doesn't need entries for every ProviderType (mimo, zai are not configured). - proxy/adapters/index: PerplexityStreamHandler lives in perplexity-stream, not perplexity. - proxy/adapters/prompt: convert getPromptVariant() PromptVariant|null results to PromptVariant|undefined where needed (TransformResult.variant and toolsToPrompt() expect undefined, not null). - proxy/adapters/prompt: guard part.text type narrowing before push. - proxy/adapters/prompt/DefaultPromptAdapter: import TOOL_PROMPT_SIGNATURES from utils/tools (where it's defined) instead of constants/signatures. - proxy/index: routes/index.ts only has a default export; use 'export { default as routes }'. - proxy/utils/accountUtils: default account name to '' when accountInfo is undefined (Account.name is required). - proxy/utils/clientDetector: look up promptSectionMarkers via CLIENT_SIGNATURES[clientType] instead of from DetectionResult (which doesn't carry that field). - proxy/utils/index: avoid duplicate re-exports between toolParser/index and the deprecated streamToolHandler (createBaseChunk, flushToolCallBuffer, shouldBlockOutput). - store/store: export StoreManager class. - store/index: import storeManager so initializeStore() can call it. --- backend/oauth/types.ts | 6 +++--- backend/providers/custom.ts | 2 +- backend/proxy/adapters/index.ts | 3 ++- backend/proxy/adapters/prompt/BasePromptAdapter.ts | 6 ++++-- .../proxy/adapters/prompt/CherryStudioPromptAdapter.ts | 2 +- backend/proxy/adapters/prompt/DefaultPromptAdapter.ts | 3 ++- backend/proxy/adapters/prompt/KiloCodePromptAdapter.ts | 4 ++-- backend/proxy/adapters/prompt/PromptAdapterRegistry.ts | 4 +++- backend/proxy/index.ts | 2 +- backend/proxy/utils/accountUtils.ts | 2 +- backend/proxy/utils/clientDetector.ts | 3 ++- backend/proxy/utils/index.ts | 7 ++++++- backend/shared/types.ts | 8 +++++++- backend/store/index.ts | 1 + backend/store/store.ts | 2 +- backend/store/types.ts | 2 ++ 16 files changed, 39 insertions(+), 18 deletions(-) diff --git a/backend/oauth/types.ts b/backend/oauth/types.ts index 226ac1b..353f8f7 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 3f856d1..5818958 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 9c24c4d..cb65439 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 ca4ce1a..52945b7 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 4daa7c3..c496caa 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 73f5f64..45123a8 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 aa8577b..4d7e2a2 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 d9a5b5f..cede7f0 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 516c982..cd321a6 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/utils/accountUtils.ts b/backend/proxy/utils/accountUtils.ts index 73d3148..0ebdd9d 100644 --- a/backend/proxy/utils/accountUtils.ts +++ b/backend/proxy/utils/accountUtils.ts @@ -77,7 +77,7 @@ export function createAccount( return { providerId, credentials, - name: accountInfo?.name, + name: accountInfo?.name || '', email: accountInfo?.email, userId: accountInfo?.userId, status: 'active', diff --git a/backend/proxy/utils/clientDetector.ts b/backend/proxy/utils/clientDetector.ts index 56e98f0..f6b33e0 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 617ebcc..4e64663 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 19b1312..40ea48a 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 b720115..06a94c7 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 846fa96..eb4a891 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 75da6b4..6cffb55 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[] } /** From 4080c2e05cf3e197de7db03ad07302709630243c Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 05:25:39 +0000 Subject: [PATCH 03/11] fix(accountUtils): drop fields not present on Account type The createAccount helper was returning userId, usageCount, and metadata, none of which exist on the Account interface (which uses requestCount, not usageCount, and does not track userId or metadata). Drop them to match the declared return type Omit. Fixes: error TS2353: Object literal may only specify known properties, and 'userId' does not exist in type 'Omit'. --- backend/proxy/utils/accountUtils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/proxy/utils/accountUtils.ts b/backend/proxy/utils/accountUtils.ts index 0ebdd9d..bc8e15b 100644 --- a/backend/proxy/utils/accountUtils.ts +++ b/backend/proxy/utils/accountUtils.ts @@ -79,11 +79,8 @@ export function createAccount( credentials, name: accountInfo?.name || '', email: accountInfo?.email, - userId: accountInfo?.userId, status: 'active', lastUsed: undefined, - usageCount: 0, - metadata: {}, } } From 9b8841429e3dd86ae74dff660ebc14729fa127d6 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 05:31:02 +0000 Subject: [PATCH 04/11] feat(frontend): add missing RequestLogList component for /logs page The Logs page imports { RequestLogList } from '@/components/logs', but the component never existed in this repo, causing vite build to fail with: [vite:load-fallback] Could not load .../frontend/src/components/logs Add a real implementation that: - Lists request logs in a table (time, status, model, provider, account, latency) - Supports search and status filtering (all/success/error) - Auto-refreshes every 5 seconds and on demand - Lets the user clear all logs (with confirm dialog) - Opens a detail dialog with tabs (Info, User Input, Request, Response, Error) Also: - Export the RequestLogEntry type from @shared/types so api.ts and the new component can import it from a single location. - Update .gitignore: the existing 'logs/' rule was unintentionally hiding the new components/logs source folder. The un-ignore line still pointed at the old Electron path src/renderer/src/components/logs/, so add !frontend/src/components/logs/ for the post-migration layout. --- .gitignore | 1 + .../src/components/logs/RequestLogList.tsx | 371 ++++++++++++++++++ frontend/src/components/logs/index.ts | 1 + frontend/src/shared/types.ts | 26 ++ 4 files changed, 399 insertions(+) create mode 100644 frontend/src/components/logs/RequestLogList.tsx create mode 100644 frontend/src/components/logs/index.ts diff --git a/.gitignore b/.gitignore index d98ffcd..874c099 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/frontend/src/components/logs/RequestLogList.tsx b/frontend/src/components/logs/RequestLogList.tsx new file mode 100644 index 0000000..85a6a84 --- /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 0000000..b1724cd --- /dev/null +++ b/frontend/src/components/logs/index.ts @@ -0,0 +1 @@ +export { RequestLogList } from './RequestLogList' diff --git a/frontend/src/shared/types.ts b/frontend/src/shared/types.ts index 19b1312..638047f 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 From 56d153ad6d4f36757e07090c9b02cd5da583a44c Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 06:35:50 +0000 Subject: [PATCH 05/11] fix(server): SPA root '/' returns 404 instead of index.html The handler for GET / was setting ctx.status = 404 and returning, with a comment saying 'let the static fallback render index.html'. But koa-router will not invoke later middleware if the handler returns without calling next(), so the static asset fallback (which knows how to serve index.html) never ran. Browsers hitting http://host:8080/ saw a bare 404. Accept next as a parameter and await it instead of returning, so the fallback in the trailing app.use() runs and serves index.html. Sub-routes already worked because they never matched this handler. --- backend/proxy/server.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/proxy/server.ts b/backend/proxy/server.ts index 1140c91..e0be836 100644 --- a/backend/proxy/server.ts +++ b/backend/proxy/server.ts @@ -225,10 +225,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 = { From bb1c88e400d01008c0c90441e64d34457c38be15 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 06:44:18 +0000 Subject: [PATCH 06/11] fix(server): API key gate must not block SPA static assets The API key middleware allow-listed only '/', '/health', '/stats' and '/v0/management/*'. Everything else (including the SPA's own /assets/*.js, /assets/*.css, *.png, and any client-side route like /dashboard) was forced through API key validation. As soon as an operator turned on 'Enable API Key' from the management UI, the next page load fetched /assets/index-XXXX.js, hit the gate without an Authorization header, and got a 401. The browser then rendered a blank page because the SPA bundle never executed. Restrict the gate to the OpenAI-compatible proxy endpoints (/v1/*), which is the only surface the key was ever meant to protect. The management API still has its own auth; the SPA bundle is now served without authentication, the same way every other SPA-host setup does. --- backend/proxy/server.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/proxy/server.ts b/backend/proxy/server.ts index e0be836..7f3e8f9 100644 --- a/backend/proxy/server.ts +++ b/backend/proxy/server.ts @@ -138,6 +138,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) { From 876f10d40ed421ba77c97c3ec6a4cf9c9dbf0ad5 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 06:55:35 +0000 Subject: [PATCH 07/11] fix(server): bookmarklet ingest needs unconditional CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /v0/management/oauth/bookmarklet/ingest endpoint is the only management route that legitimately accepts cross-origin requests — operators run the bookmarklet from the provider's own site (chatglm.cn, chat.deepseek.com, ...) and POST the captured token back here. The endpoint authenticates with a one-shot ticket baked into the bookmarklet, not with cookies, so wildcard CORS is safe. The route handler in routes/management/oauth/bookmarklet.ts already sets Access-Control-Allow-Origin: * for itself, but the global CORS middleware in setupMiddleware() runs first and: 1. Refuses to emit CORS headers for /v0/management/* unless the Origin is in CHAT2API_CORS_ORIGINS (which obviously won't list every AI provider's domain). 2. Short-circuits OPTIONS with status 204 BEFORE the request reaches the router, so the route-level CORS setup never runs for preflight. Net result: GLM bookmarklet (and every other provider's) sees the preflight come back without Access-Control-Allow-Origin and aborts with 'Failed to fetch'. Special-case the ingest path in the global middleware so it gets Allow-Origin:* on both the preflight and the POST. Other management routes still go through the strict whitelist. --- backend/proxy/server.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/proxy/server.ts b/backend/proxy/server.ts index 7f3e8f9..f82fc71 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. From dd040368a79d2d967141384a3409ff0f9250e295 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 07:03:35 +0000 Subject: [PATCH 08/11] fix(addAccount): map OAuth credentials with key aliases per provider After the bookmarklet flow finishes, the backend GLM adapter returns credentials as { refreshToken, accessToken } (camelCase), but the form field is named 'refresh_token' (snake_case) and the old mapper was looking for 'chatglm_refresh_token' (the raw cookie name) - so the mapping silently failed and the UI complained 'fill required field: Refresh Token' even though we already had the token in hand. Replace the per-provider single-key map with a list of candidate keys. For each provider we now try every name the OAuth path may emit (raw cookie names, the bookmarklet's relabelled 'token' field, snake_case, and the camelCase keys the adapters actually return) and pick the first non-empty value. The candidate gets written into the form field the credentialFields config expects. --- .../components/providers/AddAccountDialog.tsx | 127 ++++++++++-------- 1 file changed, 69 insertions(+), 58 deletions(-) diff --git a/frontend/src/components/providers/AddAccountDialog.tsx b/frontend/src/components/providers/AddAccountDialog.tsx index 845a1df..47ff905 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 } From 6d33cdf9f3a3b86a4d08986dda40e738d0e5548a Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 07:12:16 +0000 Subject: [PATCH 09/11] fix(addAccount): ensure credentials fill after tab switch renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth success handler was calling setCredentials() and setActiveTab('manual') in the same synchronous batch. When React renders the tab content for 'manual', the credential fields component mounts fresh — but because the state update for credentials may not have been committed yet when the TabsContent mounts, the fields render as empty. Fix: switch tab first (so the fields mount), then set credentials in a setTimeout(0) microtask so React can flush the tab switch before filling the inputs. Also add console.log so operators can see exactly what the OAuth flow returned and what got mapped. --- .../src/components/providers/AddAccountDialog.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/providers/AddAccountDialog.tsx b/frontend/src/components/providers/AddAccountDialog.tsx index 47ff905..931a680 100644 --- a/frontend/src/components/providers/AddAccountDialog.tsx +++ b/frontend/src/components/providers/AddAccountDialog.tsx @@ -364,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) }} /> )} From b97a5f12d3def2e52908ef46a31e9b2e2bd0a40e Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 07:34:25 +0000 Subject: [PATCH 10/11] feat(oauth): replace bookmarklet drag-drop with one-click console script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old BookmarkletPanel required users to drag a link into their bookmark bar, navigate to the provider page, and click the bookmark. This was confusing and often failed due to CORS/mixed-content issues when the backend runs on a different server. Replace with ConsoleScriptPanel — a much simpler flow: 1. Click 'Generate Script' (issues a ticket on the backend) 2. Click 'Copy Script' (copies one-line JS to clipboard) 3. Open provider page (e.g. chatglm.cn), F12 → Console → Paste → Enter 4. Script reads token from cookie/localStorage and POSTs to backend 5. Panel auto-polls and picks up the result Changes: - New: frontend/src/components/oauth/ConsoleScriptPanel.tsx - Modified: TokenExtractionGuide now renders ConsoleScriptPanel instead of BookmarkletPanel as the primary OAuth capture method - Added oauth.console i18n keys (bilingual zh-CN / en-US) with step-by-step instructions - BookmarkletPanel.tsx is kept in the repo (backend ingest endpoint still works) but no longer referenced from the UI The backend bookmarklet ingest endpoint is unchanged — the console script uses the exact same ticket/ingest/poll API, just triggered from a pasted script instead of a dragged bookmark. --- .../components/oauth/ConsoleScriptPanel.tsx | 275 ++++++++++++++++++ .../components/oauth/TokenExtractionGuide.tsx | 8 +- frontend/src/i18n/locales/en-US.json | 22 ++ frontend/src/i18n/locales/zh-CN.json | 22 ++ 4 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/oauth/ConsoleScriptPanel.tsx diff --git a/frontend/src/components/oauth/ConsoleScriptPanel.tsx b/frontend/src/components/oauth/ConsoleScriptPanel.tsx new file mode 100644 index 0000000..003734e --- /dev/null +++ b/frontend/src/components/oauth/ConsoleScriptPanel.tsx @@ -0,0 +1,275 @@ +/** + * Console Script Panel + * + * Replaces the old BookmarkletPanel. Instead of dragging a bookmark, + * the user simply: + * 1. Clicks "Generate Script" (issues a ticket on the backend) + * 2. Copies the one-line JS script to clipboard + * 3. Opens the provider's page, presses F12 → Console → Paste → Enter + * 4. Script reads the token and POSTs it to the backend ingest endpoint + * 5. This component polls and auto-completes + * + * Much simpler UX than the bookmarklet drag-drop approach. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + AlertCircle, + CheckCircle2, + ClipboardCopy, + ExternalLink, + Loader2, + Timer, + Terminal, +} from 'lucide-react' +import { ApiService } from '@/services/api' + +interface ConsoleScriptPanelProps { + providerId: string + providerType: string + providerName?: string + loginUrl: string + onSuccess: ( + credentials: Record, + accountInfo?: { name?: string; email?: string }, + ) => void +} + +type Phase = 'idle' | 'issuing' | 'waiting' | 'success' | 'error' + +const POLL_INTERVAL_MS = 2000 + +export function ConsoleScriptPanel({ + providerId, + providerType, + providerName, + loginUrl, + onSuccess, +}: ConsoleScriptPanelProps) { + const { t } = useTranslation() + const [phase, setPhase] = useState('idle') + const [scriptSource, setScriptSource] = useState('') + const [error, setError] = useState('') + const [expiresAt, setExpiresAt] = useState(0) + const [copied, setCopied] = useState(false) + const pollRef = useRef | null>(null) + const ticketRef = useRef('') + + const displayName = providerName || providerType + + // Cleanup on unmount + useEffect(() => { + return () => { + stopPolling() + if (ticketRef.current) { + ApiService.oauth.bookmarklet.cancel(ticketRef.current).catch(() => {}) + } + } + }, []) + + // Countdown timer + const [, forceUpdate] = useState(0) + useEffect(() => { + if (phase !== 'waiting') return + const timer = setInterval(() => forceUpdate((n) => n + 1), 1000) + return () => clearInterval(timer) + }, [phase]) + + const stopPolling = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + }, []) + + const issueTicket = async () => { + setPhase('issuing') + setError('') + setCopied(false) + try { + const data = await ApiService.oauth.bookmarklet.issue(providerId, providerType) + // Use the bookmarklet source but strip the IIFE wrapper for readability + setScriptSource(data.bookmarklet.source) + setExpiresAt(data.expiresAt) + ticketRef.current = data.ticket + setPhase('waiting') + startPolling(data.ticket, data.expiresAt) + } catch (err) { + setError(err instanceof Error ? err.message : t('oauth.console.networkError')) + setPhase('error') + } + } + + const startPolling = (ticketValue: string, expires: number) => { + stopPolling() + pollRef.current = setInterval(async () => { + if (Date.now() > expires) { + stopPolling() + setError(t('oauth.console.expired')) + setPhase('error') + return + } + + try { + const res = await ApiService.oauth.bookmarklet.poll(ticketValue) + if (res.state === 'completed') { + stopPolling() + ticketRef.current = '' + const result = res.result + if (result.success && result.credentials) { + setPhase('success') + onSuccess(result.credentials, result.accountInfo) + } else { + setError(result.error || t('oauth.console.tokenInvalid')) + setPhase('error') + } + } + } catch (err: any) { + if (err?.message?.includes('Ticket')) { + stopPolling() + setError(t('oauth.console.ticketUsed')) + setPhase('error') + } + } + }, POLL_INTERVAL_MS) + } + + const copyScript = async () => { + try { + await navigator.clipboard.writeText(scriptSource) + setCopied(true) + setTimeout(() => setCopied(false), 3000) + } catch { + // Fallback: select text in a temporary textarea + const ta = document.createElement('textarea') + ta.value = scriptSource + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + setCopied(true) + setTimeout(() => setCopied(false), 3000) + } + } + + const timeLeft = Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) + + if (phase === 'idle' || phase === 'issuing') { + return ( +
+

+ {t('oauth.console.description', { provider: displayName })} +

+ +
+ ) + } + + if (phase === 'waiting') { + 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. +
+
+ + {/* Copy script button */} + + + {/* Open login page */} + + + {/* Polling indicator */} +
+ + + {t('oauth.console.waiting')} + + + + {Math.floor(timeLeft / 60)}:{String(timeLeft % 60).padStart(2, '0')} {t('oauth.console.timeLeft')} + +
+
+ ) + } + + if (phase === 'success') { + return ( + + + + {t('oauth.console.success')} + + + ) + } + + // phase === 'error' + return ( +
+ + + {error} + + +
+ ) +} + +export default ConsoleScriptPanel diff --git a/frontend/src/components/oauth/TokenExtractionGuide.tsx b/frontend/src/components/oauth/TokenExtractionGuide.tsx index fa5b42d..726d828 100644 --- a/frontend/src/components/oauth/TokenExtractionGuide.tsx +++ b/frontend/src/components/oauth/TokenExtractionGuide.tsx @@ -28,7 +28,7 @@ import { Loader2, } from 'lucide-react' import { ApiService } from '@/services/api' -import { BookmarkletPanel } from './BookmarkletPanel' +import { ConsoleScriptPanel } from './ConsoleScriptPanel' type StorageType = 'localStorage' | 'cookie' | 'network' @@ -308,8 +308,8 @@ export function TokenExtractionGuide({ return (
- {/* ── Recommended: Bookmarklet ── */} - )} - {t('oauth.bookmarklet.manualFallbackAdvanced')} + {t('oauth.console.manualFallback')} {showManual && ( diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 5d779c5..0b1a487 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -876,6 +876,28 @@ "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 Script\" below, then paste (Ctrl+V) in the Console and press Enter.", + "step4": "You will see \"Chat2API: token sent\" — come back here, the token is captured automatically.", + "copyScript": "Copy Script to Clipboard", + "copied": "Script 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 2b56dee..4c07409 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -876,6 +876,28 @@ "tokenInvalid": "服务端拒绝了 Token", "manualFallback": "手动粘贴 (DevTools)", "manualFallbackAdvanced": "手动从开发者工具粘贴(高级)" + }, + "console": { + "description": "一键脚本自动获取并发送 {{provider}} 的 Token — 只需在浏览器控制台粘贴即可。", + "generate": "生成脚本", + "generating": "正在生成…", + "stepsTitle": "使用方法:", + "step1": "在新标签页中打开 {{provider}} 并确保已登录。", + "step2": "按 F12 打开开发者工具,切换到 Console(控制台)标签页。", + "step3": "点击下方「复制脚本」,然后在控制台中粘贴(Ctrl+V)并按回车。", + "step4": "你会看到弹窗提示「Chat2API: token sent」— 回到这里,Token 已自动获取。", + "copyScript": "复制脚本到剪贴板", + "copied": "脚本已复制!", + "openLogin": "打开 {{host}} 登录页面", + "waiting": "等待 Token 中…", + "timeLeft": "剩余时间", + "success": "Token 已接收并验证,正在保存账户…", + "expired": "脚本已过期,请点击「生成脚本」重新获取。", + "ticketUsed": "脚本已使用或过期。", + "tryAgain": "重试(生成新脚本)", + "networkError": "生成脚本时网络错误", + "tokenInvalid": "服务端拒绝了 Token", + "manualFallback": "或手动粘贴 Token(高级)" } }, "models": { From 215bbd50ba6b0e31e3bc14b90edb9a257cede67f Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 19 May 2026 07:42:21 +0000 Subject: [PATCH 11/11] refactor(oauth): simplify to manual copy-paste one-liner commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ticket/fetch/polling ConsoleScriptPanel with a dead-simple version that just shows a per-provider one-liner JS command: GLM: document.cookie.match(/chatglm_refresh_token=([^;]+)/)?.[1] DeepSeek: localStorage.getItem('userToken') Kimi: document.cookie.match(/kimi-auth=([^;]+)/)?.[1] Qwen: document.cookie.match(/tongyi_sso_ticket=([^;]+)/)?.[1] etc. User flow is now: 1. Click 'Copy Command' in Chat2API 2. Go to provider page → F12 → Console → Paste → Enter 3. Right-click the returned value → 'Copy string' 4. Paste into the token field in Chat2API → Add Account No bookmarklets, no tickets, no auto-POST, no CORS issues. The old BookmarkletPanel and backend ingest endpoint remain in the codebase (they still work) but are no longer referenced from the UI. --- .../components/oauth/ConsoleScriptPanel.tsx | 325 ++++++------------ .../components/oauth/TokenExtractionGuide.tsx | 7 +- frontend/src/i18n/locales/en-US.json | 9 +- frontend/src/i18n/locales/zh-CN.json | 11 +- 4 files changed, 124 insertions(+), 228 deletions(-) diff --git a/frontend/src/components/oauth/ConsoleScriptPanel.tsx b/frontend/src/components/oauth/ConsoleScriptPanel.tsx index 003734e..c72ccd0 100644 --- a/frontend/src/components/oauth/ConsoleScriptPanel.tsx +++ b/frontend/src/components/oauth/ConsoleScriptPanel.tsx @@ -1,151 +1,101 @@ /** * Console Script Panel * - * Replaces the old BookmarkletPanel. Instead of dragging a bookmark, - * the user simply: - * 1. Clicks "Generate Script" (issues a ticket on the backend) - * 2. Copies the one-line JS script to clipboard - * 3. Opens the provider's page, presses F12 → Console → Paste → Enter - * 4. Script reads the token and POSTs it to the backend ingest endpoint - * 5. This component polls and auto-completes + * 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. * - * Much simpler UX than the bookmarklet drag-drop approach. + * 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 { useCallback, useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' -import { Alert, AlertDescription } from '@/components/ui/alert' import { - AlertCircle, CheckCircle2, ClipboardCopy, ExternalLink, - Loader2, - Timer, Terminal, } from 'lucide-react' -import { ApiService } from '@/services/api' + +/** + * 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 - loginUrl: string - onSuccess: ( - credentials: Record, - accountInfo?: { name?: string; email?: string }, - ) => void } -type Phase = 'idle' | 'issuing' | 'waiting' | 'success' | 'error' - -const POLL_INTERVAL_MS = 2000 - export function ConsoleScriptPanel({ providerId, providerType, providerName, - loginUrl, - onSuccess, }: ConsoleScriptPanelProps) { const { t } = useTranslation() - const [phase, setPhase] = useState('idle') - const [scriptSource, setScriptSource] = useState('') - const [error, setError] = useState('') - const [expiresAt, setExpiresAt] = useState(0) const [copied, setCopied] = useState(false) - const pollRef = useRef | null>(null) - const ticketRef = useRef('') + const config = PROVIDER_COMMANDS[providerType] || PROVIDER_COMMANDS[providerId] const displayName = providerName || providerType - // Cleanup on unmount - useEffect(() => { - return () => { - stopPolling() - if (ticketRef.current) { - ApiService.oauth.bookmarklet.cancel(ticketRef.current).catch(() => {}) - } - } - }, []) - - // Countdown timer - const [, forceUpdate] = useState(0) - useEffect(() => { - if (phase !== 'waiting') return - const timer = setInterval(() => forceUpdate((n) => n + 1), 1000) - return () => clearInterval(timer) - }, [phase]) - - const stopPolling = useCallback(() => { - if (pollRef.current) { - clearInterval(pollRef.current) - pollRef.current = null - } - }, []) - - const issueTicket = async () => { - setPhase('issuing') - setError('') - setCopied(false) - try { - const data = await ApiService.oauth.bookmarklet.issue(providerId, providerType) - // Use the bookmarklet source but strip the IIFE wrapper for readability - setScriptSource(data.bookmarklet.source) - setExpiresAt(data.expiresAt) - ticketRef.current = data.ticket - setPhase('waiting') - startPolling(data.ticket, data.expiresAt) - } catch (err) { - setError(err instanceof Error ? err.message : t('oauth.console.networkError')) - setPhase('error') - } + if (!config) { + return null } - const startPolling = (ticketValue: string, expires: number) => { - stopPolling() - pollRef.current = setInterval(async () => { - if (Date.now() > expires) { - stopPolling() - setError(t('oauth.console.expired')) - setPhase('error') - return - } - - try { - const res = await ApiService.oauth.bookmarklet.poll(ticketValue) - if (res.state === 'completed') { - stopPolling() - ticketRef.current = '' - const result = res.result - if (result.success && result.credentials) { - setPhase('success') - onSuccess(result.credentials, result.accountInfo) - } else { - setError(result.error || t('oauth.console.tokenInvalid')) - setPhase('error') - } - } - } catch (err: any) { - if (err?.message?.includes('Ticket')) { - stopPolling() - setError(t('oauth.console.ticketUsed')) - setPhase('error') - } - } - }, POLL_INTERVAL_MS) - } - - const copyScript = async () => { + const copyCommand = async () => { try { - await navigator.clipboard.writeText(scriptSource) + await navigator.clipboard.writeText(config.command) setCopied(true) setTimeout(() => setCopied(false), 3000) } catch { - // Fallback: select text in a temporary textarea + // Fallback const ta = document.createElement('textarea') - ta.value = scriptSource + ta.value = config.command document.body.appendChild(ta) ta.select() document.execCommand('copy') @@ -155,118 +105,61 @@ export function ConsoleScriptPanel({ } } - const timeLeft = Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) - - if (phase === 'idle' || phase === 'issuing') { - return ( -
-

- {t('oauth.console.description', { provider: displayName })} + 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. +
- ) - } - - if (phase === 'waiting') { - 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. -
-
- - {/* Copy script button */} - - {/* Open login page */} - - - {/* Polling indicator */} -
- - - {t('oauth.console.waiting')} - - - - {Math.floor(timeLeft / 60)}:{String(timeLeft % 60).padStart(2, '0')} {t('oauth.console.timeLeft')} - -
+ {/* Command display */} +
+

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

+ + {config.command} +
- ) - } - if (phase === 'success') { - return ( - - - - {t('oauth.console.success')} - - - ) - } + {/* Copy button */} + - // phase === 'error' - return ( -
- - - {error} - -
) diff --git a/frontend/src/components/oauth/TokenExtractionGuide.tsx b/frontend/src/components/oauth/TokenExtractionGuide.tsx index 726d828..4e5072a 100644 --- a/frontend/src/components/oauth/TokenExtractionGuide.tsx +++ b/frontend/src/components/oauth/TokenExtractionGuide.tsx @@ -30,6 +30,9 @@ import { import { ApiService } from '@/services/api' 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' interface ProviderGuide { @@ -308,13 +311,11 @@ export function TokenExtractionGuide({ return (
- {/* ── Recommended: Console Script (one-click copy-paste) ── */} + {/* ── Recommended: Simple Console command ── */} {/* ── Fallback: Manual DevTools paste (collapsed by default) ── */} diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 0b1a487..4775b33 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -884,10 +884,11 @@ "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 Script\" below, then paste (Ctrl+V) in the Console and press Enter.", - "step4": "You will see \"Chat2API: token sent\" — come back here, the token is captured automatically.", - "copyScript": "Copy Script to Clipboard", - "copied": "Script Copied!", + "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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 4c07409..f060217 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -878,16 +878,17 @@ "manualFallbackAdvanced": "手动从开发者工具粘贴(高级)" }, "console": { - "description": "一键脚本自动获取并发送 {{provider}} 的 Token — 只需在浏览器控制台粘贴即可。", + "description": "一键脚本自动获取 {{provider}} 的 Token — 只需在浏览器控制台粘贴即可。", "generate": "生成脚本", "generating": "正在生成…", "stepsTitle": "使用方法:", "step1": "在新标签页中打开 {{provider}} 并确保已登录。", "step2": "按 F12 打开开发者工具,切换到 Console(控制台)标签页。", - "step3": "点击下方「复制脚本」,然后在控制台中粘贴(Ctrl+V)并按回车。", - "step4": "你会看到弹窗提示「Chat2API: token sent」— 回到这里,Token 已自动获取。", - "copyScript": "复制脚本到剪贴板", - "copied": "脚本已复制!", + "step3": "点击下方「复制命令」,然后在控制台中粘贴(Ctrl+V)并按回车。", + "step4": "右键点击返回的值 → 「Copy string」→ 粘贴到上方的令牌输入框中。", + "copyCommand": "复制命令到剪贴板", + "commandLabel": "控制台命令:", + "copied": "命令已复制!", "openLogin": "打开 {{host}} 登录页面", "waiting": "等待 Token 中…", "timeLeft": "剩余时间",