Forráskód Böngészése

update quickly analysis

Gogs 2 hónapja
szülő
commit
0af61be2d7
3 módosított fájl, 375 hozzáadás és 44 törlés
  1. 2 0
      .gitignore
  2. 373 40
      spacyback/mainspacy.py
  3. 0 4
      spacyback/style_config.py

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+/spacyback/__pycache__/*
+/spacyback/nohup.out

+ 373 - 40
spacyback/mainspacy.py

@@ -3,6 +3,7 @@
 
 import asyncio
 import html
+import json
 import re
 from collections import Counter
 from dataclasses import dataclass, field
@@ -22,7 +23,7 @@ from pydantic import BaseModel, Field
 from spacy.cli import download as spacy_download
 from spacy.language import Language
 from spacy.tokens import Span as SpacySpan, Token as SpacyToken
-from style_config import SENTENCE_HELPER_ENABLED, STYLE_BLOCK
+from style_config import STYLE_BLOCK
 
 BENE_PAR_WARNING: Optional[str] = None
 HAS_BENEPAR: bool = False  # new: track whether benepar was successfully attached
@@ -496,7 +497,10 @@ def _predicate_heads(sentence: SpacySpan) -> List[SpacyToken]:
 
 
 def _add_fixed_phrases(
-    sentence: SpacySpan, mapping: Dict[int, int], spans: List[Span], summary: SentenceSummary
+    sentence: SpacySpan,
+    mapping: Dict[int, int],
+    spans: List[Span],
+    summary: Optional[SentenceSummary] = None,
 ) -> None:
     base = sentence.start_char
     text = sentence.text
@@ -512,16 +516,18 @@ def _add_fixed_phrases(
                 mapping,
                 attrs={"data-phrase": label},
             )
-            summary.connectors.append(label.lower())
+            if summary is not None:
+                summary.connectors.append(label.lower())
 
 
 def annotate_sentence(
     tokens: List[Token],
     sentence: SpacySpan,
     mapping: Dict[int, int],
-) -> Tuple[List[Span], SentenceSummary]:
+    collect_summary: bool = True,
+) -> Tuple[List[Span], Optional[SentenceSummary]]:
     spans: List[Span] = []
-    summary = SentenceSummary(sentence_length=len(sentence))
+    summary = SentenceSummary(sentence_length=len(sentence)) if collect_summary else None
     sent_bounds = char_span_to_token_span(sentence.start_char, sentence.end_char, mapping)
     sent_start_tok, sent_end_tok = sent_bounds
 
@@ -535,18 +541,21 @@ def annotate_sentence(
     for tok in sentence:
         if tok.dep_ in SUBJECT_DEPS:
             add_subtree(tok, "role-subject")
-            summary.subjects.append(_subtree_text(tok))
+            if summary is not None:
+                summary.subjects.append(_subtree_text(tok))
 
     for head in _predicate_heads(sentence):
         start_char, end_char = _predicate_span_bounds(head)
         add_char_based_span(spans, start_char, end_char, "role-predicate", mapping)
         predicate_text = sentence.doc.text[start_char:end_char].strip()
-        summary.predicates.append(predicate_text or head.text)
+        if summary is not None:
+            summary.predicates.append(predicate_text or head.text)
 
     for tok in sentence:
         if tok.dep_ in DIRECT_OBJECT_DEPS:
             add_subtree(tok, "role-object-do")
-            summary.objects.append(_subtree_text(tok))
+            if summary is not None:
+                summary.objects.append(_subtree_text(tok))
             break
 
     io_token = next((tok for tok in sentence if tok.dep_ in INDIRECT_OBJECT_DEPS), None)
@@ -557,19 +566,22 @@ def annotate_sentence(
                 break
     if io_token:
         add_subtree(io_token, "role-object-io")
-        summary.objects.append(_subtree_text(io_token))
+        if summary is not None:
+            summary.objects.append(_subtree_text(io_token))
 
     for tok in sentence:
         if tok.dep_ in COMPLEMENT_DEPS:
             add_subtree(tok, "role-complement")
-            summary.complements.append(_subtree_text(tok))
+            if summary is not None:
+                summary.complements.append(_subtree_text(tok))
             break
 
     for tok in sentence:
         lowered = tok.text.lower()
         if tok.dep_ in {"cc", "mark", "preconj"} or tok.pos_ in {"CCONJ", "SCONJ"}:
             add_token(tok, "role-connector")
-            summary.connectors.append(lowered)
+            if summary is not None:
+                summary.connectors.append(lowered)
         if tok.dep_ == "det" or tok.pos_ == "DET":
             add_token(tok, "role-determiner")
         if tok.dep_ in {"amod", "poss", "compound", "nummod"}:
@@ -635,7 +647,7 @@ def _collect_residual_roles(
     tokens: List[Token],
     spans: List[Span],
     sent_bounds: Tuple[int, int],
-    summary: SentenceSummary,
+    summary: Optional[SentenceSummary],
     mapping: Dict[int, int],
 ) -> None:
     sent_start, sent_end = sent_bounds
@@ -658,7 +670,7 @@ def _collect_residual_roles(
         if not span or not span.text.strip():
             continue
         label = _label_residual_token(span[0])
-        if label and label not in summary.residual_roles:
+        if summary is not None and label and label not in summary.residual_roles:
             summary.residual_roles.append(label)
         if label:
             add_char_based_span(
@@ -783,7 +795,11 @@ def _run_pipeline_without_benepar(text: str) -> "spacy.tokens.Doc":
     return doc
 
 
-def highlight_text_with_spacy(text: str, paragraph_meta: Optional[List[Dict[str, str]]] = None) -> str:
+def highlight_text_with_spacy(
+    text: str,
+    paragraph_meta: Optional[List[Dict[str, str]]] = None,
+    include_helper: bool = False,
+) -> str:
     if NLP is None:
         raise RuntimeError(f"spaCy pipeline unavailable: {NLP_LOAD_ERROR}")
     tokens = tokenize_preserve(text)
@@ -820,22 +836,41 @@ def highlight_text_with_spacy(text: str, paragraph_meta: Optional[List[Dict[str,
         paragraph_counters[current_idx] += 1
         sentence_label = _circled_number(paragraph_counters[current_idx])
 
-        sentence_spans, summary = annotate_sentence(tokens, sent, mapping)
+        sentence_spans, summary = annotate_sentence(tokens, sent, mapping, collect_summary=include_helper)
         sent_bounds = char_span_to_token_span(sent.start_char, sent.end_char, mapping)
         sent_start, sent_end = sent_bounds
         if sent_start >= 0 and sent_end >= 0:
             _collect_residual_roles(sent, tokens, sentence_spans, sent_bounds, summary, mapping)
-            helper_note, is_complex = build_sentence_note(summary)
+            helper_note = ""
+            is_complex = False
+            if include_helper and summary is not None:
+                helper_note, is_complex = build_sentence_note(summary)
             attrs = {
                 "data-sid": sentence_label,
-                "data-note": helper_note,
-                "data-complex": "1" if is_complex else "0",
             }
+            if include_helper:
+                attrs["data-complex"] = "1" if is_complex else "0"
+                attrs["data-note"] = helper_note
             sentence_spans.append(Span(start_token=sent_start, end_token=sent_end, cls="sentence-scope", attrs=attrs))
         spans.extend(sentence_spans)
     return render_with_spans(tokens, spans)
 
 
+def _build_analysis_container(fragment: str, include_helper: bool) -> str:
+    helper_state = "on" if include_helper else "off"
+    return f"<div class='analysis' data-helper='{helper_state}'>{fragment}</div>"
+
+
+def _build_highlighted_html(fragment: str, include_helper: bool) -> str:
+    return f"{STYLE_BLOCK}{_build_analysis_container(fragment, include_helper)}"
+
+
+def _perform_analysis(text: str, include_helper: bool) -> AnalyzeResponse:
+    sanitized_fragment = highlight_text_with_spacy(text, include_helper=include_helper)
+    highlighted_html = _build_highlighted_html(sanitized_fragment, include_helper)
+    return AnalyzeResponse(highlighted_html=highlighted_html)
+
+
 app = FastAPI(title="Grammar Highlight API (spaCy + benepar)")
 app.add_middleware(
     CORSMiddleware,
@@ -852,11 +887,20 @@ async def analyze(req: AnalyzeRequest):
     if text is None or not text.strip():
         raise HTTPException(status_code=400, detail="Text is required")
     try:
-        sanitized_fragment = highlight_text_with_spacy(text)
-        helper_state = "on" if SENTENCE_HELPER_ENABLED else "off"
-        return AnalyzeResponse(
-            highlighted_html=f"{STYLE_BLOCK}<div class='analysis' data-helper='{helper_state}'>{sanitized_fragment}</div>"
-        )
+        return _perform_analysis(text, include_helper=False)
+    except RuntimeError as exc:
+        raise HTTPException(status_code=500, detail=str(exc)) from exc
+    except Exception as exc:  # pragma: no cover - defensive
+        raise HTTPException(status_code=500, detail=f"Analysis failed: {exc}") from exc
+
+
+@app.post("/analyze/detail", response_model=AnalyzeResponse)
+async def analyze_with_helper(req: AnalyzeRequest):
+    text = req.text
+    if text is None or not text.strip():
+        raise HTTPException(status_code=400, detail="Text is required")
+    try:
+        return _perform_analysis(text, include_helper=True)
     except RuntimeError as exc:
         raise HTTPException(status_code=500, detail=str(exc)) from exc
     except Exception as exc:  # pragma: no cover - defensive
@@ -902,6 +946,7 @@ async def proxy(url: Optional[str] = None, show_images: bool = False):
             source_title=title,
             show_images=show_images,
             image_notice=image_notice,
+            source_plaintext=page_text,
         )
         return HTMLResponse(html_body)
     except ValueError as exc:
@@ -960,6 +1005,7 @@ button:disabled { opacity: 0.6; cursor: wait; }
 .tts-controls { margin-top: 0.75rem; display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
 .tts-controls button { margin-top: 0; background: #f97316; }
 .tts-status { font-size: 0.95rem; color: #475569; }
+.sentence-scope.anchor-highlight { outline: 2px dashed #f97316; outline-offset: 2px; }
 </style>
 </head>
 <body>
@@ -973,6 +1019,8 @@ button:disabled { opacity: 0.6; cursor: wait; }
 <div class=\"tts-controls\">
 <button type=\"button\" id=\"tts\">朗读高亮文本</button>
 <button type=\"button\" id=\"tts-selection\">朗读选中文本</button>
+<button type=\"button\" id=\"tts-anchor\" disabled>从点击处朗读</button>
+<button type=\"button\" id=\"tts-toggle\" disabled>暂停播放</button>
 <span class=\"tts-status\" id=\"tts-status\"></span>
 </div>
 <div id=\"result\"></div>
@@ -984,22 +1032,59 @@ const textarea = document.getElementById('text');
 const statusEl = document.getElementById('status');
 const ttsBtn = document.getElementById('tts');
 const ttsSelectionBtn = document.getElementById('tts-selection');
+const ttsAnchorBtn = document.getElementById('tts-anchor');
+const ttsToggleBtn = document.getElementById('tts-toggle');
 const ttsStatus = document.getElementById('tts-status');
 const result = document.getElementById('result');
 const TTS_ENDPOINT = 'http://141.140.15.30:8028/generate';
 let currentAudio = null;
 let queuedAudios = [];
 let streamingFinished = false;
+let lastAnalyzedText = '';
+let anchorSentenceIndex = 0;
+let isPaused = false;
+let hasHighlightContent = false;
 
 function resetUI() {
   result.innerHTML = '';
   statusEl.textContent = '';
   statusEl.classList.remove('err');
   ttsStatus.textContent = '';
+  hasHighlightContent = false;
+  if (ttsAnchorBtn) {
+    ttsAnchorBtn.disabled = true;
+  }
+  resetAnchorState();
   setTtsButtonsDisabled(false);
   resetAudioPlayback();
 }
 
+function getSentenceNodes() {
+  const analysis = result.querySelector('.analysis');
+  return analysis ? Array.from(analysis.querySelectorAll('.sentence-scope')) : [];
+}
+
+function clearAnchorHighlight() {
+  const highlighted = result.querySelectorAll('.sentence-scope.anchor-highlight');
+  highlighted.forEach(el => el.classList.remove('anchor-highlight'));
+}
+
+function resetAnchorState() {
+  anchorSentenceIndex = 0;
+  clearAnchorHighlight();
+}
+
+function setAnchorFromSentence(sentenceEl) {
+  const sentences = getSentenceNodes();
+  const idx = sentences.indexOf(sentenceEl);
+  if (idx === -1) return;
+  anchorSentenceIndex = idx;
+  clearAnchorHighlight();
+  sentenceEl.classList.add('anchor-highlight');
+  const sid = sentenceEl.getAttribute('data-sid') || (idx + 1);
+  ttsStatus.textContent = '已选择第 ' + sid + ' 句作为朗读起点';
+}
+
 btn.addEventListener('click', async () => {
   resetUI();
   const value = textarea.value.trim();
@@ -1026,6 +1111,12 @@ btn.addEventListener('click', async () => {
 
     const data = await response.json();
     result.innerHTML = data.highlighted_html || '';
+    lastAnalyzedText = value;
+    resetAnchorState();
+    hasHighlightContent = true;
+    if (ttsAnchorBtn) {
+      ttsAnchorBtn.disabled = false;
+    }
     statusEl.textContent = '';
   } catch (err) {
     statusEl.textContent = '错误:' + (err.message || 'Unknown error');
@@ -1037,15 +1128,50 @@ btn.addEventListener('click', async () => {
 
 btnClear.addEventListener('click', () => {
   textarea.value = '';
+  lastAnalyzedText = '';
   resetUI();
   textarea.focus();
 });
 
+result.addEventListener('click', event => {
+  if (!hasHighlightContent) {
+    return;
+  }
+  const target = event.target;
+  const isTextNode = typeof Node !== 'undefined' && target && target.nodeType === Node.TEXT_NODE;
+  const base = isTextNode ? target.parentElement : target;
+  if (!base || typeof base.closest !== 'function') {
+    return;
+  }
+  const sentenceEl = base.closest('.sentence-scope');
+  if (sentenceEl) {
+    setAnchorFromSentence(sentenceEl);
+  }
+});
+
 function extractHighlightedText() {
   const highlightRoot = result.querySelector('.analysis');
   return highlightRoot ? highlightRoot.textContent.trim() : '';
 }
 
+function getFullTextForTts() {
+  return lastAnalyzedText || extractHighlightedText();
+}
+
+function extractAnchorText() {
+  const sentences = getSentenceNodes();
+  if (!sentences.length) return '';
+  const start = Math.min(anchorSentenceIndex, sentences.length - 1);
+  const parts = [];
+  for (let i = start; i < sentences.length; i++) {
+    const text = sentences[i].textContent.trim();
+    if (text) {
+      parts.push(text);
+    }
+  }
+  return parts.join(' ');
+}
+
 function setTtsButtonsDisabled(disabled) {
   if (ttsBtn) {
     ttsBtn.disabled = disabled;
@@ -1053,6 +1179,9 @@ function setTtsButtonsDisabled(disabled) {
   if (ttsSelectionBtn) {
     ttsSelectionBtn.disabled = disabled;
   }
+  if (ttsAnchorBtn) {
+    ttsAnchorBtn.disabled = disabled || !hasHighlightContent;
+  }
 }
 
 function resetAudioPlayback() {
@@ -1062,21 +1191,38 @@ function resetAudioPlayback() {
     currentAudio.pause();
     currentAudio = null;
   }
+  resetPauseResumeState();
+}
+
+function setPauseResumeEnabled(enabled) {
+  if (ttsToggleBtn) {
+    ttsToggleBtn.disabled = !enabled;
+  }
+}
+
+function resetPauseResumeState() {
+  isPaused = false;
+  if (ttsToggleBtn) {
+    ttsToggleBtn.textContent = '暂停播放';
+  }
+  setPauseResumeEnabled(false);
 }
 
 function markStreamingFinished() {
   streamingFinished = true;
-  if (!currentAudio && !queuedAudios.length) {
+  if (!currentAudio && !queuedAudios.length && !isPaused) {
     ttsStatus.textContent = '播放完成';
+    setPauseResumeEnabled(false);
   }
 }
 
 function playNextAudioChunk() {
   if (!queuedAudios.length) {
     currentAudio = null;
-    if (streamingFinished) {
+    if (streamingFinished && !isPaused) {
       ttsStatus.textContent = '播放完成';
-    } else {
+      setPauseResumeEnabled(false);
+    } else if (!streamingFinished) {
       ttsStatus.textContent = '等待更多语音...';
     }
     return;
@@ -1084,21 +1230,58 @@ function playNextAudioChunk() {
   const chunk = queuedAudios.shift();
   ttsStatus.textContent = '播放中...';
   currentAudio = new Audio('data:audio/wav;base64,' + chunk);
-  currentAudio.onended = playNextAudioChunk;
+  currentAudio.onended = () => {
+    if (!isPaused) {
+      playNextAudioChunk();
+    }
+  };
   currentAudio.onerror = () => {
     ttsStatus.textContent = '播放失败';
     currentAudio = null;
+    setPauseResumeEnabled(false);
   };
   currentAudio.play().catch(err => {
     ttsStatus.textContent = '自动播放被阻止:' + err.message;
     currentAudio = null;
+    queuedAudios.unshift(chunk);
+    setPauseResumeEnabled(true);
   });
 }
 
 function enqueueAudioChunk(chunk) {
   queuedAudios.push(chunk);
+  setPauseResumeEnabled(true);
+  if (!currentAudio) {
+    playNextAudioChunk();
+  }
+}
+
+function handlePauseResumeToggle() {
+  if (!ttsToggleBtn) {
+    return;
+  }
+  if (!currentAudio && !queuedAudios.length) {
+    ttsStatus.textContent = '暂无可暂停的语音';
+    return;
+  }
   if (!currentAudio) {
     playNextAudioChunk();
+    ttsToggleBtn.textContent = '暂停播放';
+    return;
+  }
+  if (!isPaused) {
+    currentAudio.pause();
+    isPaused = true;
+    ttsToggleBtn.textContent = '继续播放';
+    ttsStatus.textContent = '已暂停';
+  } else {
+    currentAudio.play().then(() => {
+      isPaused = false;
+      ttsToggleBtn.textContent = '暂停播放';
+      ttsStatus.textContent = '播放中...';
+    }).catch(err => {
+      ttsStatus.textContent = '无法继续播放:' + err.message;
+    });
   }
 }
 
@@ -1195,11 +1378,17 @@ function createTtsRequest(textResolver, emptyMessage) {
 }
 
 if (ttsBtn) {
-  ttsBtn.addEventListener('click', createTtsRequest(extractHighlightedText, '请先生成高亮结果'));
+  ttsBtn.addEventListener('click', createTtsRequest(getFullTextForTts, '请先生成高亮结果'));
 }
 if (ttsSelectionBtn) {
   ttsSelectionBtn.addEventListener('click', createTtsRequest(getSelectedPageText, '请先选择要朗读的文本'));
 }
+if (ttsAnchorBtn) {
+  ttsAnchorBtn.addEventListener('click', createTtsRequest(extractAnchorText, '请先在结果中点击句子作为朗读起点'));
+}
+if (ttsToggleBtn) {
+  ttsToggleBtn.addEventListener('click', handlePauseResumeToggle);
+}
 </script>
 </body>
 </html>"""
@@ -1221,6 +1410,7 @@ button { padding: 0.65rem 1.4rem; border: none; border-radius: 999px; background
 .tts-controls { margin-top: 0.5rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; }
 .tts-controls button { background: #f97316; }
 .tts-status { font-size: 0.95rem; color: #475569; }
+.sentence-scope.anchor-highlight { outline: 2px dashed #f97316; outline-offset: 2px; }
 .status { margin-top: 0.25rem; font-size: 0.95rem; }
 .status.err { color: #b00020; }
 .status.ok { color: #059669; }
@@ -1251,9 +1441,12 @@ $status_block
 <div class=\"tts-controls\">
   <button type=\"button\" id=\"proxy-tts-btn\" disabled>朗读高亮文本</button>
   <button type=\"button\" id=\"proxy-tts-selection\">朗读选中文本</button>
+  <button type=\"button\" id=\"proxy-tts-anchor\" disabled>从点击处朗读</button>
+  <button type=\"button\" id=\"proxy-tts-toggle\" disabled>暂停播放</button>
   <span class=\"tts-status\" id=\"proxy-tts-status\"></span>
 </div>
 $result_block
+$source_text_script
 <div class=\"clear-floating\">
   <button type=\"button\" id=\"proxy-reset\">清空并重置</button>
 </div>
@@ -1262,23 +1455,99 @@ $result_block
   var resetBtn = document.getElementById('proxy-reset');
   if (resetBtn) {
     resetBtn.addEventListener('click', function() {
-      // 简单做法:回到无参数的 /proxy,相当于重置页面状态
       window.location.href = '/proxy';
     });
   }
   var ttsBtn = document.getElementById('proxy-tts-btn');
   var ttsSelectionBtn = document.getElementById('proxy-tts-selection');
+  var ttsAnchorBtn = document.getElementById('proxy-tts-anchor');
+  var ttsToggleBtn = document.getElementById('proxy-tts-toggle');
   var ttsStatus = document.getElementById('proxy-tts-status');
+  var analysisRoot = document.querySelector('section.result .analysis');
+  var proxySourceText = window.__proxySourceText || '';
   var TTS_ENDPOINT = 'http://141.140.15.30:8028/generate';
   var currentAudio = null;
   var queuedAudios = [];
   var streamingFinished = false;
+  var anchorSentenceIndex = 0;
+  var isPaused = false;
+
+  if (analysisRoot && ttsBtn) {
+    ttsBtn.disabled = false;
+  }
+  if (analysisRoot && ttsAnchorBtn) {
+    ttsAnchorBtn.disabled = false;
+  }
 
   function extractProxyText() {
     var container = document.querySelector('section.result .analysis');
     return container ? container.textContent.trim() : '';
   }
 
+  function getSentenceNodes() {
+    var container = document.querySelector('section.result .analysis');
+    return container ? Array.from(container.querySelectorAll('.sentence-scope')) : [];
+  }
+
+  function clearAnchorHighlight() {
+    var highlighted = document.querySelectorAll('section.result .sentence-scope.anchor-highlight');
+    highlighted.forEach(function(el) {
+      el.classList.remove('anchor-highlight');
+    });
+  }
+
+  function resetAnchorState() {
+    anchorSentenceIndex = 0;
+    clearAnchorHighlight();
+  }
+
+  function setAnchorFromSentence(sentenceEl) {
+    var sentences = getSentenceNodes();
+    var idx = sentences.indexOf(sentenceEl);
+    if (idx === -1) return;
+    anchorSentenceIndex = idx;
+    clearAnchorHighlight();
+    sentenceEl.classList.add('anchor-highlight');
+    var sid = sentenceEl.getAttribute('data-sid') || (idx + 1);
+    ttsStatus.textContent = '已选择第 ' + sid + ' 句作为朗读起点';
+  }
+
+  resetAnchorState();
+  var resultSection = document.querySelector('section.result');
+  if (resultSection) {
+    resultSection.addEventListener('click', function(evt) {
+      var target = evt.target;
+      var isTextNode = typeof Node !== 'undefined' && target && target.nodeType === Node.TEXT_NODE;
+      var base = isTextNode ? target.parentElement : target;
+      if (!base || typeof base.closest !== 'function') {
+        return;
+      }
+      var sentenceEl = base.closest('.sentence-scope');
+      if (sentenceEl) {
+        setAnchorFromSentence(sentenceEl);
+      }
+    });
+  }
+
+  function getFullTextForTts() {
+    var text = proxySourceText || extractProxyText();
+    return text.trim();
+  }
+
+  function extractAnchorText() {
+    var sentences = getSentenceNodes();
+    if (!sentences.length) return '';
+    var start = Math.min(anchorSentenceIndex, sentences.length - 1);
+    var parts = [];
+    for (var i = start; i < sentences.length; i++) {
+      var text = sentences[i].textContent.trim();
+      if (text) {
+        parts.push(text);
+      }
+    }
+    return parts.join(' ');
+  }
+
   function setTtsButtonsDisabled(disabled) {
     if (ttsBtn) {
       ttsBtn.disabled = disabled;
@@ -1286,6 +1555,9 @@ $result_block
     if (ttsSelectionBtn) {
       ttsSelectionBtn.disabled = disabled;
     }
+    if (ttsAnchorBtn) {
+      ttsAnchorBtn.disabled = disabled || !analysisRoot;
+    }
   }
 
   function resetAudioPlayback() {
@@ -1295,21 +1567,38 @@ $result_block
       currentAudio.pause();
       currentAudio = null;
     }
+    resetPauseResumeState();
+  }
+
+  function setPauseResumeEnabled(enabled) {
+    if (ttsToggleBtn) {
+      ttsToggleBtn.disabled = !enabled;
+    }
+  }
+
+  function resetPauseResumeState() {
+    isPaused = false;
+    if (ttsToggleBtn) {
+      ttsToggleBtn.textContent = '暂停播放';
+    }
+    setPauseResumeEnabled(false);
   }
 
   function markStreamingFinished() {
     streamingFinished = true;
-    if (!currentAudio && !queuedAudios.length) {
+    if (!currentAudio && !queuedAudios.length && !isPaused) {
       ttsStatus.textContent = '播放完成';
+      setPauseResumeEnabled(false);
     }
   }
 
   function playNextAudioChunk() {
     if (!queuedAudios.length) {
       currentAudio = null;
-      if (streamingFinished) {
+      if (streamingFinished && !isPaused) {
         ttsStatus.textContent = '播放完成';
-      } else {
+        setPauseResumeEnabled(false);
+      } else if (!streamingFinished) {
         ttsStatus.textContent = '等待更多语音...';
       }
       return;
@@ -1317,24 +1606,61 @@ $result_block
     var chunk = queuedAudios.shift();
     ttsStatus.textContent = '播放中...';
     currentAudio = new Audio('data:audio/wav;base64,' + chunk);
-    currentAudio.onended = playNextAudioChunk;
+    currentAudio.onended = function() {
+      if (!isPaused) {
+        playNextAudioChunk();
+      }
+    };
     currentAudio.onerror = function() {
       ttsStatus.textContent = '播放失败';
       currentAudio = null;
+      setPauseResumeEnabled(false);
     };
     currentAudio.play().catch(function(err) {
       ttsStatus.textContent = '自动播放被阻止:' + err.message;
       currentAudio = null;
+      queuedAudios.unshift(chunk);
+      setPauseResumeEnabled(true);
     });
   }
 
   function enqueueAudioChunk(chunk) {
     queuedAudios.push(chunk);
+    setPauseResumeEnabled(true);
     if (!currentAudio) {
       playNextAudioChunk();
     }
   }
 
+  function handlePauseResumeToggle() {
+    if (!ttsToggleBtn) {
+      return;
+    }
+    if (!currentAudio && !queuedAudios.length) {
+      ttsStatus.textContent = '暂无可暂停的语音';
+      return;
+    }
+    if (!currentAudio) {
+      playNextAudioChunk();
+      ttsToggleBtn.textContent = '暂停播放';
+      return;
+    }
+    if (!isPaused) {
+      currentAudio.pause();
+      isPaused = true;
+      ttsToggleBtn.textContent = '继续播放';
+      ttsStatus.textContent = '已暂停';
+    } else {
+      currentAudio.play().then(function() {
+        isPaused = false;
+        ttsToggleBtn.textContent = '暂停播放';
+        ttsStatus.textContent = '播放中...';
+      }).catch(function(err) {
+        ttsStatus.textContent = '无法继续播放:' + err.message;
+      });
+    }
+  }
+
   function parseTtsLine(line) {
     try {
       var parsed = JSON.parse(line);
@@ -1430,16 +1756,17 @@ $result_block
   }
 
   if (ttsBtn) {
-    ttsBtn.addEventListener('click', createTtsRequest(extractProxyText, '暂无可朗读内容'));
-    var hasText = !!extractProxyText();
-    ttsBtn.disabled = !hasText;
-    if (!hasText) {
-      ttsStatus.textContent = '高亮完成后可朗读';
-    }
+    ttsBtn.addEventListener('click', createTtsRequest(getFullTextForTts, '请先抓取文章内容再朗读'));
   }
   if (ttsSelectionBtn) {
     ttsSelectionBtn.addEventListener('click', createTtsRequest(getSelectedPageText, '请先选择要朗读的文本'));
   }
+  if (ttsAnchorBtn) {
+    ttsAnchorBtn.addEventListener('click', createTtsRequest(extractAnchorText, '请先点击句子作为朗读起点'));
+  }
+  if (ttsToggleBtn) {
+    ttsToggleBtn.addEventListener('click', handlePauseResumeToggle);
+  }
 })();
 </script>
 </body>
@@ -2238,12 +2565,14 @@ def _render_proxy_page(
     message: Optional[str] = None,
     is_error: bool = False,
     highlight_fragment: Optional[str] = None,
+    helper_enabled: bool = False,
     source_url: Optional[str] = None,
     source_title: Optional[str] = None,
     show_images: bool = False,
     image_notice: Optional[str] = None,
+    source_plaintext: Optional[str] = None,
 ) -> str:
-    helper_state = "on" if SENTENCE_HELPER_ENABLED else "off"
+    helper_state = "on" if helper_enabled else "off"
     status_block = ""
     if message:
         cls = "status err" if is_error else "status ok"
@@ -2251,12 +2580,15 @@ def _render_proxy_page(
 
     style_block = STYLE_BLOCK if highlight_fragment else ""
     result_block = ""
+    source_script = ""
     if highlight_fragment and source_url:
         safe_url = html.escape(source_url, quote=True)
         safe_title = html.escape(source_title or source_url)
         image_hint = ""
         if image_notice:
             image_hint = f"<p class='image-hint'>{html.escape(image_notice)}</p>"
+        if source_plaintext:
+            source_script = f"<script>window.__proxySourceText = {json.dumps(source_plaintext)}</script>"
         result_block = (
             "<section class='result'>"
             f"<div class='source'>原页面:<a href='{safe_url}' target='_blank' rel='noopener'>{safe_title}</a></div>"
@@ -2272,4 +2604,5 @@ def _render_proxy_page(
         status_block=status_block,
         result_block=result_block,
         show_images_checked=show_images_checked,
+        source_text_script=source_script,
     )

+ 0 - 4
spacyback/style_config.py

@@ -24,10 +24,6 @@ def build_style_block(rules: Iterable["StyleRule"]) -> str:
     body = "".join(rule.to_css() for rule in rules if rule.enabled)
     return f"<style>{body}</style>"
 
-
-# 统一的句子辅助说明开关:True 时在句尾打印中文结构摘要。
-SENTENCE_HELPER_ENABLED: bool = False
-
 STYLE_RULES: List[StyleRule] = [
     StyleRule(
         selector=".analysis",