diff --git a/src/main/backend/__tests__/tokenUsage.test.ts b/src/main/backend/__tests__/tokenUsage.test.ts new file mode 100644 index 0000000..7b267ab --- /dev/null +++ b/src/main/backend/__tests__/tokenUsage.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { normalizeUsage } from '../tokenUsage' + +describe('normalizeUsage', () => { + it('normalizes OpenAI-compatible snake_case usage', () => { + expect(normalizeUsage({ + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + })).toEqual({ + promptTokens: 100, + completionTokens: 25, + totalTokens: 125, + }) + }) + + it('normalizes input/output token usage fields', () => { + expect(normalizeUsage({ + input_tokens: 120, + output_tokens: 30, + })).toEqual({ + promptTokens: 120, + completionTokens: 30, + totalTokens: 150, + }) + }) + + it('normalizes camelCase usage fields', () => { + expect(normalizeUsage({ + promptTokens: 80, + completionTokens: 20, + totalTokens: 100, + })).toEqual({ + promptTokens: 80, + completionTokens: 20, + totalTokens: 100, + }) + }) + + it('calculates total when prompt and completion tokens are present', () => { + expect(normalizeUsage({ + prompt_tokens: 60, + completion_tokens: 15, + })).toEqual({ + promptTokens: 60, + completionTokens: 15, + totalTokens: 75, + }) + }) + + it('keeps zero token counts', () => { + expect(normalizeUsage({ + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + })).toEqual({ + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }) + }) + + it('returns undefined for missing or invalid usage', () => { + expect(normalizeUsage(null)).toBeUndefined() + expect(normalizeUsage('usage')).toBeUndefined() + expect(normalizeUsage([])).toBeUndefined() + expect(normalizeUsage({})).toBeUndefined() + expect(normalizeUsage({ prompt_tokens: '100' })).toBeUndefined() + }) +}) diff --git a/src/main/backend/deepseekClient.ts b/src/main/backend/deepseekClient.ts index cd7dec2..347422a 100644 --- a/src/main/backend/deepseekClient.ts +++ b/src/main/backend/deepseekClient.ts @@ -1,8 +1,16 @@ import { AppError, ErrorCodes } from './errors' import { getActiveSettings } from './settingsStore' +import { normalizeUsage, TokenUsage } from './tokenUsage' interface ProviderConfig { baseURL: string + includeStreamUsage?: boolean +} + +export interface ChatCompletionResult { + content: string + usage?: TokenUsage + rawUsage?: unknown } interface JiekouModelConfig { @@ -29,12 +37,12 @@ interface MimoModelConfig { } const PROVIDER_CONFIGS: Record = { - deepseek: { baseURL: 'https://api.deepseek.com/v1' }, + deepseek: { baseURL: 'https://api.deepseek.com/v1', includeStreamUsage: true }, jiekou: { baseURL: 'https://api.jiekou.ai/openai' }, minimax: { baseURL: 'https://api.minimaxi.com/v1' }, glm: { baseURL: 'https://open.bigmodel.cn/api/paas/v4' }, mimo: { baseURL: 'https://api.xiaomimimo.com/v1' }, - kimi: { baseURL: 'https://api.moonshot.cn/v1' }, + kimi: { baseURL: 'https://api.moonshot.cn/v1', includeStreamUsage: true }, } const JIEKOU_MODEL_CONFIGS: Record = { @@ -100,8 +108,9 @@ export function loadConfig() { export async function callDeepSeek( messages: { role: 'system' | 'user' | 'assistant'; content: string }[], - onUpdate?: (chunk: string) => void -): Promise { + onUpdate?: (chunk: string) => void, + signal?: AbortSignal +): Promise { const config = loadConfig() const providerCfg = PROVIDER_CONFIGS[config.provider] @@ -110,7 +119,17 @@ export async function callDeepSeek( } const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 120_000) + let timedOut = false + const abortFromSignal = () => controller.abort() + if (signal?.aborted) { + controller.abort() + } else { + signal?.addEventListener('abort', abortFromSignal, { once: true }) + } + const timeout = setTimeout(() => { + timedOut = true + controller.abort() + }, 120_000) try { const modelCfg = config.provider === 'jiekou' @@ -141,12 +160,17 @@ export async function callDeepSeek( presence_penalty?: number stop?: null thinking?: { type: string } + stream_options?: { include_usage: boolean } } = { model: config.model, messages, stream: true, } + if (providerCfg.includeStreamUsage) { + requestBody.stream_options = { include_usage: true } + } + if (modelCfg && 'tokenParam' in modelCfg && modelCfg.tokenParam && modelCfg.maxTokens) { requestBody[modelCfg.tokenParam] = modelCfg.maxTokens } else if (modelCfg?.maxTokens) { @@ -210,6 +234,8 @@ export async function callDeepSeek( } let result = '' + let usage: TokenUsage | undefined + let rawUsage: unknown const decoder = new TextDecoder() let buffer = '' @@ -229,6 +255,11 @@ export async function callDeepSeek( try { const json = JSON.parse(payload) + if (json.usage) { + rawUsage = json.usage + usage = normalizeUsage(json.usage) ?? usage + } + const content = json.choices?.[0]?.delta?.content || '' if (content) { result += content @@ -244,11 +275,16 @@ export async function callDeepSeek( throw new AppError(ErrorCodes.API_RESPONSE_INVALID, 'API returned empty response') } - return result + signal?.removeEventListener('abort', abortFromSignal) + return { content: result, usage, rawUsage } } catch (err) { clearTimeout(timeout) + signal?.removeEventListener('abort', abortFromSignal) if (err instanceof AppError) throw err if ((err as Error).name === 'AbortError') { + if (!timedOut && signal?.aborted) { + throw new AppError(ErrorCodes.ANALYSIS_CANCELLED, 'Analysis was cancelled by the user.') + } throw new AppError(ErrorCodes.API_TIMEOUT, 'Request timed out after 120 seconds. The paper may be too long.') } throw new AppError( diff --git a/src/main/backend/errors.ts b/src/main/backend/errors.ts index ed18054..b87203e 100644 --- a/src/main/backend/errors.ts +++ b/src/main/backend/errors.ts @@ -1,3 +1,5 @@ +import { TokenUsage } from './tokenUsage' + export const ErrorCodes = { PDF_NOT_FOUND: 'PDF_NOT_FOUND', PDF_INVALID: 'PDF_INVALID', @@ -10,6 +12,7 @@ export const ErrorCodes = { API_NETWORK_ERROR: 'API_NETWORK_ERROR', API_RESPONSE_INVALID: 'API_RESPONSE_INVALID', ANALYSIS_FAILED: 'ANALYSIS_FAILED', + ANALYSIS_CANCELLED: 'ANALYSIS_CANCELLED', NO_CODE_CACHE: 'NO_CODE_CACHE', EXPORT_FAILED: 'EXPORT_FAILED', } as const @@ -34,12 +37,18 @@ export type AnalysisProgress = export type AnalysisResult = | { ok: true - summary: string - hasCoreCode: boolean + result: { + summary: string + hasCoreCode: boolean + } + usage?: TokenUsage + rawUsage?: unknown } | { ok: false error: { code: string; message: string; detail?: string } + usage?: TokenUsage + rawUsage?: unknown } export type ExportResult = diff --git a/src/main/backend/paperAnalyzer.ts b/src/main/backend/paperAnalyzer.ts index ab915e3..5a65bfb 100644 --- a/src/main/backend/paperAnalyzer.ts +++ b/src/main/backend/paperAnalyzer.ts @@ -4,6 +4,7 @@ import { buildCombinedAnalysisPrompt } from './promptBuilder' import { cacheCodeBundle, clearCache, CodeBlueprint, GeneratedFile } from './codeCache' import { AnalysisProgress, AnalysisResult, AppError, ErrorCodes } from './errors' import { getActiveSettings } from './settingsStore' +import { TokenUsage } from './tokenUsage' import { BLUEPRINT_START, BLUEPRINT_END, extractJsonObject, parseCodeBlueprint, validateFilesAgainstBlueprint, validateGeneratedFilePath } from './codeBlueprint' const SUMMARY_START = '' @@ -89,7 +90,8 @@ export function parseCodeDecision(raw: string): { needed: boolean; reason?: stri export async function analyzePaper( pdfPath: string, onProgress: (progress: AnalysisProgress) => void, - onSummaryChunk?: (chunk: string) => void + onSummaryChunk?: (chunk: string) => void, + signal?: AbortSignal ): Promise { let language = 'zh-CN' try { @@ -97,11 +99,20 @@ export async function analyzePaper( } catch {} const msg = (zh: string, en: string) => language === 'en-US' ? en : zh + let usage: TokenUsage | undefined + let rawUsage: unknown + const throwIfCancelled = () => { + if (signal?.aborted) { + throw new AppError(ErrorCodes.ANALYSIS_CANCELLED, msg('分析已取消', 'Analysis was cancelled')) + } + } try { + throwIfCancelled() clearCache() onProgress({ stage: 'parsing', message: msg('读取 PDF 文件...', 'Reading PDF file...') }) const { text, pageCount } = await parsePDF(pdfPath) + throwIfCancelled() onProgress({ stage: 'parsing', message: msg(`PDF 解析完成 (${pageCount} 页)`, `PDF parsed (${pageCount} pages)`) }) onProgress({ stage: 'summarizing', message: msg('正在分析论文结构并生成总结...', 'Analyzing paper structure and generating summary...') }) @@ -113,7 +124,7 @@ export async function analyzePaper( let blueprintProgressSent = false let bundleProgressSent = false - await callDeepSeek( + const llmResult = await callDeepSeek( [ { role: 'system', content: analysisPrompt.system }, { role: 'user', content: analysisPrompt.user }, @@ -154,8 +165,11 @@ export async function analyzePaper( bundleProgressSent = true onProgress({ stage: 'generating_code', message: msg('正在按蓝图生成核心代码文件...', 'Generating core code files from blueprint...') }) } - } + }, + signal ) + usage = llmResult.usage + rawUsage = llmResult.rawUsage const cleanedSummary = extractTaggedContent(rawOutput, SUMMARY_START, SUMMARY_END, '模型返回缺少论文总结区块') const decisionContent = extractTaggedContent(rawOutput, DECISION_START, DECISION_END, '模型返回缺少代码生成决策') @@ -190,19 +204,27 @@ export async function analyzePaper( return { ok: true, - summary: cleanedSummary.trim(), - hasCoreCode, + result: { + summary: cleanedSummary.trim(), + hasCoreCode, + }, + usage, + rawUsage, } } catch (err) { if (err instanceof AppError) { return { ok: false, error: { code: err.code, message: err.message, detail: err.detail }, + usage, + rawUsage, } } return { ok: false, error: { code: ErrorCodes.ANALYSIS_FAILED, message: msg('分析过程中发生未知错误', 'An unknown error occurred during analysis'), detail: (err as Error).message }, + usage, + rawUsage, } } } diff --git a/src/main/backend/tokenUsage.ts b/src/main/backend/tokenUsage.ts new file mode 100644 index 0000000..394f090 --- /dev/null +++ b/src/main/backend/tokenUsage.ts @@ -0,0 +1,42 @@ +export interface TokenUsage { + promptTokens?: number + completionTokens?: number + totalTokens?: number +} + +function readTokenCount(raw: Record, keys: string[]): number | undefined { + for (const key of keys) { + const value = raw[key] + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return value + } + } + + return undefined +} + +export function normalizeUsage(rawUsage: unknown): TokenUsage | undefined { + if (rawUsage === null || typeof rawUsage !== 'object' || Array.isArray(rawUsage)) { + return undefined + } + + const raw = rawUsage as Record + const promptTokens = readTokenCount(raw, ['prompt_tokens', 'input_tokens', 'promptTokens', 'inputTokens']) + const completionTokens = readTokenCount(raw, ['completion_tokens', 'output_tokens', 'completionTokens', 'outputTokens']) + const providedTotalTokens = readTokenCount(raw, ['total_tokens', 'totalTokens']) + const totalTokens = providedTotalTokens ?? ( + promptTokens !== undefined && completionTokens !== undefined + ? promptTokens + completionTokens + : undefined + ) + + if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined) { + return undefined + } + + return { + promptTokens, + completionTokens, + totalTokens, + } +} diff --git a/src/main/main.ts b/src/main/main.ts index 01428e6..481dfe4 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3,6 +3,7 @@ import * as path from 'path' import { getActiveSettings, saveSettingsPatch, SettingsPatch } from './backend/settingsStore' let mainWindow: BrowserWindow | null = null +let currentAnalysisController: AbortController | null = null function createWindow() { mainWindow = new BrowserWindow({ @@ -54,6 +55,9 @@ ipcMain.handle('get-settings', async () => { ipcMain.handle('analyze-paper', async (_event, pdfPath: string) => { if (!mainWindow) return { ok: false, error: { code: 'NO_WINDOW', message: 'Window not available' } } + currentAnalysisController?.abort() + const controller = new AbortController() + currentAnalysisController = controller const sendProgress = (progress: { stage: string; message: string }) => { mainWindow?.webContents.send('analysis-progress', progress) @@ -64,7 +68,19 @@ ipcMain.handle('analyze-paper', async (_event, pdfPath: string) => { } const { analyzePaper } = await import('./backend/paperAnalyzer') - return analyzePaper(pdfPath, sendProgress, sendSummaryChunk) + try { + return await analyzePaper(pdfPath, sendProgress, sendSummaryChunk, controller.signal) + } finally { + if (currentAnalysisController === controller) { + currentAnalysisController = null + } + } +}) + +ipcMain.handle('cancel-analysis', async () => { + currentAnalysisController?.abort() + currentAnalysisController = null + return true }) ipcMain.handle('download-core-code', async () => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 9bc49d1..93c011e 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('save-settings', settings), getSettings: () => ipcRenderer.invoke('get-settings'), analyzePaper: (pdfPath: string) => ipcRenderer.invoke('analyze-paper', pdfPath), + cancelAnalysis: () => ipcRenderer.invoke('cancel-analysis'), downloadCoreCode: () => ipcRenderer.invoke('download-core-code'), onAnalysisProgress: (callback: (progress: { stage: string; message: string }) => void) => { const handler = (_event: Electron.IpcRendererEvent, progress: { stage: string; message: string }) => callback(progress) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index aa517a3..ee6f013 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -11,6 +11,11 @@ export default function App() { const [result, setResult] = useState<{ summary: string; hasCoreCode: boolean } | null>(null) const [streamingSummary, setStreamingSummary] = useState('') const [error, setError] = useState(null) + const [analysisStatus, setAnalysisStatus] = useState('idle') + const [elapsedTime, setElapsedTime] = useState(0) + const [startedAt, setStartedAt] = useState(null) + const [finishedAt, setFinishedAt] = useState(null) + const [tokenUsage, setTokenUsage] = useState(null) const [apiKeyConfigured, setApiKeyConfigured] = useState(false) const [language, setLanguage] = useState('zh-CN') const [showCompletionDialog, setShowCompletionDialog] = useState(false) @@ -24,6 +29,27 @@ export default function App() { return () => cleanupRef.current?.() }, []) + useEffect(() => { + if (startedAt === null || finishedAt !== null) return + + const updateElapsed = () => { + setElapsedTime(Math.floor((Date.now() - startedAt) / 1000)) + } + + updateElapsed() + const timer = window.setInterval(updateElapsed, 1000) + + return () => window.clearInterval(timer) + }, [startedAt, finishedAt]) + + const resetAnalysisMeta = () => { + setAnalysisStatus('idle') + setElapsedTime(0) + setStartedAt(null) + setFinishedAt(null) + setTokenUsage(null) + } + const handleSelectPDF = async () => { const path = await window.electronAPI.selectPDF() if (path) { @@ -31,6 +57,7 @@ export default function App() { setResult(null) setStreamingSummary('') setError(null) + resetAnalysisMeta() setShowCompletionDialog(false) } } @@ -40,47 +67,78 @@ export default function App() { setResult(null) setStreamingSummary('') setError(null) + resetAnalysisMeta() setShowCompletionDialog(false) } const handleAnalyze = async () => { if (!pdfPath) return + const started = Date.now() setAnalyzing(true) setProgress([]) setResult(null) setStreamingSummary('') setError(null) + setAnalysisStatus('parsing') + setElapsedTime(0) + setStartedAt(started) + setFinishedAt(null) + setTokenUsage(null) setShowCompletionDialog(false) - const removeListener = window.electronAPI.onAnalysisProgress((p) => { + let removeListener: (() => void) | null = window.electronAPI.onAnalysisProgress((p) => { + if (p.stage === 'parsing') { + setAnalysisStatus('parsing') + } else if (p.stage === 'summarizing' || p.stage === 'generating_code') { + setAnalysisStatus('analyzing') + } + setProgress((prev) => { const exists = prev.some((x) => x.stage === p.stage && x.message === p.message) return exists ? prev : [...prev, p] }) }) - const removeSummaryListener = window.electronAPI.onSummaryChunk((chunk) => { + let removeSummaryListener: (() => void) | null = window.electronAPI.onSummaryChunk((chunk) => { setStreamingSummary((prev) => prev + chunk) }) cleanupRef.current = () => { - removeListener() - removeSummaryListener() + removeListener?.() + removeSummaryListener?.() } - const res = await window.electronAPI.analyzePaper(pdfPath) - removeListener() - removeSummaryListener() - cleanupRef.current = null + try { + const res = await window.electronAPI.analyzePaper(pdfPath) + const ended = Date.now() + setFinishedAt(ended) + setElapsedTime(Math.floor((ended - started) / 1000)) + setAnalyzing(false) - setAnalyzing(false) - - if (res.ok) { - setResult({ summary: res.summary, hasCoreCode: res.hasCoreCode }) - setStreamingSummary(res.summary) - setShowCompletionDialog(true) - } else { - const detail = res.error.detail?.trim() - setError(detail ? `${res.error.message}\n\n${detail}` : res.error.message) + if (res.ok) { + setAnalysisStatus('success') + setResult(res.result) + setStreamingSummary(res.result.summary) + setTokenUsage(res.usage ?? null) + setShowCompletionDialog(true) + } else { + setAnalysisStatus('error') + setTokenUsage(res.usage ?? null) + const detail = res.error.detail?.trim() + setError(detail ? `${res.error.message}\n\n${detail}` : res.error.message) + } + } catch (err) { + const ended = Date.now() + setFinishedAt(ended) + setElapsedTime(Math.floor((ended - started) / 1000)) + setAnalyzing(false) + setAnalysisStatus('error') + setError((err as Error).message) + } finally { + removeListener?.() + removeSummaryListener?.() + removeListener = null + removeSummaryListener = null + cleanupRef.current = null } } @@ -91,6 +149,10 @@ export default function App() { } } + const handleCancelAnalyze = async () => { + await window.electronAPI.cancelAnalysis() + } + const handleLanguageChange = async (lang: Language) => { setLanguage(lang) await window.electronAPI.saveSettings({ language: lang }) @@ -154,6 +216,7 @@ export default function App() { onSelectPDF={handleSelectPDF} onSetPDFPath={handleSetPDFPath} onAnalyze={handleAnalyze} + onCancelAnalyze={handleCancelAnalyze} /> @@ -163,6 +226,9 @@ export default function App() { analyzing={analyzing} streamingSummary={streamingSummary} error={error} + analysisStatus={analysisStatus} + elapsedTime={elapsedTime} + tokenUsage={tokenUsage} apiKeyConfigured={apiKeyConfigured} pdfPath={pdfPath} language={language} diff --git a/src/renderer/components/OutputPanel.tsx b/src/renderer/components/OutputPanel.tsx index d095efd..242c8fb 100644 --- a/src/renderer/components/OutputPanel.tsx +++ b/src/renderer/components/OutputPanel.tsx @@ -12,13 +12,16 @@ interface OutputPanelProps { analyzing: boolean streamingSummary: string error: string | null + analysisStatus: AnalysisStatus + elapsedTime: number + tokenUsage: TokenUsage | null apiKeyConfigured: boolean pdfPath: string | null language: Language onDownloadCode: () => void } -export default function OutputPanel({ result, analyzing, streamingSummary, error, apiKeyConfigured, pdfPath, language, onDownloadCode }: OutputPanelProps) { +export default function OutputPanel({ result, analyzing, streamingSummary, error, analysisStatus, elapsedTime, tokenUsage, apiKeyConfigured, pdfPath, language, onDownloadCode }: OutputPanelProps) { const scrollContainerRef = useRef(null) const shouldStickToBottomRef = useRef(true) @@ -102,6 +105,13 @@ export default function OutputPanel({ result, analyzing, streamingSummary, error )} + +
0) { + return `${String(hours).padStart(2, '0')}:${mm}:${ss}` + } + + return `${mm}:${ss}` +} + +function formatTokenCount(value: number | undefined): string { + return value === undefined ? '-' : value.toLocaleString() +} + +function getStatusLabel(language: Language, status: AnalysisStatus): string { + const statusKey = status === 'idle' + ? 'result.statusIdle' + : status === 'parsing' + ? 'result.statusParsing' + : status === 'analyzing' + ? 'result.statusAnalyzing' + : status === 'success' + ? 'result.statusSuccess' + : 'result.statusError' + + return t(language, statusKey) +} + +function getTokenTotal(usage: TokenUsage | null): number | undefined { + if (!usage) return undefined + if (usage.totalTokens !== undefined) return usage.totalTokens + if (usage.promptTokens !== undefined && usage.completionTokens !== undefined) { + return usage.promptTokens + usage.completionTokens + } + return undefined +} + +function AnalysisStatusBar({ language, status, elapsedTime, tokenUsage }: { + language: Language + status: AnalysisStatus + elapsedTime: number + tokenUsage: TokenUsage | null +}) { + const totalTokens = getTokenTotal(tokenUsage) + + return ( +
+ + {t(language, 'result.status')}: {getStatusLabel(language, status)} + + | + + {t(language, 'result.elapsed')}: {formatElapsedTime(elapsedTime)} + + | + {tokenUsage ? ( +
+ + {t(language, 'result.tokenTotal')}: {formatTokenCount(totalTokens)} + +
+
{t(language, 'result.promptTokens')}: {formatTokenCount(tokenUsage.promptTokens)}
+
{t(language, 'result.completionTokens')}: {formatTokenCount(tokenUsage.completionTokens)}
+
{t(language, 'result.totalTokens')}: {formatTokenCount(totalTokens)}
+
+
+ ) : ( + {t(language, 'result.tokenTotal')}: {t(language, 'result.tokenUnavailable')} + )} +
+ ) +} + function ErrorCard({ language, error }: { language: Language; error: string }) { const suggestions: string[] = [] const lower = error.toLowerCase() diff --git a/src/renderer/components/PDFPanel.tsx b/src/renderer/components/PDFPanel.tsx index e065f43..6640bb8 100644 --- a/src/renderer/components/PDFPanel.tsx +++ b/src/renderer/components/PDFPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { FileText, Upload, Play, RefreshCw, LoaderCircle } from 'lucide-react' +import { FileText, Upload, Play, RefreshCw, LoaderCircle, Square } from 'lucide-react' import { t, Language } from '../i18n' interface PDFPanelProps { @@ -12,6 +12,7 @@ interface PDFPanelProps { onSelectPDF: () => void onSetPDFPath: (path: string) => void onAnalyze: () => void + onCancelAnalyze: () => void } const cardStyle: React.CSSProperties = { @@ -39,7 +40,7 @@ const dropBase: React.CSSProperties = { gap: 8, } -export default function PDFPanel({ pdfPath, analyzing, progress, apiKeyConfigured, hasResult, language, onSelectPDF, onSetPDFPath, onAnalyze }: PDFPanelProps) { +export default function PDFPanel({ pdfPath, analyzing, progress, apiKeyConfigured, hasResult, language, onSelectPDF, onSetPDFPath, onAnalyze, onCancelAnalyze }: PDFPanelProps) { const [dragging, setDragging] = useState(false) const fileName = pdfPath @@ -92,10 +93,10 @@ export default function PDFPanel({ pdfPath, analyzing, progress, apiKeyConfigure ...(analyzing ? { cursor: 'not-allowed' } : {}), } - const btnDisabled = analyzing || !pdfPath || !apiKeyConfigured + const btnDisabled = !analyzing && (!pdfPath || !apiKeyConfigured) const btnText = analyzing - ? t(language, 'upload.analyzing') + ? t(language, 'upload.cancelAnalysis') : !pdfPath ? t(language, 'upload.startAnalysis') : !apiKeyConfigured @@ -206,14 +207,14 @@ export default function PDFPanel({ pdfPath, analyzing, progress, apiKeyConfigure {t(language, 'upload.selectPDF')}