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