@@ -306,7 +306,7 @@ function chunkText(text, maxChars = 6000) {
306306
307307// ── OpenAI Streaming (direct from browser) ───────────
308308
309- async function * streamChatCompletion ( messages ) {
309+ async function * streamChatCompletion ( messages , signal ) {
310310 const settings = getSettings ( ) ;
311311
312312 if ( ! settings . apiKey ) {
@@ -329,6 +329,7 @@ async function* streamChatCompletion(messages) {
329329 temperature : 0.85 ,
330330 max_tokens : 4096 ,
331331 } ) ,
332+ signal : signal ,
332333 } ) ;
333334
334335 if ( ! response . ok ) {
@@ -466,10 +467,14 @@ function doSaveSettings() {
466467}
467468
468469
470+ let controller = null ;
471+
469472// ── Event Binding ────────────────────────────────────
470473function bindEvents ( ) {
471474 document . getElementById ( "btnConvert" ) . addEventListener ( "click" , startConvert ) ;
475+ document . getElementById ( "btnStop" ) . addEventListener ( "click" , stopConvert ) ;
472476 document . getElementById ( "btnCopy" ) . addEventListener ( "click" , copyNovel ) ;
477+ document . getElementById ( "btnDownload" ) . addEventListener ( "click" , downloadNovel ) ;
473478 document . getElementById ( "btnSettings" ) . addEventListener ( "click" , showSettingsModal ) ;
474479 document . getElementById ( "btnCloseSettings" ) . addEventListener ( "click" , hideSettingsModal ) ;
475480 document . getElementById ( "btnSaveSettings" ) . addEventListener ( "click" , doSaveSettings ) ;
@@ -504,11 +509,13 @@ async function startConvert() {
504509
505510 // UI state
506511 isConverting = true ;
512+ controller = new AbortController ( ) ;
507513 const btn = document . getElementById ( "btnConvert" ) ;
508- btn . querySelector ( ".btn-text" ) . style . display = "none" ;
509- btn . querySelector ( ".btn-loading" ) . style . display = "inline-flex" ;
510- btn . disabled = true ;
511-
514+ const btnStop = document . getElementById ( "btnStop" ) ;
515+
516+ btn . style . display = "none" ;
517+ btnStop . style . display = "inline-flex" ;
518+
512519 const progressArea = document . getElementById ( "progressArea" ) ;
513520 const novelOutput = document . getElementById ( "novelOutput" ) ;
514521 progressArea . style . display = "block" ;
@@ -517,11 +524,13 @@ async function startConvert() {
517524
518525 try {
519526 // Step 1: Fetch paper
527+ if ( controller . signal . aborted ) throw new Error ( "已停止" ) ;
520528 document . getElementById ( "paperTitle" ) . textContent = "正在获取论文..." ;
521529 document . getElementById ( "paperAuthors" ) . textContent = "从 ar5iv 提取全文中..." ;
522530
523531 const paper = await fetchPaper ( url ) ;
524532
533+ if ( controller . signal . aborted ) throw new Error ( "已停止" ) ;
525534 document . getElementById ( "paperTitle" ) . textContent = paper . title ;
526535 document . getElementById ( "paperAuthors" ) . textContent = paper . authors . join ( ", " ) || "Unknown" ;
527536
@@ -540,6 +549,8 @@ async function startConvert() {
540549
541550 // Step 3: Stream each chunk through LLM
542551 for ( let idx = 0 ; idx < totalChunks ; idx ++ ) {
552+ if ( controller . signal . aborted ) throw new Error ( "已停止" ) ;
553+
543554 setChunkStatus ( idx , "active" ) ;
544555 updateProgress ( idx , totalChunks ) ;
545556 ensureChapterEl ( idx , totalChunks ) ;
@@ -550,7 +561,7 @@ async function startConvert() {
550561 ] ;
551562
552563 let chapterText = "" ;
553- for await ( const token of streamChatCompletion ( messages ) ) {
564+ for await ( const token of streamChatCompletion ( messages , controller . signal ) ) {
554565 chapterText += token ;
555566 renderChapterContent ( idx , chapterText ) ;
556567 }
@@ -561,16 +572,58 @@ async function startConvert() {
561572 updateProgress ( totalChunks , totalChunks ) ;
562573
563574 } catch ( err ) {
564- alert ( "转换出错:" + err . message ) ;
565- console . error ( err ) ;
575+ if ( err . name === 'AbortError' || err . message === "已停止" ) {
576+ console . log ( "Conversion stopped by user" ) ;
577+ } else {
578+ alert ( "转换出错:" + err . message ) ;
579+ console . error ( err ) ;
580+ }
566581 } finally {
567582 isConverting = false ;
583+ controller = null ;
584+ btn . style . display = "inline-flex" ;
568585 btn . querySelector ( ".btn-text" ) . style . display = "inline-flex" ;
569586 btn . querySelector ( ".btn-loading" ) . style . display = "none" ;
570587 btn . disabled = false ;
588+ btnStop . style . display = "none" ;
571589 }
572590}
573591
592+ function stopConvert ( ) {
593+ if ( controller ) {
594+ controller . abort ( ) ;
595+ }
596+ }
597+
598+ function downloadNovel ( ) {
599+ const title = document . getElementById ( "novelTitle" ) . textContent . trim ( ) ;
600+ if ( ! title || title === "—" ) {
601+ alert ( "暂无内容可下载" ) ;
602+ return ;
603+ }
604+
605+ let content = `# ${ title } \n\n` ;
606+ const meta = document . getElementById ( "novelGenre" ) . textContent ;
607+ content += `> 风格:${ meta } \n\n---\n\n` ;
608+
609+ const chapters = document . querySelectorAll ( "#novelContent .chapter" ) ;
610+ chapters . forEach ( ch => {
611+ const chTitle = ch . querySelector ( ".chapter-title" ) . innerText ;
612+ const chBody = ch . querySelector ( ".chapter-body" ) . innerText ; // innerText preserves newlines better than textContent usually
613+ content += `## ${ chTitle } \n\n${ chBody } \n\n` ;
614+ } ) ;
615+
616+ const blob = new Blob ( [ content ] , { type : "text/markdown;charset=utf-8" } ) ;
617+ const url = URL . createObjectURL ( blob ) ;
618+ const a = document . createElement ( "a" ) ;
619+ a . href = url ;
620+ a . download = `${ title } .md` ;
621+ document . body . appendChild ( a ) ;
622+ a . click ( ) ;
623+ document . body . removeChild ( a ) ;
624+ URL . revokeObjectURL ( url ) ;
625+ }
626+
574627
575628// ── UI Helpers ───────────────────────────────────────
576629function renderChunkIndicators ( total ) {
0 commit comments