commit 63b1913d9e57bb799b5c4c5539af4a77e7d8157e
Author: Xulilong
Date: Wed Jul 15 17:02:32 2026 +0800
Initial SmartMeeting deployment
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e4a79d1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,42 @@
+# Runtime data
+deploy/data/
+*.db
+*.sqlite
+*.sqlite3
+
+# Logs and build output
+*.log
+logs/
+deploy/build*.log
+
+# Local env files
+.env
+*.env.local
+
+# Backups and generated platform files
+.flatten-backup-*/
+.flatten-backup-local-*/
+.DS_Store
+.claude/
+__pycache__/
+*.py[cod]
+*.bak
+*.bak-*
+
+# Audio/media uploads and test captures
+*.wav
+*.mp3
+*.m4a
+*.webm
+*.ogg
+*.flac
+
+# Node/Python caches
+node_modules/
+.venv/
+venv/
+.pytest_cache/
+.mypy_cache/
+
+# Large local models
+models/
diff --git a/asr/main.py b/asr/main.py
new file mode 100644
index 0000000..5e561b9
--- /dev/null
+++ b/asr/main.py
@@ -0,0 +1,611 @@
+"""
+FunASR Paraformer 语音识别 FastAPI 服务
+
+ASR 模型: speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch
+VAD 模型: speech_fsmn_vad_zh-cn-16k-common-pytorch
+PUNC 模型: punc_ct-transformer_zh-cn-common-vocab272727-pytorch
+端口: 59805
+"""
+
+import os
+import re
+import json
+import tempfile
+import logging
+import subprocess
+import wave
+from typing import Optional
+
+import requests
+import uvicorn
+from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, WebSocket, WebSocketDisconnect
+from fastapi.responses import JSONResponse, PlainTextResponse
+from funasr import AutoModel
+
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+
+
+_CN_NUM = {
+ "零": 0,
+ "一": 1,
+ "幺": 1,
+ "二": 2,
+ "两": 2,
+ "三": 3,
+ "四": 4,
+ "五": 5,
+ "六": 6,
+ "七": 7,
+ "八": 8,
+ "九": 9,
+}
+
+_CN_UNIT = {
+ "十": 10,
+ "百": 100,
+ "千": 1000,
+ "万": 10000,
+ "亿": 100000000,
+}
+
+_CN_PATTERN = re.compile(r"[零一幺二两三四五六七八九十百千万亿]+")
+
+
+def _cn2num(s: str) -> int:
+ total, section, num = 0, 0, 0
+
+ for ch in s:
+ if ch in _CN_NUM:
+ num = _CN_NUM[ch]
+ elif ch in _CN_UNIT:
+ unit = _CN_UNIT[ch]
+ if unit >= 10000:
+ section = (section + (num or 1)) * unit
+ total += section
+ section = 0
+ else:
+ section += (num or 1) * unit
+ num = 0
+
+ return total + section + num
+
+
+def cn_to_arabic(text: str) -> str:
+ return _CN_PATTERN.sub(lambda m: str(_cn2num(m.group())), text)
+
+
+def read_txt_file(file_path: str) -> str:
+ try:
+ with open(file_path, "r", encoding="utf-8") as file:
+ return file.read()
+ except FileNotFoundError:
+ logger.warning(f"找不到热词文件: {file_path}")
+ return ""
+ except Exception as e:
+ logger.warning(f"读取热词文件失败: {e}")
+ return ""
+
+
+app = FastAPI(
+ title="Paraformer ASR API",
+ description="基于 FunASR Paraformer 的中文语音识别服务",
+ version="1.0.0",
+)
+
+
+model = None
+VOICE_DETECTED_URL = os.environ.get("VOICE_DETECTED_URL", "http://127.0.0.1:18000")
+VOICEPRINT_STORE = os.environ.get("VOICEPRINT_STORE", "/tmp/asr_voiceprints.json")
+VOICEPRINT_MATCH_THRESHOLD = float(os.environ.get("VOICEPRINT_MATCH_THRESHOLD", "0.45"))
+
+
+def _voiceprint_key(database_name: str, collection_name: str) -> str:
+ return f"{database_name or 'NB'}::{collection_name or 'voice'}"
+
+
+def _load_voiceprints() -> dict:
+ if not os.path.exists(VOICEPRINT_STORE):
+ return {}
+ try:
+ with open(VOICEPRINT_STORE, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else {}
+ except Exception as e:
+ logger.warning(f"读取声纹库失败,将使用空库: {e}")
+ return {}
+
+
+def _save_voiceprints(data: dict):
+ os.makedirs(os.path.dirname(VOICEPRINT_STORE) or ".", exist_ok=True)
+ tmp_path = f"{VOICEPRINT_STORE}.tmp"
+ with open(tmp_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False)
+ os.replace(tmp_path, VOICEPRINT_STORE)
+
+
+def _extract_embedding(audio_path: str, filename: str) -> list:
+ with open(audio_path, "rb") as f:
+ response = requests.post(
+ f"{VOICE_DETECTED_URL.rstrip('/')}/extract_embedding",
+ files={"wav_file": (filename or "voice.wav", f, "audio/wav")},
+ timeout=120,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ embedding = payload.get("embedding")
+ if not isinstance(embedding, list):
+ raise ValueError("voice-dected 返回中没有有效 embedding")
+ return embedding
+
+
+def _convert_to_embedding_wav(audio_path: str) -> str:
+ output = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
+ output_path = output.name
+ output.close()
+
+ try:
+ subprocess.run(
+ [
+ "ffmpeg",
+ "-y",
+ "-i",
+ audio_path,
+ "-acodec",
+ "pcm_s16le",
+ "-ar",
+ "16000",
+ "-ac",
+ "1",
+ output_path,
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ return output_path
+ except Exception:
+ if os.path.exists(output_path):
+ os.unlink(output_path)
+ raise
+
+
+def _cosine_similarity(a: list, b: list) -> float:
+ if not a or not b or len(a) != len(b):
+ return -1.0
+ dot = sum(float(x) * float(y) for x, y in zip(a, b))
+ norm_a = sum(float(x) * float(x) for x in a) ** 0.5
+ norm_b = sum(float(y) * float(y) for y in b) ** 0.5
+ if norm_a == 0 or norm_b == 0:
+ return -1.0
+ return dot / (norm_a * norm_b)
+
+
+def _match_voiceprint(audio_path: str, filename: str, database_name: str = "NB", collection_name: str = "voice"):
+ store = _load_voiceprints()
+ records = store.get(_voiceprint_key(database_name, collection_name), {})
+ if not records:
+ return None, None
+
+ embedding_path = _convert_to_embedding_wav(audio_path)
+ try:
+ embedding = _extract_embedding(embedding_path, "match.wav")
+ finally:
+ if os.path.exists(embedding_path):
+ os.unlink(embedding_path)
+
+ best_name = None
+ best_score = -1.0
+
+ for name, record in records.items():
+ score = _cosine_similarity(embedding, record.get("embedding", []))
+ if score > best_score:
+ best_name = name
+ best_score = score
+
+ logger.info(f"声纹匹配结果: name={best_name}, score={best_score:.4f}, threshold={VOICEPRINT_MATCH_THRESHOLD}")
+ if best_name and best_score >= VOICEPRINT_MATCH_THRESHOLD:
+ return best_name, best_score
+ return None, best_score
+
+
+def load_model():
+ global model
+
+ if model is None:
+ logger.info("正在加载 Paraformer 模型...")
+ model = AutoModel(
+ model="/app/models/seacomodel",
+ vad_model="/app/models/vadmodel",
+ punc_model="/app/models/ctpuncmodel",
+ )
+ logger.info("模型加载完成")
+
+ return model
+
+
+@app.on_event("startup")
+async def startup_event():
+ load_model()
+
+
+@app.get("/")
+async def root():
+ return {
+ "status": "ok",
+ "message": "Paraformer ASR 服务运行中",
+ }
+
+
+@app.get("/health")
+async def health():
+ return {"status": "healthy"}
+
+
+async def run_asr_websocket(websocket: WebSocket):
+ await websocket.accept(subprotocol="binary")
+ audio_chunks = []
+
+ try:
+ while True:
+ message = await websocket.receive()
+
+ if "bytes" in message and message["bytes"]:
+ audio_chunks.append(message["bytes"])
+ continue
+
+ if "text" in message and message["text"]:
+ try:
+ payload = json.loads(message["text"])
+ except Exception:
+ payload = {}
+
+ if payload.get("type") == "stop" or payload.get("is_speaking") is False:
+ break
+ continue
+
+ if message.get("type") == "websocket.disconnect":
+ break
+ except WebSocketDisconnect:
+ pass
+
+ text = ""
+ tmp_path = None
+ try:
+ if audio_chunks:
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
+ tmp_path = tmp.name
+
+ with wave.open(tmp_path, "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(16000)
+ wav_file.writeframes(b"".join(audio_chunks))
+
+ result = await run_asr(tmp_path, language="zh", response_format="json")
+ if isinstance(result, dict):
+ text = result.get("text", "")
+
+ await websocket.send_json({
+ "mode": "2pass-offline",
+ "text": text,
+ "is_final": True,
+ })
+ finally:
+ if tmp_path and os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ try:
+ await websocket.close()
+ except Exception:
+ pass
+
+
+@app.websocket("/ws/asr")
+async def websocket_asr(websocket: WebSocket):
+ await run_asr_websocket(websocket)
+
+
+@app.websocket("/")
+async def websocket_asr_root(websocket: WebSocket):
+ await run_asr_websocket(websocket)
+
+
+def make_speakr_response(text: str, language: str = "zh", speaker: str = "SPEAKER_00"):
+ text = (text or "").strip()
+ return {
+ "text": text,
+ "language": language,
+ "segments": [
+ {
+ "start": None,
+ "end": None,
+ "text": text,
+ "speaker": speaker or "SPEAKER_00",
+ }
+ ] if text else [],
+ }
+
+
+async def run_asr(
+ audio_path: str,
+ language: str = "zh",
+ response_format: str = "json",
+):
+ try:
+ hotword = read_txt_file("/app/hotword.txt")
+ result = model.generate(input=audio_path, hotword=hotword)
+
+ if result and len(result) > 0:
+ text = result[0].get("text", "")
+ else:
+ text = ""
+
+ text = cn_to_arabic(text)
+ logger.info(f"识别结果: {text}")
+
+ if response_format == "text":
+ return PlainTextResponse(content=text)
+
+ return {
+ "text": text,
+ "language": language,
+ }
+
+ except Exception as e:
+ logger.error(f"识别出错: {e}")
+ raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
+
+
+@app.post("/api/v1/audio/transcriptions")
+async def transcribe(
+ request: Request,
+ file: Optional[UploadFile] = File(None),
+ language: Optional[str] = Form("zh"),
+ response_format: Optional[str] = Form("json"),
+):
+ """
+ 语音转文字接口,兼容两种方式:
+
+ 1. multipart/form-data 上传文件:
+ - file: 音频文件
+ - language: 默认 zh
+ - response_format: json/text
+
+ 2. application/json 传服务器本地文件路径:
+ {
+ "file_path": "/app/audio/test.wav",
+ "language": "zh",
+ "response_format": "json"
+ }
+ """
+
+ tmp_path = None
+ should_delete = False
+
+ try:
+ content_type = request.headers.get("content-type", "").lower()
+
+ if "application/json" in content_type:
+ body = await request.json()
+
+ file_path = body.get("file_path")
+ language = body.get("language", language)
+ response_format = body.get("response_format", response_format)
+
+ if not file_path:
+ raise HTTPException(status_code=400, detail="缺少 file_path")
+
+ if not os.path.exists(file_path):
+ raise HTTPException(status_code=400, detail=f"文件不存在: {file_path}")
+
+ if not os.path.isfile(file_path):
+ raise HTTPException(status_code=400, detail=f"不是有效文件: {file_path}")
+
+ audio_path = file_path
+ logger.info(f"处理服务器本地文件: {audio_path}")
+
+ else:
+ if file is None:
+ raise HTTPException(status_code=400, detail="缺少上传文件 file")
+
+ suffix = os.path.splitext(file.filename or "")[1] or ".wav"
+
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
+ content = await file.read()
+
+ if not content:
+ raise HTTPException(status_code=400, detail="上传文件为空")
+
+ tmp.write(content)
+ tmp_path = tmp.name
+ should_delete = True
+
+ audio_path = tmp_path
+ logger.info(
+ f"处理上传文件: {file.filename}, 大小: {len(content)} bytes, 临时路径: {audio_path}"
+ )
+
+ return await run_asr(
+ audio_path=audio_path,
+ language=language or "zh",
+ response_format=response_format or "json",
+ )
+
+ finally:
+ if should_delete and tmp_path and os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+
+
+@app.post("/v1/audio/transcriptions")
+async def transcribe_v1(
+ request: Request,
+ file: Optional[UploadFile] = File(None),
+ language: Optional[str] = Form("zh"),
+ response_format: Optional[str] = Form("json"),
+):
+ return await transcribe(
+ request=request,
+ file=file,
+ language=language,
+ response_format=response_format,
+ )
+
+
+@app.post("/asr")
+async def transcribe_speakr_compatible(
+ audio_file: UploadFile = File(...),
+ language: Optional[str] = Form("zh"),
+ database_name: Optional[str] = Form("NB"),
+ collection_name: Optional[str] = Form("voice"),
+):
+ """
+ Speakr 兼容接口:
+ - 上传字段:audio_file
+ - 返回字段:segments,其中每个 segment 含 text/speaker/start/end
+ """
+
+ tmp_path = None
+
+ try:
+ suffix = os.path.splitext(audio_file.filename or "")[1] or ".wav"
+
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
+ content = await audio_file.read()
+
+ if not content:
+ raise HTTPException(status_code=400, detail="上传文件为空")
+
+ tmp.write(content)
+ tmp_path = tmp.name
+
+ logger.info(
+ f"处理 Speakr /asr 上传文件: {audio_file.filename}, 大小: {len(content)} bytes, 临时路径: {tmp_path}"
+ )
+
+ result = await run_asr(
+ audio_path=tmp_path,
+ language=language or "zh",
+ response_format="json",
+ )
+ speaker_name = "SPEAKER_00"
+ try:
+ matched_name, score = _match_voiceprint(
+ tmp_path,
+ audio_file.filename or "audio.wav",
+ database_name or "NB",
+ collection_name or "voice",
+ )
+ if matched_name:
+ speaker_name = matched_name
+ except Exception as e:
+ logger.warning(f"声纹匹配失败,使用默认说话人: {e}")
+
+ return make_speakr_response(
+ text=result.get("text", "") if isinstance(result, dict) else "",
+ language=result.get("language", language or "zh") if isinstance(result, dict) else language or "zh",
+ speaker=speaker_name,
+ )
+
+ finally:
+ if tmp_path and os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+
+
+@app.get("/get_voice_name")
+async def get_voice_name(
+ database_name: str = "NB",
+ collection_name: str = "voice",
+):
+ store = _load_voiceprints()
+ records = store.get(_voiceprint_key(database_name, collection_name), {})
+ return {
+ "code": 0,
+ "success": True,
+ "message": "ok",
+ "data": [
+ {"name": name, "voice_name": name}
+ for name in sorted(records.keys())
+ ],
+ }
+
+
+@app.post("/voice_insert")
+async def voice_insert(
+ voice_name: str = Form(...),
+ database_name: str = Form("NB"),
+ collection_name: str = Form("voice"),
+ voice_file: UploadFile = File(...),
+):
+ tmp_path = None
+
+ try:
+ suffix = os.path.splitext(voice_file.filename or "")[1] or ".wav"
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
+ content = await voice_file.read()
+ if not content:
+ raise HTTPException(status_code=400, detail="上传声纹文件为空")
+ tmp.write(content)
+ tmp_path = tmp.name
+
+ embedding = _extract_embedding(tmp_path, voice_file.filename or f"{voice_name}.wav")
+ store = _load_voiceprints()
+ key = _voiceprint_key(database_name, collection_name)
+ records = store.setdefault(key, {})
+ records[voice_name] = {
+ "name": voice_name,
+ "database_name": database_name,
+ "collection_name": collection_name,
+ "embedding": embedding,
+ }
+ _save_voiceprints(store)
+
+ return {
+ "code": 0,
+ "success": True,
+ "message": "voice inserted",
+ "data": {"name": voice_name, "embedding_size": len(embedding)},
+ }
+ except requests.RequestException as e:
+ logger.error(f"调用 voice-dected 失败: {e}")
+ raise HTTPException(status_code=502, detail=f"voice-dected 调用失败: {e}")
+ except Exception as e:
+ logger.error(f"写入声纹失败: {e}")
+ raise HTTPException(status_code=500, detail=f"写入声纹失败: {e}")
+ finally:
+ if tmp_path and os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+
+
+@app.delete("/delete_voice")
+async def delete_voice(
+ name: str,
+ database_name: str = "NB",
+ collection_name: str = "voice",
+):
+ store = _load_voiceprints()
+ key = _voiceprint_key(database_name, collection_name)
+ records = store.setdefault(key, {})
+ existed = name in records
+ records.pop(name, None)
+ _save_voiceprints(store)
+
+ return {
+ "code": 0,
+ "success": True,
+ "message": "voice deleted" if existed else "voice not found",
+ "data": {"name": name, "deleted": existed},
+ }
+
+
+if __name__ == "__main__":
+ uvicorn.run(
+ "main:app",
+ host="0.0.0.0",
+ port=59805,
+ reload=False,
+ workers=1,
+ log_level="info",
+ )
diff --git a/config.env b/config.env
new file mode 100644
index 0000000..e040e4c
--- /dev/null
+++ b/config.env
@@ -0,0 +1,40 @@
+# Shared runtime config for /storage01/home/xll/202607/voice/docker-compose.yml
+
+# Speakr / ASR
+USE_ASR_ENDPOINT=true
+ASR_BASE_URL=http://asr-test:59805
+ASR_DIARIZE=true
+ASR_MIN_SPEAKERS=1
+ASR_MAX_SPEAKERS=10
+
+# LLM used by Speakr summaries and chat
+TEXT_MODEL_BASE_URL=http://192.168.0.46:59800/v1
+TEXT_MODEL_NAME=46-qwen3.5-35B
+TEXT_MODEL_API_KEY=none
+
+# Realtime WebSocket bridge
+TARGET_WS_URL=ws://asr-test:59805/ws/asr
+REDIS_URL=redis://redis:6379/0
+LLM_API_KEY=none
+LLM_BASE_URL=http://192.168.0.46:59800/v1
+LLM_MODEL=46-qwen3.5-35B
+USE_LLM_ROLE_IDENTIFICATION=false
+LLM_ROLE_CANDIDATES=主持人,正方辩手,反方辩手,计时员,评委,嘉宾,未知
+LLM_ROLE_MIN_TEXT_LEN=8
+LLM_ROLE_CACHE_CONFIDENCE=0.82
+LLM_ROLE_HISTORY_MAX_CHARS=1200
+
+# ASR voiceprint adapter
+VOICE_DETECTED_URL=http://voice-dected:8000
+VOICEPRINT_STORE=/data/asr_voiceprints.json
+VOICEPRINT_MATCH_THRESHOLD=0.45
+
+# fastapi_wss legacy/local ASR-with-speaker helper
+ASR_LOCAL_URL=http://asr-test:59805/asr
+SPEAKER_DET_URL=http://voice-dected:8000/extract_embedding
+
+# Runtime
+DEVICE=cuda
+CUDA_VISIBLE_DEVICES=0
+NVIDIA_VISIBLE_DEVICES=7
+PYTHONUNBUFFERED=1
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..5808d85
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,41 @@
+services:
+ speakr:
+ build:
+ context: ..
+ dockerfile: deploy/speakr.runtime.Dockerfile
+ image: voice-speakr:runtime
+ container_name: voice-speakr
+ restart: unless-stopped
+ working_dir: /app
+ environment:
+ PYTHONPATH: /app
+ SQLALCHEMY_DATABASE_URI: sqlite:////data/instance/transcriptions.db
+ UPLOAD_FOLDER: /data/uploads
+ USE_ASR_ENDPOINT: "true"
+ ASR_BASE_URL: http://192.168.0.46:59805
+ TEXT_MODEL_BASE_URL: http://192.168.0.46:59800/v1
+ TEXT_MODEL_NAME: 46-qwen3.5-35B
+ TEXT_MODEL_API_KEY: none
+ volumes:
+ - ../speakr:/app
+ - ./data/speakr/uploads:/data/uploads
+ - ./data/speakr/instance:/data/instance
+ ports:
+ - "8899:8899"
+ depends_on:
+ - voice-dected
+
+ voice-dected:
+ build:
+ context: ..
+ dockerfile: deploy/voice-dected.runtime.Dockerfile
+ image: voice-dected:runtime
+ container_name: voice-dected
+ restart: unless-stopped
+ working_dir: /app
+ environment:
+ PYTHONPATH: /app
+ volumes:
+ - ../voice-dected/src:/app
+ ports:
+ - "18000:8000"
diff --git a/deploy/speakr.runtime.Dockerfile b/deploy/speakr.runtime.Dockerfile
new file mode 100644
index 0000000..0abcbaf
--- /dev/null
+++ b/deploy/speakr.runtime.Dockerfile
@@ -0,0 +1,29 @@
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PYTHONPATH=/app \
+ FLASK_APP=src/app.py \
+ SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db \
+ UPLOAD_FOLDER=/data/uploads
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ffmpeg \
+ libgomp1 \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY speakr/requirements.txt /tmp/requirements.txt
+RUN pip install --upgrade pip setuptools wheel \
+ && pip install --index-url https://download.pytorch.org/whl/cpu torch \
+ && pip install -r /tmp/requirements.txt \
+ && rm -rf /root/.cache/pip
+
+RUN mkdir -p /data/uploads /data/instance
+
+EXPOSE 8899
+
+CMD ["sh", "-c", "mkdir -p /data/uploads /data/instance && python -c \"from src.app import app; from src.clients import db; app.app_context().push(); db.create_all()\" && exec gunicorn --workers ${GUNICORN_WORKERS:-3} --threads ${GUNICORN_THREADS:-8} --bind 0.0.0.0:8899 --timeout ${GUNICORN_TIMEOUT:-600} src.app:app"]
diff --git a/deploy/start.sh b/deploy/start.sh
new file mode 100755
index 0000000..7b195c2
--- /dev/null
+++ b/deploy/start.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+mkdir -p data/speakr/uploads data/speakr/instance
+docker compose up -d --build
+docker compose ps
diff --git a/deploy/voice-dected.runtime.Dockerfile b/deploy/voice-dected.runtime.Dockerfile
new file mode 100644
index 0000000..317aa73
--- /dev/null
+++ b/deploy/voice-dected.runtime.Dockerfile
@@ -0,0 +1,27 @@
+FROM python:3.8-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PYTHONPATH=/app
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libsndfile1 \
+ sox \
+ libsox-dev \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN pip install --upgrade pip setuptools wheel \
+ && pip install --index-url https://download.pytorch.org/whl/cpu torch torchaudio
+
+COPY voice-dected/requirements.txt /tmp/requirements.txt
+RUN grep -v -E '^[[:space:]]*(torch|torchaudio)[[:space:]]*$' /tmp/requirements.txt > /tmp/requirements-no-torch.txt \
+ && pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r /tmp/requirements-no-torch.txt \
+ && rm -rf /root/.cache/pip
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..c7395f2
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,128 @@
+services:
+ speakr:
+ image: voice-speakr:runtime
+ container_name: voice-speakr
+ restart: unless-stopped
+ working_dir: /app
+ env_file:
+ - ./config.env
+ environment:
+ PYTHONPATH: /app
+ SQLALCHEMY_DATABASE_URI: sqlite:////data/instance/transcriptions.db
+ UPLOAD_FOLDER: /data/uploads
+ USE_ASR_ENDPOINT: "true"
+ ASR_BASE_URL: http://asr-test:59805
+ volumes:
+ - ./speakr:/app
+ - ./deploy/data/speakr/uploads:/data/uploads
+ - ./deploy/data/speakr/instance:/data/instance
+ ports:
+ - "8899:8899"
+ depends_on:
+ - asr-test
+ - voice-dected
+ command:
+ - sh
+ - -c
+ - >
+ mkdir -p /data/uploads /data/instance &&
+ python -c "from src.app import app; from src.clients import db; app.app_context().push(); db.create_all()" &&
+ exec gunicorn --workers $${GUNICORN_WORKERS:-3} --threads $${GUNICORN_THREADS:-8}
+ --bind 0.0.0.0:8899 --timeout $${GUNICORN_TIMEOUT:-600} src.app:app
+
+ voice-dected:
+ image: voice-dected:runtime
+ container_name: voice-dected
+ restart: unless-stopped
+ working_dir: /app
+ env_file:
+ - ./config.env
+ environment:
+ PYTHONPATH: /app
+ volumes:
+ - ./voice-dected/src:/app
+ ports:
+ - "18000:8000"
+ command:
+ - uvicorn
+ - app:app
+ - --host
+ - 0.0.0.0
+ - --port
+ - "8000"
+
+ asr-test:
+ image: paraformer-asr:latest
+ container_name: asr-test
+ restart: unless-stopped
+ runtime: nvidia
+ gpus:
+ - driver: nvidia
+ device_ids:
+ - "7"
+ capabilities:
+ - gpu
+ working_dir: /app
+ env_file:
+ - ./config.env
+ environment:
+ DEVICE: cuda
+ CUDA_VISIBLE_DEVICES: "0"
+ NVIDIA_VISIBLE_DEVICES: "7"
+ VOICE_DETECTED_URL: http://voice-dected:8000
+ VOICEPRINT_STORE: /data/asr_voiceprints.json
+ volumes:
+ - ./asr/main.py:/app/main.py:ro
+ - /storage01/home/xll/audio:/app/audio:ro
+ - /storage01/home/xll/weixiu-hj/seacomodel:/app/models/seacomodel:ro
+ - /storage01/home/xll/weixiu-hj/vadmodel:/app/models/vadmodel:ro
+ - /storage01/home/xll/weixiu-hj/ctpuncmodel:/app/models/ctpuncmodel:ro
+ - /storage01/home/xll/weixiu-hj/speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch/example/hotword.txt:/app/hotword.txt:ro
+ - ./deploy/data/asr:/data
+ ports:
+ - "59805:59805"
+ depends_on:
+ - voice-dected
+ command:
+ - uvicorn
+ - main:app
+ - --host
+ - 0.0.0.0
+ - --port
+ - "59805"
+ - --workers
+ - "1"
+
+ fastapi-wss:
+ image: fastapi-wss:runtime
+ container_name: fastapi-wss
+ restart: unless-stopped
+ working_dir: /app/src
+ env_file:
+ - ./config.env
+ environment:
+ TARGET_WS_URL: ws://asr-test:59805/ws/asr
+ REDIS_URL: redis://redis:6379/0
+ volumes:
+ - ./fastapi_wss:/app
+ ports:
+ - "10095:10095"
+ depends_on:
+ - asr-test
+ - redis
+ command:
+ - python
+ - a2a_wss.py
+
+ redis:
+ image: redis:7-alpine
+ container_name: voice-redis
+ restart: unless-stopped
+ volumes:
+ - ./deploy/data/redis:/data
+ ports:
+ - "16380:6379"
+ command:
+ - redis-server
+ - --appendonly
+ - "yes"
diff --git a/fastapi_wss/.env.example b/fastapi_wss/.env.example
new file mode 100644
index 0000000..2bc86fb
--- /dev/null
+++ b/fastapi_wss/.env.example
@@ -0,0 +1,5 @@
+TARGET_WS_URL= ws://10.100.3.22:10095
+REDIS_URL=redis://localhost:6379/0
+LLM_API_KEY=http://192.168.0.46:59800/v1
+LLM_BASE_URL=http://192.168.0.46:59800/v1
+LLM_MODEL=
\ No newline at end of file
diff --git a/fastapi_wss/Dockerfile b/fastapi_wss/Dockerfile
new file mode 100644
index 0000000..15edb04
--- /dev/null
+++ b/fastapi_wss/Dockerfile
@@ -0,0 +1,29 @@
+# 使用轻量级的 Python 3.10 镜像
+FROM python:3.10-slim
+
+# 设置工作目录
+WORKDIR /app
+
+# 设置环境变量
+# 1. 确保 Python 输出直接打印到终端,不进行缓冲(方便查看日志)
+# 2. 避免生成 pyc 文件
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1
+
+# 复制依赖文件并安装
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt && \
+ apt-get update && \
+ apt-get install -y vim && \
+ rm -rf /var/lib/apt/lists/*
+
+# 复制当前目录下的所有代码到容器中
+COPY . .
+
+WORKDIR /app/src
+
+# 暴露端口 (对应代码中的 LOCAL_PORT)
+EXPOSE 10095
+
+# 启动 a2a_wss 服务
+CMD ["python", "a2a_wss.py"]
\ No newline at end of file
diff --git a/fastapi_wss/requirements.txt b/fastapi_wss/requirements.txt
new file mode 100644
index 0000000..b634f87
--- /dev/null
+++ b/fastapi_wss/requirements.txt
@@ -0,0 +1,33 @@
+# WebSocket 智能语义分析服务 - 依赖包列表
+#
+# 安装方法:
+# pip install -r requirements.txt
+#
+# Python 版本要求:3.8+
+
+# WebSocket 通信库
+websockets>=11.0
+
+# 异步 HTTP 客户端(可选,用于扩展功能)
+aiohttp>=3.8.0
+
+# Redis 异步客户端
+redis>=5.0.0
+
+# Redis C 扩展(性能优化)
+hiredis
+
+# OpenAI API 客户端(用于调用大语言模型)
+openai>=1.0.0
+
+# 数值计算库(用于未来的特征处理)
+numpy
+python-dotenv
+# 注意:以下为 Python 标准库,无需安装:
+# - asyncio (异步编程)
+# - json (JSON 处理)
+# - os (操作系统接口)
+# - re (正则表达式)
+# - logging (日志记录)
+# - typing (类型提示)
+# - dataclasses (数据类)
diff --git a/fastapi_wss/src/README.md b/fastapi_wss/src/README.md
new file mode 100644
index 0000000..225e0cf
--- /dev/null
+++ b/fastapi_wss/src/README.md
@@ -0,0 +1,425 @@
+# WebSocket 智能语义分析服务 - 长期记忆增强版
+
+## 项目概述
+
+这是一个基于 WebSocket 和 Agent-to-Agent (A2A) 架构的智能语音识别服务,具备说话人识别和长期记忆功能。系统能够在同一场会议中,通过学习每个说话人的发言特征,持续提高说话人识别的准确率。
+
+## 核心特性
+
+### 1. 实时语音识别处理
+- 接收前端音频流,转发到 ASR 引擎
+- 支持双通道模式(2pass)和 PCM 音频格式
+- 实时推送识别结果到前端
+
+### 2. 多 Agent 协作架构
+- **AuditorAgent**: 判断说话人是否完成发言
+- **ProfilerAgent**: 识别说话人身份(支持长期记忆)
+
+### 3. 长期记忆功能 ⭐
+- 为每个说话人建立特征档案
+- 持续学习说话人的发言风格和用词习惯
+- 利用会议历史上下文提高识别准确率
+
+### 4. 完善的日志系统 📝
+- 自动创建日志目录,分级记录运行信息
+- 控制台输出:INFO 级别(运行状态)
+- 完整日志文件:DEBUG 级别(详细调试信息)
+- 错误日志文件:ERROR 级别(错误和异常)
+- 所有日志包含时间戳和上下文信息
+
+---
+
+## 日志系统说明
+
+### 日志文件位置
+
+程序运行时会在项目根目录下创建 `logs` 文件夹,包含以下日志文件:
+
+```
+logs/
+├── a2a_wss.log # 完整日志(DEBUG 级别)
+└── a2a_wss_error.log # 错误日志(ERROR 级别)
+```
+
+### 日志级别说明
+
+| 级别 | 说明 | 使用场景 | 输出位置 |
+|------|------|----------|----------|
+| **DEBUG** | 调试信息 | 详细的程序执行流程、变量值等 | 仅日志文件 |
+| **INFO** | 一般信息 | 关键业务流程、状态变更 | 控制台 + 日志文件 |
+| **ERROR** | 错误信息 | 异常、错误堆栈 | 控制台 + 日志文件 + 错误日志 |
+
+### 日志内容示例
+
+```
+2025-01-07 14:30:15 - root - INFO - 启动 WebSocket 智能语义分析服务: ws://0.0.0.0:10095
+2025-01-07 14:30:15 - root - INFO - Agent 模型: Qwen3-32B
+2025-01-07 14:30:15 - root - INFO - ASR 服务地址: ws://localhost:59805/ws/asr
+2025-01-07 14:30:15 - root - INFO - Redis 地址: redis://localhost:6379/0
+2025-01-07 14:30:20 - root - INFO - 客户端已连接
+2025-01-07 14:30:20 - root - INFO - 已连接到 ASR 服务: ws://localhost:59805/ws/asr
+2025-01-07 14:30:22 - root - DEBUG - 收到 ASR 识别结果: 各位好,今天我们召开月度工作总结会议...
+2025-01-07 14:30:23 - root - INFO - [Auditor] 发言完成检测通过 - 会话ID: room_101
+2025-01-07 14:30:23 - root - DEBUG - 获取说话人特征库 - 会话ID: room_101, 说话人数量: 0
+2025-01-07 14:30:24 - root - INFO - [Profiler] 识别说话人: 主持人 - 会话ID: room_101
+```
+
+### 日志配置
+
+日志系统在代码中自动配置(无需手动配置),包含:
+
+1. **日志格式**:`时间 - 记录器名称 - 级别 - 消息`
+2. **编码**:UTF-8(支持中文)
+3. **自动创建目录**:首次运行时自动创建 `logs` 文件夹
+4. **错误追踪**:ERROR 级别日志包含完整的异常堆栈信息
+
+### 日志查看建议
+
+- **实时监控**:`tail -f logs/a2a_wss.log`
+- **查看错误**:`cat logs/a2a_wss_error.log`
+- **搜索关键词**:`grep "识别说话人" logs/a2a_wss.log`
+- **统计分析**:`grep "ERROR" logs/a2a_wss_error.log | wc -l`
+
+---
+
+## 长期记忆功能详解
+
+### 功能说明
+
+长期记忆功能使得系统能够在同一场会议中,记住每个说话人的发言特征,并利用这些历史信息来辅助后续的说话人识别。
+
+### 工作流程
+
+```
+┌─────────────┐
+│ 开始会议 │
+└──────┬──────┘
+ │
+ ▼
+┌─────────────────────┐
+│ 接收语音片段 │
+└──────┬──────────────┘
+ │
+ ▼
+┌─────────────────────┐
+│ 累积到缓冲区 │
+└──────┬──────────────┘
+ │
+ ▼
+┌─────────────────────┐
+│ Auditor: 判断发言 │
+│ 是否完成 │
+└──────┬──────────────┘
+ │
+ ▼ (发言完成)
+┌──────────────────────────────┐
+│ 获取长期记忆 │
+│ (所有说话人的历史特征) │
+└──────┬───────────────────────┘
+ │
+ ▼
+┌──────────────────────────────┐
+│ Profiler: 识别说话人 │
+│ - 分析会议历史记录 │
+│ - 对比说话人特征库 │
+│ - 判断对话流程和上下文 │
+└──────┬───────────────────────┘
+ │
+ ▼
+┌──────────────────────────────┐
+│ 更新长期记忆 │
+│ (保存当前发言到特征库) │
+└──────┬───────────────────────┘
+ │
+ ▼
+┌─────────────────────┐
+│ 推送结果到前端 │
+└─────────────────────┘
+```
+
+### 核心改进
+
+#### 1. Redis 存储扩展
+
+**新增方法**:
+
+- `save_speaker_profile(sid, speaker, text, features)`: 保存说话人特征到长期记忆
+ - 保留每个说话人最近 10 条发言记录
+ - 存储发言文本片段(前 200 字符)
+ - 记录时间戳和可选的特征向量
+
+- `get_speaker_profiles(sid)`: 获取所有说话人的历史特征
+ - 返回字典格式:`{说话人: [历史记录列表]}`
+ - 用于 Agent 分析时参考
+
+#### 2. ProfilerAgent 增强
+
+**改进的识别逻辑**:
+
+- **历史记录分析**:查看最近 15 条对话,理解会议上下文
+- **对话流程分析**:判断当前发言是对之前谁的话题的回应
+- **说话风格匹配**:对比每个角色的用词习惯、句式结构、语气
+- **角色职责判断**:根据发言内容是否符合角色职责范围
+- **话题连贯性**:当前发言与该角色历史话题的连贯性
+
+**提示词优化**:
+
+```python
+# 增强版系统提示词包含4个分析维度:
+1. 对话流程分析
+2. 说话风格匹配
+3. 角色职责判断
+4. 话题连贯性
+```
+
+#### 3. A2AOrchestrator 集成
+
+在 `process_asr_fragment()` 方法中集成长期记忆:
+
+```python
+# 1. 获取长期记忆(说话人特征库)
+speaker_profiles = await self.redis.get_speaker_profiles(session_id)
+
+# 2. 使用长期记忆识别说话人
+speaker = await self.profiler.identify(buf, history, speaker_profiles)
+
+# 3. 保存到长期记忆(持续学习)
+await self.redis.save_speaker_profile(session_id, speaker, buf)
+```
+
+---
+
+## 配置说明
+
+### 环境变量
+
+| 变量名 | 说明 | 默认值 |
+|--------|------|--------|
+| `TARGET_WS_URL` | ASR 服务的 WebSocket 地址 | `ws://localhost:59805/ws/asr` |
+| `REDIS_URL` | Redis 连接地址 | `redis://localhost:6379/0` |
+| `LLM_API_KEY` | 大语言模型 API Key | `sk-xxxx` |
+| `LLM_BASE_URL` | 大语言模型服务地址 | `http://172.18.127.124:9997/v1` |
+| `LLM_MODEL` | 使用的模型名称 | `Qwen3-32B` |
+
+### 候选说话人列表
+
+在代码中配置(`CANDIDATE_SPEAKERS`):
+```python
+CANDIDATE_SPEAKERS = [
+ "局长",
+ "主持人",
+ "商务专员",
+ "市场专员",
+ "控制要素席",
+ "空中侦察席"
+]
+```
+
+---
+
+## Redis 数据结构
+
+### 键命名规范
+
+```
+meeting:buffer:{session_id} # 会话文本缓冲
+meeting:history:{session_id} # 会话历史记录
+meeting:speaker_profile:{session_id}:{speaker} # 说话人特征库
+```
+
+### 数据类型
+
+- **缓冲区**: String (累积的未完成发言)
+- **历史记录**: List (最多 20 条)
+- **说话人特征**: List (每个说话人最多 10 条)
+
+---
+
+## 使用示例
+
+### 安装依赖
+
+首先确保已安装 Python 3.8+,然后安装所需依赖:
+
+```bash
+# 在项目根目录下执行
+pip install -r requirements.txt
+```
+
+### 启动服务
+
+```bash
+# 在 src 目录下执行
+python a2a_wss.py
+```
+
+服务将监听 `ws://0.0.0.0:10095`
+
+### 连接和消息格式
+
+**前端发送配置**:
+```json
+{
+ "mode": "2pass",
+ "chunk_size": [10, 10, 10],
+ "wav_format": "pcm"
+}
+```
+
+**前端发送音频**: 二进制 PCM 数据
+
+**后端返回结果**:
+```json
+{
+ "mode": "offline-speaker",
+ "type": "asr_with_speaker",
+ "segments": [{
+ "id": 0,
+ "speaker": "局长",
+ "embedding": null,
+ "seek": 0,
+ "full_text": "今天我们讨论一下市场推广方案"
+ }],
+ "speaker": "局长"
+}
+```
+
+---
+
+## 长期记忆的工作示例
+
+### 会议初期(无记忆)
+
+```
+历史记录: [暂无]
+说话人特征库: [暂无]
+
+当前发言: "各位好,今天我们召开月度工作总结会议"
+
+识别结果: 主持人 (基于内容特征)
+```
+
+### 会议中期(有部分记忆)
+
+```
+历史记录:
+[1] 主持人: 各位好,今天我们召开月度工作总结会议
+[2] 局长: 本月工作总体进展顺利
+[3] 商务专员: 我们完成了三个重要合同的签署
+[4] 市场专员: 市场推广活动覆盖了五个新城市
+
+说话人特征库:
+- 【主持人】(发言1次): 各位好,今天我们召开月度工作总结会议
+- 【局长】(发言1次): 本月工作总体进展顺利
+- 【商务专员】(发言1次): 我们完成了三个重要合同的签署
+- 【市场专员】(发言1次): 市场推广活动覆盖了五个新城市
+
+当前发言: "关于下个月的计划,我们需要重点关注客户反馈"
+
+识别结果: 市场专员
+分析:
+- 延续了市场专员之前的话题(市场推广)
+- 符合市场专员的职责范围(客户反馈)
+- 用词风格与历史记录匹配
+```
+
+### 会议后期(丰富记忆)
+
+```
+历史记录: [15条完整对话记录]
+
+说话人特征库:
+- 【局长】(发言4次): 本月工作总体进展顺利; 很好,各部门配合默契;
+ 这次会议很有成效; 最后强调一下安全问题
+- 【主持人】(发言3次): 各位好,今天我们召开月度工作总结会议;
+ 下面请商务专员汇报; 时间关系,今天的会议就到这里
+- 【商务专员】(发言5次): 我们完成了三个重要合同的签署;
+ 这三个合同总金额达500万; 客户对我们的方案很满意;
+ 下季度重点跟进大客户; 需要技术部门的支持
+...
+
+当前发言: "技术部门会全力支持商务工作,确保项目交付质量"
+
+识别结果: (未出现在候选列表中,或需要进一步判断)
+分析:
+- 提到了"技术部门",可能是技术相关角色
+- 对之前商务专员的话题做出回应
+- 风式偏向技术人员的特点(强调"质量"、"交付")
+```
+
+---
+
+## 性能优化
+
+### 1. 数据限制
+- 历史记录:最多保留 20 条
+- 每个说话人特征:最多保留 10 条
+- LLM 上下文:使用最近 15 条历史记录
+
+### 2. 异步处理
+- Agent 处理使用后台任务,不阻塞主循环
+- 连接关闭时等待任务完成(最多 5 秒)
+
+### 3. Redis 连接池
+- 使用连接池提高并发性能
+- 自动编码/解码 JSON 数据
+
+---
+
+## 技术栈
+
+- **Python 3.8+**
+- **websockets**: WebSocket 通信
+- **redis**: 会话状态和长期记忆存储
+- **openai**: 大语言模型调用
+- **logging**: 日志记录和系统监控
+- **asyncio**: 异步编程
+
+---
+
+## 代码结构
+
+```
+a2a_wss.py
+├── 基础配置 (1-48行)
+├── RedisManager 类 (52-199行)
+│ ├── 会话缓冲管理
+│ ├── 历史记录管理
+│ └── 说话人特征库管理 ⭐新增
+├── Agent 基类 (154-208行)
+├── AuditorAgent 类 (211-235行)
+├── ProfilerAgent 类 (238-369行) ⭐增强
+│ ├── 长期记忆提示词
+│ ├── 会议历史分析
+│ └── 说话人特征匹配
+├── A2AOrchestrator 类 (372-343行) ⭐集成
+│ └── process_asr_fragment() 方法
+└── WebSocket 处理 (352-450行)
+```
+
+---
+
+## 注意事项
+
+1. **Redis 依赖**: 确保 Redis 服务正常运行
+2. **LLM 可用性**: 确保大语言模型服务可访问
+3. **内存管理**: 长时间运行的会议会累积数据,建议定期清理
+4. **识别准确率**: 长期记忆随时间累积,会议初期识别准确率可能较低
+
+---
+
+## 未来改进方向
+
+1. **跨会议记忆**: 支持多场会议之间的说话人特征迁移
+2. **特征工程**: 提取更多说话人特征(语速、停顿模式等)
+3. **置信度评分**: 为每次识别添加置信度分数
+4. **自适应学习**: 根据识别准确率动态调整特征权重
+5. **多模态融合**: 结合声学特征提高识别准确率
+
+---
+
+## 作者和许可
+
+本项目为智能语音识别系统的核心组件,采用 Agent-to-Agent 编排模式。
+
+最后更新: 2025-01-07
diff --git a/fastapi_wss/src/a2a_wss copy.py b/fastapi_wss/src/a2a_wss copy.py
new file mode 100644
index 0000000..9ce582f
--- /dev/null
+++ b/fastapi_wss/src/a2a_wss copy.py
@@ -0,0 +1,769 @@
+"""
+WebSocket 智能语义分析服务
+
+功能说明:
+1. 作为 WebSocket 代理,接收前端音频数据并转发到 ASR 引擎
+2. 实时处理 ASR 识别结果,应用多 Agent 协作(A2A)模式
+3. Agent 功能:
+ - AuditorAgent: 判断说话人是否完成发言
+ - ProfilerAgent: 识别当前说话人的身份
+4. 使用 Redis 存储会话缓冲和历史记录
+5. 将处理后的带说话人身份的结果推送回前端
+
+架构模式:Agent-to-Agent (A2A) 编排
+"""
+
+import asyncio
+import json
+import os
+import websockets
+import re
+import logging
+from pathlib import Path
+from typing import List, Optional, Dict, Set
+from dataclasses import dataclass
+from dotenv import load_dotenv
+
+try:
+ import redis.asyncio as redis
+except ModuleNotFoundError:
+ redis = None
+
+try:
+ from openai import AsyncOpenAI
+except ModuleNotFoundError:
+ AsyncOpenAI = None
+# ========================
+# 1. 日志配置
+# ========================
+ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
+load_dotenv(ENV_PATH)
+
+
+def get_env(name: str, default: str) -> str:
+ return os.getenv(name, default).strip()
+
+
+def get_env_bool(name: str, default: bool = False) -> bool:
+ value = os.getenv(name)
+ if value is None:
+ return default
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def setup_logging():
+ """配置日志系统"""
+ # 创建日志目录
+ log_dir = "logs"
+ os.makedirs(log_dir, exist_ok=True)
+
+ # 配置日志格式
+ log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ date_format = "%Y-%m-%d %H:%M:%S"
+
+ # 配置根日志记录器
+ logger = logging.getLogger()
+ logger.setLevel(logging.INFO)
+
+ # 清除现有处理器
+ logger.handlers.clear()
+
+ # 控制台处理器
+ console_handler = logging.StreamHandler()
+ console_handler.setLevel(logging.INFO)
+ console_formatter = logging.Formatter(log_format, datefmt=date_format)
+ console_handler.setFormatter(console_formatter)
+ logger.addHandler(console_handler)
+
+ # 文件处理器(所有日志)
+ file_handler = logging.FileHandler(
+ f"{log_dir}/a2a_wss.log",
+ encoding="utf-8"
+ )
+ file_handler.setLevel(logging.DEBUG)
+ file_formatter = logging.Formatter(log_format, datefmt=date_format)
+ file_handler.setFormatter(file_formatter)
+ logger.addHandler(file_handler)
+
+ # 错误日志文件处理器
+ error_handler = logging.FileHandler(
+ f"{log_dir}/a2a_wss_error.log",
+ encoding="utf-8"
+ )
+ error_handler.setLevel(logging.ERROR)
+ error_formatter = logging.Formatter(log_format, datefmt=date_format)
+ error_handler.setFormatter(error_formatter)
+ logger.addHandler(error_handler)
+
+ return logger
+
+# 初始化日志系统
+logger = setup_logging()
+
+# ========================
+# 2. 基础配置
+# ========================
+
+# 目标 ASR 服务的 WebSocket 地址(负责语音识别)
+# 注意:移除末尾的斜杠,避免路径匹配问题
+TARGET_WS_URL = get_env("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
+
+# 本地服务监听地址和端口(接收前端连接)
+LOCAL_HOST = get_env("LOCAL_HOST", "0.0.0.0")
+LOCAL_PORT = int(get_env("LOCAL_PORT", "10095"))
+
+# Redis 连接配置(用于存储会话状态和历史记录)
+REDIS_URL = get_env("REDIS_URL", "redis://localhost:6379/0")
+
+# LLM(大语言模型)配置(用于 Agent 推理)
+LLM_API_KEY = get_env("LLM_API_KEY", "none")
+LLM_BASE_URL = get_env("LLM_BASE_URL", "http://192.168.0.46:59800/v1")
+LLM_MODEL = get_env("LLM_MODEL", "46-qwen3.5-35B")
+USE_LLM_ROLE_IDENTIFICATION = get_env_bool("USE_LLM_ROLE_IDENTIFICATION", False)
+# 候选说话人列表(用于身份识别 Agent)
+CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员", "控制要素席", "空中侦察席"]
+
+# ========================
+# 3. Redis 管理器
+# ========================
+
+class RedisManager:
+ """
+ Redis 会话管理器
+
+ 职责:
+ - 管理与 Redis 的连接池
+ - 存储每个会话的文本缓冲(累积不完整的识别片段)
+ - 存储历史对话记录(用于上下文理解)
+ """
+
+ def __init__(self, url):
+ """
+ 初始化 Redis 连接池
+
+ Args:
+ url: Redis 连接 URL
+ """
+ if redis is None:
+ raise RuntimeError("redis package is not installed")
+ # 使用连接池提高并发性能,decode_responses=True 自动将字节转为字符串
+ self.pool = redis.ConnectionPool.from_url(url, decode_responses=True)
+ # 键前缀,用于命名隔离,避免与其他业务冲突
+ self.prefix = "meeting:"
+
+ async def _get_conn(self):
+ """从连接池获取一个 Redis 连接"""
+ return redis.Redis(connection_pool=self.pool)
+
+ async def get_buffer(self, sid):
+ """
+ 获取指定会话的文本缓冲
+
+ Args:
+ sid: 会话 ID(Session ID)
+
+ Returns:
+ 该会话当前累积的文本片段
+ """
+ r = await self._get_conn()
+ val = await r.get(f"{self.prefix}buffer:{sid}") or ""
+ return val
+
+ async def append_buffer(self, sid, text):
+ """
+ 向会话缓冲追加新的文本片段
+
+ Args:
+ sid: 会话 ID
+ text: 新识别的文本片段
+ """
+ r = await self._get_conn()
+ # 追加文本并添加空格分隔
+ await r.append(f"{self.prefix}buffer:{sid}", text + " ")
+
+ async def clear_buffer(self, sid):
+ """
+ 清空指定会话的缓冲区
+
+ 用途:当判断发言完成后,清空缓冲准备下一次发言
+
+ Args:
+ sid: 会话 ID
+ """
+ r = await self._get_conn()
+ await r.delete(f"{self.prefix}buffer:{sid}")
+
+ async def push_history(self, sid, role, content):
+ """
+ 将完成的对话推入历史记录
+
+ Args:
+ sid: 会话 ID
+ role: 说话人角色
+ content: 对话内容
+ """
+ r = await self._get_conn()
+ key = f"{self.prefix}history:{sid}"
+ # 将对话记录推入列表右端
+ await r.rpush(key, json.dumps({"role": role, "content": content}))
+ # 只保留最近 20 条记录,避免历史过长影响性能
+ await r.ltrim(key, -20, -1)
+
+ async def get_history(self, sid):
+ """
+ 获取会话的历史对话记录
+
+ Args:
+ sid: 会话 ID
+
+ Returns:
+ 历史对话列表,每项包含 role 和 content
+ """
+ r = await self._get_conn()
+ items = await r.lrange(f"{self.prefix}history:{sid}", 0, -1)
+ return [json.loads(i) for i in items]
+
+ async def save_speaker_profile(self, sid, speaker, text, features=None):
+ """
+ 保存说话人的特征到长期记忆
+
+ Args:
+ sid: 会话 ID
+ speaker: 说话人名称
+ text: 发言文本
+ features: 可选的特征字典(如关键词、语速等)
+ """
+ r = await self._get_conn()
+ key = f"{self.prefix}speaker_profile:{sid}:{speaker}"
+
+ # 构造特征记录
+ profile_entry = {
+ "text": text[:200], # 只保留前200个字符作为示例
+ "timestamp": asyncio.get_event_loop().time(),
+ "features": features or {}
+ }
+
+ # 将记录推入该说话人的历史列表
+ await r.rpush(key, json.dumps(profile_entry))
+ # 只保留最近10条该说话人的记录
+ await r.ltrim(key, -10, -1)
+
+ async def get_speaker_profiles(self, sid):
+ """
+ 获取所有说话人的历史特征记录
+
+ Args:
+ sid: 会话 ID
+
+ Returns:
+ 字典,key为说话人名称,value为该说话人的历史记录列表
+ """
+ r = await self._get_conn()
+ # 扫描所有说话人的profile键
+ pattern = f"{self.prefix}speaker_profile:{sid}:*"
+ profiles = {}
+
+ async for key in r.scan_iter(match=pattern):
+ # 从键名中提取说话人名称
+ speaker = key.decode() if isinstance(key, bytes) else key
+ speaker = speaker.split(":")[-1]
+
+ # 获取该说话人的所有历史记录
+ items = await r.lrange(key, 0, -1)
+ profiles[speaker] = [json.loads(i) for i in items]
+
+ return profiles
+
+ async def close(self):
+ """关闭连接池,释放资源(修复 DeprecationWarning)"""
+ await self.pool.disconnect()
+
+# ========================
+# 3. Agent 实现
+# ========================
+
+class BaseAgent:
+ """
+ Agent 基类
+
+ 提供所有 Agent 的通用能力:
+ - 调用 LLM 进行推理
+ - 提取 LLM 返回的 XML 标签结果
+ """
+
+ def __init__(self, client: AsyncOpenAI):
+ """
+ 初始化 Agent
+
+ Args:
+ client: OpenAI 异步客户端
+ """
+ self.client = client
+
+ async def _call_llm(self, system_prompt: str, user_content: str) -> str:
+ """
+ 调用大语言模型进行推理
+
+ Args:
+ system_prompt: 系统提示词(定义 Agent 的角色和行为)
+ user_content: 用户输入内容
+
+ Returns:
+ LLM 返回的文本结果,失败时返回空字符串
+ """
+ try:
+ response = await self.client.chat.completions.create(
+ model=LLM_MODEL,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_content}
+ ],
+ temperature=0.1, # 低温度使输出更确定、一致
+ )
+ return response.choices[0].message.content
+ except Exception as e:
+ logger.error(f"LLM调用失败: {e}", exc_info=True)
+ return ""
+
+ def _extract_result(self, text: str) -> str:
+ """
+ 从 LLM 返回结果中提取 标签内容
+
+ Args:
+ text: LLM 返回的完整文本
+
+ Returns:
+ 提取的结果,如果没有标签则返回原文
+ """
+ match = re.search(r'(.*?) ', text, re.DOTALL | re.IGNORECASE)
+ return match.group(1).strip() if match else text.strip()
+
+
+class AuditorAgent(BaseAgent):
+ """
+ 审计 Agent:判断当前发言是否完成
+
+ 功能:
+ - 分析累积的文本缓冲
+ - 判断说话人是否已经完成一次完整的发言
+ - 判断依据:语义完整性、停顿暗示等
+ """
+
+ # 系统提示词:定义 Agent 的角色和输出格式
+ SYSTEM_PROMPT = "你是一个对话状态监测专家。请判断发言是否结束,放入 true/false 中。"
+
+ async def check(self, text: str) -> bool:
+ """
+ 判断给定文本是否代表一次完整的发言
+
+ Args:
+ text: 当前累积的文本片段
+
+ Returns:
+ True 表示发言完成,False 表示发言未完成(继续累积)
+ """
+ res = await self._call_llm(self.SYSTEM_PROMPT, f"内容:{text}")
+ return "true" in self._extract_result(res).lower()
+
+
+class ProfilerAgent(BaseAgent):
+ """
+ 分析 Agent:识别说话人身份
+
+ 功能:
+ - 基于文本内容和说话风格识别说话人
+ - 利用历史对话上下文提高识别准确率
+ - 利用长期记忆(说话人特征库)增强识别
+ - 从预定义的候选说话人列表中选择
+ """
+
+ # 系统提示词:定义识别任务和候选说话人列表
+ SYSTEM_PROMPT = f"你是一个会议身份识别专家。请从 {CANDIDATE_SPEAKERS} 中选择发言人,放入 名称 中。"
+
+ # 增强版提示词:结合长期记忆
+ SYSTEM_PROMPT_WITH_MEMORY = f"""你是一个会议身份识别专家。请根据以下信息识别发言人:
+
+候选说话人列表:{CANDIDATE_SPEAKERS}
+
+=== 会议历史记录(按时间顺序)===
+{{history}}
+
+=== 说话人特征库(长期记忆)===
+{{speaker_profiles}}
+
+=== 当前待识别的发言 ===
+{{current_text}}
+
+=== 识别指南 ===
+请仔细分析并综合判断:
+
+1. **对话流程分析**:
+ - 查看历史记录中的对话顺序和上下文
+ - 判断当前发言是对之前谁的话题的回应、延续或补充
+ - 注意对话的逻辑关系和互动模式
+
+2. **说话风格匹配**:
+ - 每个角色的用词习惯、句式结构、语气的特点
+ - 专有名词、技术术语的使用习惯
+ - 提问方式、陈述方式、指令方式的差异
+
+3. **角色职责判断**:
+ - 根据各角色的职责范围判断发言内容的合理性
+ - 例如:局长通常做总结和决策,主持人负责控场,专员负责具体业务汇报
+
+4. **话题连贯性**:
+ - 当前发言与该角色之前发言的话题是否连贯
+ - 是否体现了该角色应有的专业领域关注点
+
+将识别结果放入 说话人名称 中。"""
+
+ async def identify(self, text: str, history: List[Dict], speaker_profiles: Dict = None) -> str:
+ """
+ 识别当前文本的说话人身份
+
+ Args:
+ text: 当前发言文本
+ history: 历史对话记录(用于上下文理解)
+ speaker_profiles: 说话人特征库(长期记忆),key为说话人名称,value为历史记录列表
+
+ Returns:
+ 识别出的说话人名称
+ """
+ # 如果有长期记忆,使用增强版提示词
+ if speaker_profiles:
+ # 使用更多历史记录(最多15条),保持完整的会议上下文
+ h_str = self._format_history(history[-15:])
+
+ # 构建说话人特征库摘要
+ profiles_summary = self._format_profiles(speaker_profiles)
+
+ prompt = self.SYSTEM_PROMPT_WITH_MEMORY.format(
+ history=h_str or "暂无历史记录",
+ speaker_profiles=profiles_summary,
+ current_text=text
+ )
+
+ res = await self._call_llm(prompt, "")
+ return self._extract_result(res)
+ else:
+ # 原有的简单识别逻辑
+ h_str = "\n".join([f"{h['role']}: {h['content']}" for h in history[-5:]])
+ res = await self._call_llm(self.SYSTEM_PROMPT, f"历史:{h_str}\n当前:{text}")
+ return self._extract_result(res)
+
+ def _format_history(self, history: List[Dict]) -> str:
+ """
+ 格式化历史对话记录,添加序号和时间顺序
+
+ Args:
+ history: 历史对话列表
+
+ Returns:
+ 格式化后的对话文本
+ """
+ if not history:
+ return "暂无历史记录"
+
+ lines = []
+ for idx, h in enumerate(history, 1):
+ content_preview = h['content'][:150] # 只显示前150字符
+ if len(h['content']) > 150:
+ content_preview += "..."
+ lines.append(f"[{idx}] {h['role']}: {content_preview}")
+
+ return "\n".join(lines)
+
+ def _format_profiles(self, speaker_profiles: Dict) -> str:
+ """
+ 格式化说话人特征库为可读文本
+
+ Args:
+ speaker_profiles: 说话人特征字典
+
+ Returns:
+ 格式化后的文本摘要
+ """
+ if not speaker_profiles:
+ return "暂无说话人特征记录"
+
+ summary_lines = []
+ for speaker, profiles in speaker_profiles.items():
+ # 提取该说话人最近5条发言的文本片段
+ recent_texts = [p['text'] for p in profiles[-5:]]
+ texts_str = "; ".join(recent_texts)
+
+ # 统计该说话人的发言次数
+ speaking_count = len(profiles)
+
+ summary_lines.append(f"- 【{speaker}】(发言{speaking_count}次): {texts_str}")
+
+ return "\n".join(summary_lines)
+
+# ========================
+# 4. A2A 编排器
+# ========================
+
+class A2AOrchestrator:
+ """
+ Agent-to-Agent 编排器
+
+ 职责:
+ - 协调多个 Agent 的协作流程
+ - 处理 ASR 识别结果,依次调用 Auditor 和 Profiler
+ - 管理会话状态(缓冲和历史记录)
+ - 将最终结果推送给前端
+ """
+
+ def __init__(self, client: AsyncOpenAI, redis_mgr: RedisManager):
+ """
+ 初始化编排器
+
+ Args:
+ client: OpenAI 异步客户端
+ redis_mgr: Redis 管理器实例
+ """
+ self.redis = redis_mgr
+ self.auditor = AuditorAgent(client) # 审计 Agent
+ self.profiler = ProfilerAgent(client) # 分析 Agent
+
+ async def process_asr_fragment(self, session_id: str, fragment: str, websocket):
+ """
+ 处理 ASR 识别的文本片段(核心流程)
+
+ 流程:
+ 1. 将新片段追加到缓冲区
+ 2. 让 Auditor 判断发言是否完成
+ 3. 如果完成,让 Profiler 使用长期记忆识别说话人
+ 4. 将识别结果保存到长期记忆
+ 5. 将结果存入历史,清空缓冲
+ 6. 推送结果到前端
+
+ Args:
+ session_id: 会话 ID
+ fragment: ASR 识别的新文本片段
+ websocket: 前端 WebSocket 连接(用于推送结果)
+ """
+ # 1. 追加新片段到缓冲区
+ await self.redis.append_buffer(session_id, fragment)
+ buf = await self.redis.get_buffer(session_id)
+
+ # 2. 检查发言是否完成
+ if await self.auditor.check(buf):
+ logger.info(f"[Auditor] 发言完成检测通过 - 会话ID: {session_id}")
+
+ # 3. 获取长期记忆(说话人特征库)
+ speaker_profiles = await self.redis.get_speaker_profiles(session_id)
+ logger.debug(f"获取说话人特征库 - 会话ID: {session_id}, 说话人数量: {len(speaker_profiles)}")
+
+ # 4. 识别说话人身份(使用长期记忆)
+ history = await self.redis.get_history(session_id)
+ speaker = await self.profiler.identify(buf, history, speaker_profiles)
+ logger.info(f"[Profiler] 识别说话人: {speaker} - 会话ID: {session_id}")
+
+ # 5. 保存到长期记忆(更新说话人特征库)
+ await self.redis.save_speaker_profile(session_id, speaker, buf)
+
+ # 6. 保存到历史记录并清空缓冲
+ await self.redis.push_history(session_id, speaker, buf)
+ await self.redis.clear_buffer(session_id)
+
+ # 7. 构造结果消息
+ segments = [{
+ "id": 0,
+ "speaker": speaker,
+ "embedding": None, # 占位(预留字段)
+ "seek": 0,
+ "full_text": buf.strip()
+ }]
+
+ msg = {
+ "mode": "offline-speaker",
+ "type": "asr_with_speaker",
+ "segments": segments,
+ "speaker": speaker
+ }
+
+ # 推送结果到前端(失败时静默处理)
+ try:
+ await websocket.send(json.dumps(msg, ensure_ascii=False))
+ except:
+ pass
+
+
+def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
+ text = (data.get("text") or "").strip()
+ speaker = (data.get("spk_name") or "").strip()
+ if not text or not speaker or speaker.lower() == "unknown":
+ return None
+
+ segment = {
+ "id": 0,
+ "speaker": speaker,
+ "embedding": None,
+ "seek": 0,
+ "full_text": text,
+ "start": None,
+ "end": None,
+ }
+ msg = {
+ "mode": "offline-speaker",
+ "type": "asr_with_speaker",
+ "source": "acoustic",
+ "speaker": speaker,
+ "spk_score": data.get("spk_score"),
+ "text": text,
+ "wav_name": data.get("wav_name"),
+ "is_final": data.get("is_final", True),
+ "segments": [segment],
+ }
+ if "timestamp" in data:
+ msg["timestamp"] = data["timestamp"]
+ if "sentence_info" in data:
+ msg["sentence_info"] = data["sentence_info"]
+ return msg
+
+
+# ========================
+# 5. WebSocket 处理与任务追踪
+# ========================
+
+async def forward_audio(websocket):
+ """
+ WebSocket 连接处理函数(核心入口)
+
+ 职责:
+ 1. 接受前端 WebSocket 连接
+ 2. 建立到 ASR 服务的下游连接
+ 3. 双向转发消息(前端 -> ASR,ASR -> 前端)
+ 4. 拦截 ASR 结果并触 Agent 处理
+ 5. 确保所有后台任务完成后才退出(防止数据丢失)
+
+ Args:
+ websocket: 前端 WebSocket 连接对象
+ """
+ logger.info("客户端已连接")
+
+ client = None
+ redis_mgr = None
+ orchestrator = None
+ use_llm_role_identification = USE_LLM_ROLE_IDENTIFICATION
+
+ if use_llm_role_identification:
+ if AsyncOpenAI is None:
+ logger.error("openai package is not installed; LLM role identification disabled")
+ use_llm_role_identification = False
+ elif redis is None:
+ logger.error("redis package is not installed; LLM role identification disabled")
+ use_llm_role_identification = False
+ else:
+ # 初始化 LLM 客户端、Redis 管理器和编排器
+ client = AsyncOpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
+ redis_mgr = RedisManager(REDIS_URL)
+ orchestrator = A2AOrchestrator(client, redis_mgr)
+
+ # 用于追踪后台任务,防止连接关闭时任务被强制销毁
+ background_tasks: Set[asyncio.Task] = set()
+
+ try:
+ # 连接到目标 ASR 服务
+ # 添加连接参数以提高鲁棒性
+ async with websockets.connect(
+ TARGET_WS_URL,
+ close_timeout=10, # 关闭超时
+ ping_timeout=20, # ping 超时
+ max_queue=1024, # 消息队列大小
+ subprotocols=["binary"],
+ ) as target_ws:
+ logger.info(f"已连接到 ASR 服务: {TARGET_WS_URL}")
+
+ # 发送 ASR 配置(双通道模式,PCM 音频格式)
+ config = {"mode": "2pass", "chunk_size": [10, 10, 10], "wav_format": "pcm"}
+ await target_ws.send(json.dumps(config))
+ logger.debug(f"发送 ASR 配置: {config}")
+
+ async def frontend_to_target():
+ """
+ 上行转发:前端音频数据 -> ASR 服务
+ """
+ async for message in websocket:
+ await target_ws.send(message)
+
+ async def target_to_frontend():
+ """
+ 下行转发:ASR 识别结果 -> 前端
+ 同时拦截结果触发 Agent 处理
+ """
+ async for message in target_ws:
+ # 1. 透传 ASR 原始结果到前端(字幕显示)
+ await websocket.send(message)
+
+ # 2. 如果是文本消息,尝试触发 Agent 处理
+ if isinstance(message, str):
+ try:
+ data = json.loads(message)
+ # 只处理最终识别结果(2pass-offline 模式)
+ if data.get("mode") == "2pass-offline":
+ text = data.get("text", "").strip()
+ speaker_msg = build_acoustic_speaker_message(data)
+ if speaker_msg is not None:
+ await websocket.send(json.dumps(speaker_msg, ensure_ascii=False))
+
+ if text and use_llm_role_identification and orchestrator is not None:
+ logger.debug(f"收到 ASR 识别结果: {text[:50]}...")
+ # 创建后台任务处理 Agent 逻辑(不阻塞主循环)
+ t = asyncio.create_task(
+ orchestrator.process_asr_fragment("room_101", text, websocket)
+ )
+ background_tasks.add(t)
+ # 任务完成后自动从追踪集合中移除
+ t.add_done_callback(background_tasks.discard)
+ except Exception as e:
+ logger.error(f"处理 ASR 结果时出错: {e}", exc_info=True)
+
+ # 并发运行两个方向的数据流
+ await asyncio.gather(frontend_to_target(), target_to_frontend())
+
+ except websockets.exceptions.InvalidMessage as e:
+ logger.error(f"WebSocket 握手失败 - ASR 服务可能未正常运行或 URL 配置错误: {e}")
+ logger.error(f"请检查: 1) ASR 服务是否在 {TARGET_WS_URL} 正常运行")
+ logger.error(f" 2) ASR 服务的 URL 路径是否正确")
+ logger.error(f" 3) ASR 服务是否需要特定的认证或配置")
+ except ConnectionRefusedError:
+ logger.error(f"连接被拒绝 - 无法连接到 ASR 服务 {TARGET_WS_URL}")
+ logger.error(f"请检查 ASR 服务是否已启动")
+ except Exception as e:
+ logger.error(f"连接错误: {type(e).__name__}: {e}", exc_info=True)
+ finally:
+ # --- 优雅退出处理 ---
+ if background_tasks:
+ logger.info(f"等待 {len(background_tasks)} 个后台 Agent 任务完成...")
+ # 等待所有剩余任务跑完,最多等 5 秒(防止无限等待)
+ await asyncio.wait(background_tasks, timeout=5)
+ logger.debug("所有后台任务已完成或超时")
+
+ # 释放资源
+ if client is not None:
+ await client.close()
+ if redis_mgr is not None:
+ await redis_mgr.close()
+ logger.info("客户端已断开连接")
+
+
+async def main():
+ """
+ 主函数:启动 WebSocket 服务器
+ """
+ logger.info(f"启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
+ logger.info(f"Agent 模型: {LLM_MODEL}")
+ logger.info(f"ASR 服务地址: {TARGET_WS_URL}")
+ logger.info(f"Redis 地址: {REDIS_URL}")
+ # 启动服务器并持续运行(asyncio.Future() 永不完成)
+ async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT):
+ await asyncio.Future()
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ pass
diff --git a/fastapi_wss/src/a2a_wss.py b/fastapi_wss/src/a2a_wss.py
new file mode 100644
index 0000000..6fb3fbc
--- /dev/null
+++ b/fastapi_wss/src/a2a_wss.py
@@ -0,0 +1,958 @@
+"""
+WebSocket 智能语义分析服务
+
+功能说明:
+1. 作为 WebSocket 代理,接收前端音频数据并转发到 ASR 引擎
+2. 实时处理 ASR 识别结果,应用多 Agent 协作(A2A)模式
+3. Agent 功能:
+ - AuditorAgent: 判断说话人是否完成发言
+ - ProfilerAgent: 识别当前说话人的身份
+4. 使用 Redis 存储会话缓冲和历史记录
+5. 将处理后的带说话人身份的结果推送回前端
+
+架构模式:Agent-to-Agent (A2A) 编排
+"""
+
+import asyncio
+import json
+import os
+import websockets
+import re
+import logging
+from pathlib import Path
+from typing import List, Optional, Dict, Set
+from dataclasses import dataclass
+from dotenv import load_dotenv
+
+try:
+ import redis.asyncio as redis
+except ModuleNotFoundError:
+ redis = None
+
+try:
+ from openai import AsyncOpenAI
+except ModuleNotFoundError:
+ AsyncOpenAI = None
+# ========================
+# 1. 日志配置
+# ========================
+ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
+load_dotenv(ENV_PATH)
+
+
+def get_env(name: str, default: str) -> str:
+ return os.getenv(name, default).strip()
+
+
+def get_env_bool(name: str, default: bool = False) -> bool:
+ value = os.getenv(name)
+ if value is None:
+ return default
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def get_env_list(name: str, default: str) -> List[str]:
+ raw = get_env(name, default)
+ return [item.strip() for item in raw.split(",") if item.strip()]
+
+
+def setup_logging():
+ """配置日志系统"""
+ # 创建日志目录
+ log_dir = "logs"
+ os.makedirs(log_dir, exist_ok=True)
+
+ # 配置日志格式
+ log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ date_format = "%Y-%m-%d %H:%M:%S"
+
+ # 配置根日志记录器
+ logger = logging.getLogger()
+ logger.setLevel(logging.INFO)
+
+ # 清除现有处理器
+ logger.handlers.clear()
+
+ # 控制台处理器
+ console_handler = logging.StreamHandler()
+ console_handler.setLevel(logging.INFO)
+ console_formatter = logging.Formatter(log_format, datefmt=date_format)
+ console_handler.setFormatter(console_formatter)
+ logger.addHandler(console_handler)
+
+ # 文件处理器(所有日志)
+ file_handler = logging.FileHandler(
+ f"{log_dir}/a2a_wss.log",
+ encoding="utf-8"
+ )
+ file_handler.setLevel(logging.DEBUG)
+ file_formatter = logging.Formatter(log_format, datefmt=date_format)
+ file_handler.setFormatter(file_formatter)
+ logger.addHandler(file_handler)
+
+ # 错误日志文件处理器
+ error_handler = logging.FileHandler(
+ f"{log_dir}/a2a_wss_error.log",
+ encoding="utf-8"
+ )
+ error_handler.setLevel(logging.ERROR)
+ error_formatter = logging.Formatter(log_format, datefmt=date_format)
+ error_handler.setFormatter(error_formatter)
+ logger.addHandler(error_handler)
+
+ return logger
+
+# 初始化日志系统
+logger = setup_logging()
+
+# ========================
+# 2. 基础配置
+# ========================
+
+# 目标 ASR 服务的 WebSocket 地址(负责语音识别)
+# 注意:移除末尾的斜杠,避免路径匹配问题
+TARGET_WS_URL = get_env("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
+
+# 本地服务监听地址和端口(接收前端连接)
+LOCAL_HOST = get_env("LOCAL_HOST", "0.0.0.0")
+LOCAL_PORT = int(get_env("LOCAL_PORT", "10095"))
+
+# Redis 连接配置(用于存储会话状态和历史记录)
+REDIS_URL = get_env("REDIS_URL", "redis://localhost:6379/0")
+
+# LLM(大语言模型)配置(用于 Agent 推理)
+LLM_API_KEY = get_env("LLM_API_KEY", "none")
+LLM_BASE_URL = get_env("LLM_BASE_URL", "http://192.168.0.46:59800/v1")
+LLM_MODEL = get_env("LLM_MODEL", "46-qwen3.5-35B")
+USE_LLM_ROLE_IDENTIFICATION = get_env_bool("USE_LLM_ROLE_IDENTIFICATION", False)
+LLM_ROLE_CANDIDATES = get_env_list(
+ "LLM_ROLE_CANDIDATES",
+ "主持人,正方辩手,反方辩手,计时员,评委,嘉宾,未知",
+)
+LLM_ROLE_MIN_TEXT_LEN = int(get_env("LLM_ROLE_MIN_TEXT_LEN", "8"))
+LLM_ROLE_CACHE_CONFIDENCE = float(get_env("LLM_ROLE_CACHE_CONFIDENCE", "0.82"))
+LLM_ROLE_HISTORY_MAX_CHARS = int(get_env("LLM_ROLE_HISTORY_MAX_CHARS", "1200"))
+# 候选说话人列表(用于身份识别 Agent)
+CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员", "控制要素席", "空中侦察席"]
+
+# ========================
+# 3. Redis 管理器
+# ========================
+
+class RedisManager:
+ """
+ Redis 会话管理器
+
+ 职责:
+ - 管理与 Redis 的连接池
+ - 存储每个会话的文本缓冲(累积不完整的识别片段)
+ - 存储历史对话记录(用于上下文理解)
+ """
+
+ def __init__(self, url):
+ """
+ 初始化 Redis 连接池
+
+ Args:
+ url: Redis 连接 URL
+ """
+ if redis is None:
+ raise RuntimeError("redis package is not installed")
+ # 使用连接池提高并发性能,decode_responses=True 自动将字节转为字符串
+ self.pool = redis.ConnectionPool.from_url(url, decode_responses=True)
+ # 键前缀,用于命名隔离,避免与其他业务冲突
+ self.prefix = "meeting:"
+
+ async def _get_conn(self):
+ """从连接池获取一个 Redis 连接"""
+ return redis.Redis(connection_pool=self.pool)
+
+ async def get_buffer(self, sid):
+ """
+ 获取指定会话的文本缓冲
+
+ Args:
+ sid: 会话 ID(Session ID)
+
+ Returns:
+ 该会话当前累积的文本片段
+ """
+ r = await self._get_conn()
+ val = await r.get(f"{self.prefix}buffer:{sid}") or ""
+ return val
+
+ async def append_buffer(self, sid, text):
+ """
+ 向会话缓冲追加新的文本片段
+
+ Args:
+ sid: 会话 ID
+ text: 新识别的文本片段
+ """
+ r = await self._get_conn()
+ # 追加文本并添加空格分隔
+ await r.append(f"{self.prefix}buffer:{sid}", text + " ")
+
+ async def clear_buffer(self, sid):
+ """
+ 清空指定会话的缓冲区
+
+ 用途:当判断发言完成后,清空缓冲准备下一次发言
+
+ Args:
+ sid: 会话 ID
+ """
+ r = await self._get_conn()
+ await r.delete(f"{self.prefix}buffer:{sid}")
+
+ async def push_history(self, sid, role, content):
+ """
+ 将完成的对话推入历史记录
+
+ Args:
+ sid: 会话 ID
+ role: 说话人角色
+ content: 对话内容
+ """
+ r = await self._get_conn()
+ key = f"{self.prefix}history:{sid}"
+ # 将对话记录推入列表右端
+ await r.rpush(key, json.dumps({"role": role, "content": content}))
+ # 只保留最近 20 条记录,避免历史过长影响性能
+ await r.ltrim(key, -20, -1)
+
+ async def get_history(self, sid):
+ """
+ 获取会话的历史对话记录
+
+ Args:
+ sid: 会话 ID
+
+ Returns:
+ 历史对话列表,每项包含 role 和 content
+ """
+ r = await self._get_conn()
+ items = await r.lrange(f"{self.prefix}history:{sid}", 0, -1)
+ return [json.loads(i) for i in items]
+
+ async def save_speaker_profile(self, sid, speaker, text, features=None):
+ """
+ 保存说话人的特征到长期记忆
+
+ Args:
+ sid: 会话 ID
+ speaker: 说话人名称
+ text: 发言文本
+ features: 可选的特征字典(如关键词、语速等)
+ """
+ r = await self._get_conn()
+ key = f"{self.prefix}speaker_profile:{sid}:{speaker}"
+
+ # 构造特征记录
+ profile_entry = {
+ "text": text[:200], # 只保留前200个字符作为示例
+ "timestamp": asyncio.get_event_loop().time(),
+ "features": features or {}
+ }
+
+ # 将记录推入该说话人的历史列表
+ await r.rpush(key, json.dumps(profile_entry))
+ # 只保留最近10条该说话人的记录
+ await r.ltrim(key, -10, -1)
+
+ async def get_speaker_profiles(self, sid):
+ """
+ 获取所有说话人的历史特征记录
+
+ Args:
+ sid: 会话 ID
+
+ Returns:
+ 字典,key为说话人名称,value为该说话人的历史记录列表
+ """
+ r = await self._get_conn()
+ # 扫描所有说话人的profile键
+ pattern = f"{self.prefix}speaker_profile:{sid}:*"
+ profiles = {}
+
+ async for key in r.scan_iter(match=pattern):
+ # 从键名中提取说话人名称
+ speaker = key.decode() if isinstance(key, bytes) else key
+ speaker = speaker.split(":")[-1]
+
+ # 获取该说话人的所有历史记录
+ items = await r.lrange(key, 0, -1)
+ profiles[speaker] = [json.loads(i) for i in items]
+
+ return profiles
+
+ async def close(self):
+ """关闭连接池,释放资源(修复 DeprecationWarning)"""
+ await self.pool.disconnect()
+
+# ========================
+# 3. Agent 实现
+# ========================
+
+class BaseAgent:
+ """
+ Agent 基类
+
+ 提供所有 Agent 的通用能力:
+ - 调用 LLM 进行推理
+ - 提取 LLM 返回的 XML 标签结果
+ """
+
+ def __init__(self, client: AsyncOpenAI):
+ """
+ 初始化 Agent
+
+ Args:
+ client: OpenAI 异步客户端
+ """
+ self.client = client
+
+ async def _call_llm(self, system_prompt: str, user_content: str) -> str:
+ """
+ 调用大语言模型进行推理
+
+ Args:
+ system_prompt: 系统提示词(定义 Agent 的角色和行为)
+ user_content: 用户输入内容
+
+ Returns:
+ LLM 返回的文本结果,失败时返回空字符串
+ """
+ try:
+ response = await self.client.chat.completions.create(
+ model=LLM_MODEL,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_content}
+ ],
+ temperature=0.1, # 低温度使输出更确定、一致
+ )
+ return response.choices[0].message.content
+ except Exception as e:
+ logger.error(f"LLM调用失败: {e}", exc_info=True)
+ return ""
+
+ def _extract_result(self, text: str) -> str:
+ """
+ 从 LLM 返回结果中提取 标签内容
+
+ Args:
+ text: LLM 返回的完整文本
+
+ Returns:
+ 提取的结果,如果没有标签则返回原文
+ """
+ match = re.search(r'(.*?) ', text, re.DOTALL | re.IGNORECASE)
+ return match.group(1).strip() if match else text.strip()
+
+
+class AuditorAgent(BaseAgent):
+ """
+ 审计 Agent:判断当前发言是否完成
+
+ 功能:
+ - 分析累积的文本缓冲
+ - 判断说话人是否已经完成一次完整的发言
+ - 判断依据:语义完整性、停顿暗示等
+ """
+
+ # 系统提示词:定义 Agent 的角色和输出格式
+ SYSTEM_PROMPT = "你是一个对话状态监测专家。请判断发言是否结束,放入 true/false 中。"
+
+ async def check(self, text: str) -> bool:
+ """
+ 判断给定文本是否代表一次完整的发言
+
+ Args:
+ text: 当前累积的文本片段
+
+ Returns:
+ True 表示发言完成,False 表示发言未完成(继续累积)
+ """
+ res = await self._call_llm(self.SYSTEM_PROMPT, f"内容:{text}")
+ return "true" in self._extract_result(res).lower()
+
+
+class ProfilerAgent(BaseAgent):
+ """
+ 分析 Agent:识别说话人身份
+
+ 功能:
+ - 基于文本内容和说话风格识别说话人
+ - 利用历史对话上下文提高识别准确率
+ - 利用长期记忆(说话人特征库)增强识别
+ - 从预定义的候选说话人列表中选择
+ """
+
+ # 系统提示词:定义识别任务和候选说话人列表
+ SYSTEM_PROMPT = f"你是一个会议身份识别专家。请从 {CANDIDATE_SPEAKERS} 中选择发言人,放入 名称 中。"
+
+ # 增强版提示词:结合长期记忆
+ SYSTEM_PROMPT_WITH_MEMORY = f"""你是一个会议身份识别专家。请根据以下信息识别发言人:
+
+候选说话人列表:{CANDIDATE_SPEAKERS}
+
+=== 会议历史记录(按时间顺序)===
+{{history}}
+
+=== 说话人特征库(长期记忆)===
+{{speaker_profiles}}
+
+=== 当前待识别的发言 ===
+{{current_text}}
+
+=== 识别指南 ===
+请仔细分析并综合判断:
+
+1. **对话流程分析**:
+ - 查看历史记录中的对话顺序和上下文
+ - 判断当前发言是对之前谁的话题的回应、延续或补充
+ - 注意对话的逻辑关系和互动模式
+
+2. **说话风格匹配**:
+ - 每个角色的用词习惯、句式结构、语气的特点
+ - 专有名词、技术术语的使用习惯
+ - 提问方式、陈述方式、指令方式的差异
+
+3. **角色职责判断**:
+ - 根据各角色的职责范围判断发言内容的合理性
+ - 例如:局长通常做总结和决策,主持人负责控场,专员负责具体业务汇报
+
+4. **话题连贯性**:
+ - 当前发言与该角色之前发言的话题是否连贯
+ - 是否体现了该角色应有的专业领域关注点
+
+将识别结果放入 说话人名称 中。"""
+
+ async def identify(self, text: str, history: List[Dict], speaker_profiles: Dict = None) -> str:
+ """
+ 识别当前文本的说话人身份
+
+ Args:
+ text: 当前发言文本
+ history: 历史对话记录(用于上下文理解)
+ speaker_profiles: 说话人特征库(长期记忆),key为说话人名称,value为历史记录列表
+
+ Returns:
+ 识别出的说话人名称
+ """
+ # 如果有长期记忆,使用增强版提示词
+ if speaker_profiles:
+ # 使用更多历史记录(最多15条),保持完整的会议上下文
+ h_str = self._format_history(history[-15:])
+
+ # 构建说话人特征库摘要
+ profiles_summary = self._format_profiles(speaker_profiles)
+
+ prompt = self.SYSTEM_PROMPT_WITH_MEMORY.format(
+ history=h_str or "暂无历史记录",
+ speaker_profiles=profiles_summary,
+ current_text=text
+ )
+
+ res = await self._call_llm(prompt, "")
+ return self._extract_result(res)
+ else:
+ # 原有的简单识别逻辑
+ h_str = "\n".join([f"{h['role']}: {h['content']}" for h in history[-5:]])
+ res = await self._call_llm(self.SYSTEM_PROMPT, f"历史:{h_str}\n当前:{text}")
+ return self._extract_result(res)
+
+ def _format_history(self, history: List[Dict]) -> str:
+ """
+ 格式化历史对话记录,添加序号和时间顺序
+
+ Args:
+ history: 历史对话列表
+
+ Returns:
+ 格式化后的对话文本
+ """
+ if not history:
+ return "暂无历史记录"
+
+ lines = []
+ for idx, h in enumerate(history, 1):
+ content_preview = h['content'][:150] # 只显示前150字符
+ if len(h['content']) > 150:
+ content_preview += "..."
+ lines.append(f"[{idx}] {h['role']}: {content_preview}")
+
+ return "\n".join(lines)
+
+ def _format_profiles(self, speaker_profiles: Dict) -> str:
+ """
+ 格式化说话人特征库为可读文本
+
+ Args:
+ speaker_profiles: 说话人特征字典
+
+ Returns:
+ 格式化后的文本摘要
+ """
+ if not speaker_profiles:
+ return "暂无说话人特征记录"
+
+ summary_lines = []
+ for speaker, profiles in speaker_profiles.items():
+ # 提取该说话人最近5条发言的文本片段
+ recent_texts = [p['text'] for p in profiles[-5:]]
+ texts_str = "; ".join(recent_texts)
+
+ # 统计该说话人的发言次数
+ speaking_count = len(profiles)
+
+ summary_lines.append(f"- 【{speaker}】(发言{speaking_count}次): {texts_str}")
+
+ return "\n".join(summary_lines)
+
+# ========================
+# 4. A2A 编排器
+# ========================
+
+class A2AOrchestrator:
+ """
+ Agent-to-Agent 编排器
+
+ 职责:
+ - 协调多个 Agent 的协作流程
+ - 处理 ASR 识别结果,依次调用 Auditor 和 Profiler
+ - 管理会话状态(缓冲和历史记录)
+ - 将最终结果推送给前端
+ """
+
+ def __init__(self, client: AsyncOpenAI, redis_mgr: RedisManager):
+ """
+ 初始化编排器
+
+ Args:
+ client: OpenAI 异步客户端
+ redis_mgr: Redis 管理器实例
+ """
+ self.redis = redis_mgr
+ self.auditor = AuditorAgent(client) # 审计 Agent
+ self.profiler = ProfilerAgent(client) # 分析 Agent
+
+ async def process_asr_fragment(self, session_id: str, fragment: str, websocket):
+ """
+ 处理 ASR 识别的文本片段(核心流程)
+
+ 流程:
+ 1. 将新片段追加到缓冲区
+ 2. 让 Auditor 判断发言是否完成
+ 3. 如果完成,让 Profiler 使用长期记忆识别说话人
+ 4. 将识别结果保存到长期记忆
+ 5. 将结果存入历史,清空缓冲
+ 6. 推送结果到前端
+
+ Args:
+ session_id: 会话 ID
+ fragment: ASR 识别的新文本片段
+ websocket: 前端 WebSocket 连接(用于推送结果)
+ """
+ # 1. 追加新片段到缓冲区
+ await self.redis.append_buffer(session_id, fragment)
+ buf = await self.redis.get_buffer(session_id)
+
+ # 2. 检查发言是否完成
+ if await self.auditor.check(buf):
+ logger.info(f"[Auditor] 发言完成检测通过 - 会话ID: {session_id}")
+
+ # 3. 获取长期记忆(说话人特征库)
+ speaker_profiles = await self.redis.get_speaker_profiles(session_id)
+ logger.debug(f"获取说话人特征库 - 会话ID: {session_id}, 说话人数量: {len(speaker_profiles)}")
+
+ # 4. 识别说话人身份(使用长期记忆)
+ history = await self.redis.get_history(session_id)
+ speaker = await self.profiler.identify(buf, history, speaker_profiles)
+ logger.info(f"[Profiler] 识别说话人: {speaker} - 会话ID: {session_id}")
+
+ # 5. 保存到长期记忆(更新说话人特征库)
+ await self.redis.save_speaker_profile(session_id, speaker, buf)
+
+ # 6. 保存到历史记录并清空缓冲
+ await self.redis.push_history(session_id, speaker, buf)
+ await self.redis.clear_buffer(session_id)
+
+ # 7. 构造结果消息
+ segments = [{
+ "id": 0,
+ "speaker": speaker,
+ "embedding": None, # 占位(预留字段)
+ "seek": 0,
+ "full_text": buf.strip()
+ }]
+
+ msg = {
+ "mode": "offline-speaker",
+ "type": "asr_with_speaker",
+ "segments": segments,
+ "speaker": speaker
+ }
+
+ # 推送结果到前端(失败时静默处理)
+ try:
+ await websocket.send(json.dumps(msg, ensure_ascii=False))
+ except:
+ pass
+
+
+def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
+ text = (data.get("text") or "").strip()
+ speaker = (data.get("spk_name") or "").strip()
+ if not text or not speaker or speaker.lower() == "unknown":
+ return None
+
+ segment = {
+ "id": 0,
+ "speaker": speaker,
+ "embedding": None,
+ "seek": 0,
+ "full_text": text,
+ "start": None,
+ "end": None,
+ }
+ msg = {
+ "mode": "offline-speaker",
+ "type": "asr_with_speaker",
+ "source": "acoustic",
+ "speaker": speaker,
+ "spk_score": data.get("spk_score"),
+ "text": text,
+ "wav_name": data.get("wav_name"),
+ "is_final": data.get("is_final", True),
+ "segments": [segment],
+ }
+ if "timestamp" in data:
+ msg["timestamp"] = data["timestamp"]
+ if "sentence_info" in data:
+ msg["sentence_info"] = data["sentence_info"]
+ return msg
+
+
+MOJIBAKE_MARKERS = ("锛", "涓", "灏", "鍒", "鏄", "鎴", "浣", "鐨", "璇", "濂", "绗")
+
+
+def maybe_repair_mojibake(text: str) -> str:
+ if not text:
+ return text
+ marker_count = sum(text.count(marker) for marker in MOJIBAKE_MARKERS)
+ if marker_count < 3:
+ return text
+ try:
+ repaired = text.encode("gbk").decode("utf-8")
+ except UnicodeError:
+ return text
+ repaired_marker_count = sum(repaired.count(marker) for marker in MOJIBAKE_MARKERS)
+ return repaired if repaired_marker_count < marker_count else text
+
+
+def _extract_json_object(text: str) -> Dict:
+ if not text:
+ return {}
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ pass
+
+ start = text.find("{")
+ end = text.rfind("}")
+ if start == -1 or end == -1 or end <= start:
+ return {}
+ try:
+ return json.loads(text[start:end + 1])
+ except json.JSONDecodeError:
+ return {}
+
+
+def _safe_confidence(value, default: float = 0.0) -> float:
+ try:
+ confidence = float(value)
+ except (TypeError, ValueError):
+ confidence = default
+ return max(0.0, min(1.0, confidence))
+
+
+class RoleIdentifier:
+ def __init__(self, client):
+ self.client = client
+ self.profiles: Dict[str, Dict] = {}
+
+ def _get_profile(self, speaker: str) -> Dict:
+ return self.profiles.setdefault(
+ speaker,
+ {"texts": [], "role": None, "confidence": 0.0, "reason": ""},
+ )
+
+ def _append_text(self, profile: Dict, text: str):
+ if not text:
+ return
+ profile["texts"].append(text)
+ joined = "\n".join(profile["texts"])
+ while len(joined) > LLM_ROLE_HISTORY_MAX_CHARS and len(profile["texts"]) > 1:
+ profile["texts"].pop(0)
+ joined = "\n".join(profile["texts"])
+
+ async def identify(self, speaker: str, text: str) -> Optional[Dict]:
+ clean_text = maybe_repair_mojibake(text.strip())
+ profile = self._get_profile(speaker)
+ self._append_text(profile, clean_text)
+
+ cached_role = profile.get("role")
+ cached_confidence = _safe_confidence(profile.get("confidence"))
+ if cached_role and cached_confidence >= LLM_ROLE_CACHE_CONFIDENCE:
+ return {
+ "role": cached_role,
+ "role_confidence": cached_confidence,
+ "role_reason": profile.get("reason", ""),
+ "role_source": "llm_text_cached",
+ "llm_text": clean_text,
+ }
+
+ if len(clean_text) < LLM_ROLE_MIN_TEXT_LEN:
+ return None
+
+ system_prompt = (
+ "你是会议/辩论转写的角色识别器。"
+ "只能根据文本内容、说话顺序和同一声纹说话人的历史文本判断角色。"
+ "不要修改 speaker 编号;speaker_1、speaker_2 是声纹身份,不是角色。"
+ "候选角色只能从给定列表选择。"
+ "只返回 JSON,不要输出解释文本。"
+ 'JSON 格式:{"role":"候选角色之一","confidence":0.0,"reason":"一句话原因"}'
+ )
+ user_payload = {
+ "acoustic_speaker": speaker,
+ "candidate_roles": LLM_ROLE_CANDIDATES,
+ "speaker_history": profile["texts"][-6:],
+ "current_text": clean_text,
+ }
+
+ try:
+ response = await self.client.chat.completions.create(
+ model=LLM_MODEL,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
+ ],
+ temperature=0.0,
+ )
+ content = response.choices[0].message.content or ""
+ parsed = _extract_json_object(content)
+ role = str(parsed.get("role") or "").strip()
+ if role not in LLM_ROLE_CANDIDATES:
+ role = "未知" if "未知" in LLM_ROLE_CANDIDATES else LLM_ROLE_CANDIDATES[-1]
+ confidence = _safe_confidence(parsed.get("confidence"))
+ reason = str(parsed.get("reason") or "").strip()[:160]
+
+ profile["role"] = role
+ profile["confidence"] = confidence
+ profile["reason"] = reason
+
+ return {
+ "role": role,
+ "role_confidence": confidence,
+ "role_reason": reason,
+ "role_source": "llm_text",
+ "llm_text": clean_text,
+ }
+ except Exception as e:
+ logger.error(f"LLM角色判断失败: {e}", exc_info=True)
+ return None
+
+
+def build_role_enriched_message(speaker_msg: Dict, role_result: Dict) -> Dict:
+ msg = dict(speaker_msg)
+ msg["type"] = "asr_with_speaker_role"
+ msg["source"] = "acoustic+llm"
+ msg["role"] = role_result.get("role")
+ msg["role_confidence"] = role_result.get("role_confidence")
+ msg["role_reason"] = role_result.get("role_reason")
+ msg["role_source"] = role_result.get("role_source")
+
+ segments = []
+ for segment in speaker_msg.get("segments", []):
+ seg = dict(segment)
+ seg["role"] = msg["role"]
+ seg["role_confidence"] = msg["role_confidence"]
+ seg["role_reason"] = msg["role_reason"]
+ segments.append(seg)
+ msg["segments"] = segments
+ return msg
+
+
+# ========================
+# 5. WebSocket 处理与任务追踪
+# ========================
+
+async def forward_audio(websocket):
+ """
+ WebSocket 连接处理函数(核心入口)
+
+ 职责:
+ 1. 接受前端 WebSocket 连接
+ 2. 建立到 ASR 服务的下游连接
+ 3. 双向转发消息(前端 -> ASR,ASR -> 前端)
+ 4. 拦截 ASR 结果并触 Agent 处理
+ 5. 确保所有后台任务完成后才退出(防止数据丢失)
+
+ Args:
+ websocket: 前端 WebSocket 连接对象
+ """
+ logger.info("客户端已连接")
+
+ client = None
+ redis_mgr = None
+ role_identifier = None
+ use_llm_role_identification = USE_LLM_ROLE_IDENTIFICATION
+
+ if use_llm_role_identification:
+ if AsyncOpenAI is None:
+ logger.error("openai package is not installed; LLM role identification disabled")
+ use_llm_role_identification = False
+ else:
+ client = AsyncOpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
+ role_identifier = RoleIdentifier(client)
+
+ # 用于追踪后台任务,防止连接关闭时任务被强制销毁
+ background_tasks: Set[asyncio.Task] = set()
+
+ try:
+ # 连接到目标 ASR 服务
+ # 添加连接参数以提高鲁棒性
+ async with websockets.connect(
+ TARGET_WS_URL,
+ close_timeout=10, # 关闭超时
+ ping_interval=None,
+ ping_timeout=None,
+ max_queue=1024, # 消息队列大小
+ subprotocols=["binary"],
+ ) as target_ws:
+ logger.info(f"已连接到 ASR 服务: {TARGET_WS_URL}")
+
+ # 发送 ASR 配置(双通道模式,PCM 音频格式)
+ config = {"mode": "2pass", "chunk_size": [10, 10, 10], "wav_format": "pcm"}
+ await target_ws.send(json.dumps(config))
+ logger.debug(f"发送 ASR 配置: {config}")
+
+ async def frontend_to_target():
+ """
+ 上行转发:前端音频数据 -> ASR 服务
+ """
+ try:
+ async for message in websocket:
+ await target_ws.send(message)
+ except websockets.exceptions.ConnectionClosed:
+ logger.info("客户端连接已关闭,停止上行转发")
+ finally:
+ try:
+ await target_ws.close()
+ except Exception:
+ pass
+
+ async def send_role_update(speaker_msg: Dict):
+ if role_identifier is None:
+ return
+ try:
+ role_result = await role_identifier.identify(
+ speaker_msg.get("speaker", ""),
+ speaker_msg.get("text", ""),
+ )
+ if not role_result:
+ return
+ role_msg = build_role_enriched_message(speaker_msg, role_result)
+ await websocket.send(json.dumps(role_msg, ensure_ascii=False))
+ except Exception as e:
+ logger.error(f"发送LLM角色结果失败: {e}", exc_info=True)
+
+ async def target_to_frontend():
+ """
+ 下行转发:ASR 识别结果 -> 前端
+ 同时拦截结果触发 Agent 处理
+ """
+ try:
+ async for message in target_ws:
+ # 1. 透传 ASR 原始结果到前端(字幕显示)
+ try:
+ await websocket.send(message)
+ except websockets.exceptions.ConnectionClosed:
+ logger.info("客户端连接已关闭,停止下行转发")
+ break
+
+ # 2. 如果是文本消息,尝试触发 Agent 处理
+ if isinstance(message, str):
+ try:
+ data = json.loads(message)
+ # 只处理最终识别结果(2pass-offline 模式)
+ if data.get("mode") == "2pass-offline":
+ speaker_msg = build_acoustic_speaker_message(data)
+ if speaker_msg is not None:
+ await websocket.send(json.dumps(speaker_msg, ensure_ascii=False))
+ if use_llm_role_identification and role_identifier is not None:
+ t = asyncio.create_task(send_role_update(speaker_msg))
+ background_tasks.add(t)
+ t.add_done_callback(background_tasks.discard)
+ except websockets.exceptions.ConnectionClosed:
+ logger.info("客户端连接已关闭,停止发送增强结果")
+ break
+ except Exception as e:
+ logger.error(f"处理 ASR 结果时出错: {e}", exc_info=True)
+ except websockets.exceptions.ConnectionClosed as e:
+ logger.info(f"ASR 服务连接已关闭: {type(e).__name__}: {e}")
+ finally:
+ try:
+ await websocket.close()
+ except Exception:
+ pass
+
+ # 并发运行两个方向的数据流
+ await asyncio.gather(frontend_to_target(), target_to_frontend())
+
+ except websockets.exceptions.InvalidMessage as e:
+ logger.error(f"WebSocket 握手失败 - ASR 服务可能未正常运行或 URL 配置错误: {e}")
+ logger.error(f"请检查: 1) ASR 服务是否在 {TARGET_WS_URL} 正常运行")
+ logger.error(f" 2) ASR 服务的 URL 路径是否正确")
+ logger.error(f" 3) ASR 服务是否需要特定的认证或配置")
+ except ConnectionRefusedError:
+ logger.error(f"连接被拒绝 - 无法连接到 ASR 服务 {TARGET_WS_URL}")
+ logger.error(f"请检查 ASR 服务是否已启动")
+ except Exception as e:
+ logger.error(f"连接错误: {type(e).__name__}: {e}", exc_info=True)
+ finally:
+ # --- 优雅退出处理 ---
+ if background_tasks:
+ logger.info(f"等待 {len(background_tasks)} 个后台 Agent 任务完成...")
+ # 等待所有剩余任务跑完,最多等 5 秒(防止无限等待)
+ await asyncio.wait(background_tasks, timeout=5)
+ logger.debug("所有后台任务已完成或超时")
+
+ # 释放资源
+ if client is not None:
+ await client.close()
+ if redis_mgr is not None:
+ await redis_mgr.close()
+ logger.info("客户端已断开连接")
+
+
+async def main():
+ """
+ 主函数:启动 WebSocket 服务器
+ """
+ logger.info(f"启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
+ logger.info(f"Agent 模型: {LLM_MODEL}")
+ logger.info(f"ASR 服务地址: {TARGET_WS_URL}")
+ logger.info(f"Redis 地址: {REDIS_URL}")
+ # 启动服务器并持续运行(asyncio.Future() 永不完成)
+ async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT, ping_interval=None):
+ await asyncio.Future()
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ pass
diff --git a/fastapi_wss/src/agent_wss.py b/fastapi_wss/src/agent_wss.py
new file mode 100644
index 0000000..60c4c68
--- /dev/null
+++ b/fastapi_wss/src/agent_wss.py
@@ -0,0 +1,271 @@
+import asyncio
+import json
+import os
+import websockets
+import redis.asyncio as redis
+import re
+from openai import AsyncOpenAI # ⬅️ 引入 OpenAI 异步客户端
+
+# ========================
+# 基础配置
+# ========================
+TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
+LOCAL_HOST = "0.0.0.0"
+LOCAL_PORT = 10095
+
+# Redis
+REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
+REDIS_KEY_PREFIX = "meeting:"
+
+# LLM 配置 (适配 OpenAI SDK)
+# 如果是 OpenAI 官方,Base URL 默认即可;如果是 DeepSeek/阿里等,需修改 BASE_URL
+LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-xxxx")
+LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://172.18.127.124:9997/v1")
+LLM_MODEL = os.getenv("LLM_MODEL", "Qwen3-32B")
+
+# 候选人
+CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员"]
+DEFAULT_SESSION_ID = "room_101"
+
+# ASR 初始化配置
+config = {
+ "chunk_size": [10, 10, 10],
+ "wav_name": "h5",
+ "is_speaking": True,
+ "wav_format": "pcm",
+ "chunk_interval": 10,
+ "itn": True,
+ "mode": "2pass",
+ "hotwords": "",
+}
+
+# ========================
+# Redis 操作封装 (保持不变)
+# ========================
+async def append_to_buffer(session_id, text):
+ """将新的片段追加到暂存区"""
+ r = redis.from_url(REDIS_URL, decode_responses=True)
+ key = f"{REDIS_KEY_PREFIX}buffer:{session_id}"
+ await r.append(key, text + " ")
+ current_buffer = await r.get(key)
+ await r.close()
+ return current_buffer
+
+async def clear_buffer(session_id):
+ """清空暂存区"""
+ r = redis.from_url(REDIS_URL, decode_responses=True)
+ key = f"{REDIS_KEY_PREFIX}buffer:{session_id}"
+ await r.delete(key)
+ await r.close()
+
+async def save_history(session_id, role, content):
+ """归档历史记录"""
+ r = redis.from_url(REDIS_URL, decode_responses=True)
+ key = f"{REDIS_KEY_PREFIX}history:{session_id}"
+ record = json.dumps({"role": role, "content": content})
+ await r.rpush(key, record)
+ await r.ltrim(key, -20, -1)
+ await r.close()
+
+async def get_history(session_id):
+ """获取上下文"""
+ r = redis.from_url(REDIS_URL, decode_responses=True)
+ key = f"{REDIS_KEY_PREFIX}history:{session_id}"
+ items = await r.lrange(key, 0, -1)
+ await r.close()
+ return [json.loads(i) for i in items]
+
+# ========================
+# 通用 LLM 调用函数 (改为 OpenAI SDK)
+# ========================
+async def call_llm(client: AsyncOpenAI, system_prompt: str, user_text: str):
+ """
+ 使用 OpenAI SDK 发起调用
+ """
+ try:
+ response = await client.chat.completions.create(
+ model=LLM_MODEL,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_text}
+ ],
+ temperature=0.1,
+ )
+
+ content = response.choices[0].message.content
+ print(f"🤖 LLM 返回: {content}")
+ return content
+ except Exception as e:
+ print(f"❌ OpenAI API 调用异常: {e}")
+ return None
+
+# ========================
+# Agent A: 完结判断 (The Auditor)
+# ========================
+async def agent_check_completion(text_buffer, client: AsyncOpenAI):
+ """
+ 判断当前的文本累积是否已经构成了一个完整的发言回合。
+ """
+ system_prompt = """
+ #ROLE
+ 你是一个对话状态判断助手。你的任务是判断当前的发言内容是否已经结束。
+ #RULE
+ 1. **强制标准**:如果内容中包含 "发言完毕"、"讲完了"、"汇报完毕" 等明确结束语,必须返回 true。
+ 2. **语义标准**:如果内容完整且看似一段独立的话结束,返回 true;如果看似还在列举或句子未完,返回 false。
+ 3. 如果内容仅仅是很短的一句 "嗯"、"好的",视作未结束或无需独立归档。
+ 4. 请将结果用 包裹着。
+
+ #EXAMPLES
+ 示例1:空中侦察席报告,今日预计飞行287架航班,空气湿度适宜,安全风险较小。 true
+ 示例2:控制要素席报告,我这边已经完成了所有的准备工作.false
+ 示例3:下面有请局长发言 false
+ 示例4:地面侦察席报告目前没有发现敌对情况.false
+ """
+
+ result = await call_llm(client, system_prompt, f"当前累积内容:{text_buffer}")
+ if result:
+ # 更健壮的版本(容忍某些格式错误)
+ match = re.search(r'([^<]*)(?: ||$)', result, re.IGNORECASE)
+ if match:
+ content = match.group(1).strip()
+ else:
+ content = False # 或者根据你的逻辑处理未匹配的情况
+ return content
+ return False
+
+# ========================
+# Agent B: 身份识别 (The Profiler)
+# ========================
+async def agent_identify_speaker(text_full, session_id, client: AsyncOpenAI):
+ """
+ 仅在 Agent A 判定结束时调用。根据全文和历史判断身份。
+ """
+ history = await get_history(session_id)
+ history_str = "\n".join([f"{h['role']}: {h['content']}" for h in history])
+
+ system_prompt = f"""
+ #ROLE
+ 你是一个会议记录专家。请根据上下文和完整的段落内容,推断当前的发言人。
+ #RULE
+ 1. **强制标准**:如果内容中包含 "控制要素席"、"空中侦察席"、"海上观察席"、"联合指挥中心"等明确发言人,必须直接返回名称。
+ 2. **语义标准**:如果内容完整且没有明确发言人,则根据语义进行判断,潜在的候选人有:{', '.join(CANDIDATE_SPEAKERS)}。
+ 3. 提供历史上下文:{history_str}
+ 4. 请将结果用 包裹着。
+
+ #EXAMPLES
+ 示例1:空中侦察席报告,今日预计飞行287架航班,空气湿度适宜,安全风险较小。 空中侦察席
+ 示例2:控制要素席报告,我这边已经完成了所有的准备工作,可以开始会议了。 控制要素席
+ 示例3:下面有请局长发言 主持人
+ """
+
+ result = await call_llm(client, system_prompt, f"完整发言内容:{text_full}")
+ if result:
+ match = re.search(r'([^<]*)(?: ||$)', result, re.IGNORECASE)
+ if match:
+ content = match.group(1).strip()
+ return content
+ return "未知说话人"
+
+# ========================
+# 核心编排逻辑 (多 Agent 协作)
+# ========================
+async def orchestrate_agents(new_fragment, websocket, client: AsyncOpenAI):
+ session_id = DEFAULT_SESSION_ID
+
+ # 1. 存入 Buffer (累积)
+ current_buffer = await append_to_buffer(session_id, new_fragment)
+ print(f"📥 [Buffer] 长度: {len(current_buffer)} | 内容片段: {current_buffer[-20:]}")
+
+ # 2. 调用 Agent A 判断是否结束
+ is_finished = await agent_check_completion(current_buffer, client)
+
+ if is_finished:
+ print(f"🛑 [Agent A] 判定发言结束。触发身份识别...")
+
+ # 3. 调用 Agent B 识别身份
+ speaker = await agent_identify_speaker(current_buffer, session_id, client)
+ print(f"👤 [Agent B] 识别为: {speaker}")
+
+ # 4. 存入历史并清空 Buffer
+ await save_history(session_id, speaker, current_buffer)
+ await clear_buffer(session_id)
+
+ # 5. 推送最终结果给前端
+ msg = {
+ "type": "final_speech_record",
+ "speaker": speaker,
+ "content": current_buffer.strip(),
+ "timestamp": "now"
+ }
+ await websocket.send(json.dumps(msg, ensure_ascii=False))
+
+ else:
+ # 未结束,什么都不做,继续等待下一段文本
+ print(f"⏳ [Agent A] 判定未结束...")
+
+# ========================
+# WebSocket 主逻辑
+# ========================
+async def forward_audio(websocket):
+ print("🟢 Client Connected.")
+
+ # 初始化 OpenAI 客户端
+ # 注意:AsyncOpenAI 是线程/协程安全的,也可以放在全局初始化
+ client = AsyncOpenAI(
+ api_key=LLM_API_KEY,
+ base_url=LLM_BASE_URL
+ )
+
+ try:
+ async with websockets.connect(TARGET_WS_URL) as target_ws:
+ await target_ws.send(json.dumps(config))
+
+ async def frontend_to_target():
+ async for message in websocket:
+ await target_ws.send(message)
+
+ async def target_to_frontend():
+ async for message in target_ws:
+ # 1. 实时转发 (用于字幕上屏)
+ await websocket.send(message)
+
+ if isinstance(message, str):
+ try:
+ data = json.loads(message)
+ # 2. 触发 Agent 协作
+ if data.get("mode") == "2pass-offline":
+ asr_text = data.get("text", "").strip()
+ if asr_text:
+ # 异步触发,传入 client
+ asyncio.create_task(
+ orchestrate_agents(asr_text, websocket, client)
+ )
+ except json.JSONDecodeError:
+ pass
+
+ await asyncio.gather(frontend_to_target(), target_to_frontend())
+
+ except Exception as e:
+ print(f"❌ Error: {e}")
+ finally:
+ # 关闭客户端(虽然 AsyncOpenAI 通常会自动管理,但显式关闭是好习惯)
+ await client.close()
+ print("🔴 Client Disconnected.")
+
+# ========================
+# 启动
+# ========================
+async def main():
+ print(f"🚀 启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
+ print(f"🧠 Agent 模型: {LLM_MODEL}")
+
+ # 这里的 websockets.serve 会在 main 协程内部运行,此时已有 Loop
+ async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT):
+ # 创建一个永远 pending 的 Future,让服务一直运行不退出
+ await asyncio.Future()
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ print("\n🛑 服务已停止")
\ No newline at end of file
diff --git a/fastapi_wss/src/app.py b/fastapi_wss/src/app.py
new file mode 100644
index 0000000..9c15f95
--- /dev/null
+++ b/fastapi_wss/src/app.py
@@ -0,0 +1,176 @@
+import asyncio
+import json
+import os
+import tempfile
+import wave
+import websockets
+import aiohttp
+
+# ========================
+# 配置
+# ========================
+TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
+LOCAL_HOST = "0.0.0.0"
+LOCAL_PORT = 10095
+
+# ⚠️ 修改为你自己的 FastAPI ASR 服务地址
+ASR_LOCAL_URL = os.getenv("ASR_LOCAL_URL", "http://172.18.127.124:6688/asr")
+
+SAMPLE_RATE = 16000 # ⬅️ 关键!
+SAMPLE_WIDTH = 2 # 16-bit
+CHANNELS = 1
+
+config = {
+ "chunk_size": [10, 10, 10],
+ "wav_name": "h5",
+ "is_speaking": True,
+ "wav_format": "pcm",
+ "chunk_interval": 10,
+ "itn": True,
+ "mode": "2pass",
+ "hotwords": "",
+}
+
+
+# ========================
+# 调用本地 ASR 接口(带说话人识别)
+# ========================
+async def call_local_asr_with_audio(wav_path: str, session: aiohttp.ClientSession):
+ """调用你自己的 /asr 接口,返回结构化结果"""
+ try:
+ with open(wav_path, "rb") as f:
+ data = aiohttp.FormData()
+ data.add_field(
+ "audio_file",
+ f,
+ filename=os.path.basename(wav_path),
+ content_type="audio/wav"
+ )
+ # 可选:传递参数(如 spk_diarization=True 已默认开启)
+ params = {
+ "spk_diarization": "true",
+ "spk_threshold": "0.6",
+ "output": "json"
+ }
+ async with session.post(ASR_LOCAL_URL, data=data, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
+ if resp.status == 200:
+ return await resp.json()
+ else:
+ text = await resp.text()
+ print(f"❌ 本地 ASR 接口错误: {resp.status} - {text}")
+ return None
+ except Exception as e:
+ print(f"❌ 调用本地 ASR 异常: {e}")
+ return None
+
+
+# ========================
+# 后台处理:音频 → 本地 ASR → 推送结果
+# ========================
+async def process_audio_with_local_asr(audio_chunks, asr_text, websocket, session):
+ if not audio_chunks:
+ return
+
+ wav_path = None
+ try:
+ # 1. 保存为 WAV
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
+ wav_path = tmp.name
+ with wave.open(wav_path, 'wb') as wf:
+ wf.setnchannels(CHANNELS)
+ wf.setsampwidth(SAMPLE_WIDTH)
+ wf.setframerate(SAMPLE_RATE)
+ wf.writeframes(b''.join(audio_chunks))
+
+ # 2. 调用本地 ASR 接口
+ asr_result = await call_local_asr_with_audio(wav_path, session)
+ if not asr_result or not asr_result.get("success"):
+ print("❌ 本地 ASR 处理失败")
+ return
+
+ # 3. 构建前端消息
+ segments = asr_result.get("segments", [])
+ full_text = asr_result.get("text", "")
+
+ # 推送完整结果(含说话人)
+ msg = {
+ "type": "asr_with_speaker",
+ "full_text": full_text,
+ "segments": segments, # 每段含 speaker, text, start, end 等
+ "original_asr_text": asr_text # 来自远程 ASR 的原始文本(可选)
+ }
+
+ await websocket.send(json.dumps(msg, ensure_ascii=False))
+ print(f"✅ 本地 ASR + 说话人识别完成,共 {len(segments)} 段")
+
+ finally:
+ if wav_path and os.path.exists(wav_path):
+ os.unlink(wav_path)
+
+
+# ========================
+# 主 WebSocket 转发逻辑
+# ========================
+async def forward_audio(websocket):
+ print("🟢 前端已连接。")
+ current_audio_chunks = []
+ lock = asyncio.Lock()
+
+ async with aiohttp.ClientSession() as session:
+ try:
+ async with websockets.connect(TARGET_WS_URL) as target_ws:
+ print(f"🔗 连接到远程 ASR: {TARGET_WS_URL}")
+ await target_ws.send(json.dumps(config))
+
+ async def frontend_to_target():
+ async for message in websocket:
+ if isinstance(message, bytes):
+ async with lock:
+ current_audio_chunks.append(message)
+ await target_ws.send(message)
+ else:
+ await target_ws.send(message)
+
+ async def target_to_frontend():
+ async for message in target_ws:
+ # 立即转发原始 ASR 消息(保持低延迟)
+ await websocket.send(message)
+
+ if isinstance(message, str):
+ try:
+ data = json.loads(message)
+ if data.get("mode") == "2pass-offline":
+ asr_text = data.get("text", "").strip()
+ print(f"🔊 触发本地 ASR 处理: '{asr_text}'")
+
+ async with lock:
+ audio_copy = current_audio_chunks.copy()
+ current_audio_chunks.clear()
+
+ # 启动后台任务:调用你自己的 /asr 接口
+ asyncio.create_task(
+ process_audio_with_local_asr(audio_copy, asr_text, websocket, session)
+ )
+ except json.JSONDecodeError:
+ pass
+
+ await asyncio.gather(frontend_to_target(), target_to_frontend())
+
+ except Exception as e:
+ print(f"❌ 连接异常: {e}")
+ finally:
+ print("🔴 前端断开连接。")
+
+
+# ========================
+# 启动服务
+# ========================
+async def main():
+ print(f"🚀 启动 WebSocket 中转服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
+ print(f"📝 本地 ASR 接口: {ASR_LOCAL_URL}")
+ server = await websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT)
+ await server.wait_closed()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
\ No newline at end of file
diff --git a/fastapi_wss/src/speaker_det.py b/fastapi_wss/src/speaker_det.py
new file mode 100644
index 0000000..890e982
--- /dev/null
+++ b/fastapi_wss/src/speaker_det.py
@@ -0,0 +1,317 @@
+import asyncio
+import json
+import os
+import tempfile
+import wave
+import websockets
+import aiohttp
+import logging
+
+from dotenv import load_dotenv
+
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+ force=True
+)
+logger = logging.getLogger("app")
+
+
+# ========================
+# 配置
+# ========================
+load_dotenv()
+logger.info("正在加载环境变量 ")
+TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
+LOCAL_HOST = "0.0.0.0"
+LOCAL_PORT = 10095
+ASR_LOCAL_URL = os.getenv("ASR_LOCAL_URL", "http://172.18.127.124:6688/asr")
+
+SAMPLE_RATE = 16000
+SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
+CHANNELS = 1
+
+# 计算每秒的字节数
+BYTES_PER_SECOND = SAMPLE_RATE * SAMPLE_WIDTH * CHANNELS
+# 设定最小触发时长(秒)
+MIN_DURATION_THRESHOLD = 3.0
+
+config = {
+ "chunk_size": [10, 10, 10],
+ "wav_name": "h5",
+ "is_speaking": True,
+ "wav_format": "pcm",
+ "chunk_interval": 10,
+ "itn": True,
+ "mode": "2pass",
+ "hotwords": "",
+}
+
+
+# ========================
+# 新增:声纹会话缓存管理类 (带超时机制 + 日志)
+# ========================
+class SpeakerSession:
+ def __init__(self):
+ self.last_speaker = None
+ self.buffer = []
+ self.accumulated_duration = 0.0
+ self.lock = asyncio.Lock()
+ self.MAX_BUFFER_DURATION = 3.0
+
+ async def process_and_send(self, new_msg, websocket, duration=0.0, force_flush=False):
+ """
+ duration: 当前这段消息对应的音频时长(秒)
+ """
+ async with self.lock:
+ # === 1. 强制清空 (连接断开时) ===
+ if force_flush:
+ if self.buffer:
+ logger.info(f"🧹 [最终清空] 准备推送 {len(self.buffer)} 条缓存...")
+ for msg in self.buffer:
+ text_content = msg.get("full_text", "")
+ logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
+ await websocket.send(json.dumps(msg, ensure_ascii=False))
+ self.buffer = []
+ self.accumulated_duration = 0.0
+ return
+
+ # 获取当前消息的说话人
+ segments = new_msg.get("segments", [])
+ current_speaker = segments[0].get("spk") if segments else "unknown"
+
+ # === 2. 如果是同一个人(或者刚开始) ===
+ if self.last_speaker is None or current_speaker == self.last_speaker:
+ self.buffer.append(new_msg)
+ self.accumulated_duration += duration
+ self.last_speaker = current_speaker
+
+ # 🌟 检查是否超时 20s
+ if self.accumulated_duration >= self.MAX_BUFFER_DURATION:
+ logger.info(f"⏰ [超时推送] 同人 ({current_speaker}) 积攒 {self.accumulated_duration:.2f}s >= 8s")
+
+ for msg in self.buffer:
+ text_content = msg.get("full_text", "")
+ logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
+ await websocket.send(json.dumps(msg, ensure_ascii=False))
+
+ # 清空缓存,重置时长
+ self.buffer = []
+ self.accumulated_duration = 0.0
+ else:
+ # 仅缓存,不发送
+ text_preview = new_msg.get("full_text", "")
+ logger.info(f"📥 [缓存中] Spk:{current_speaker} | 文本:{text_preview} | 累计:{self.accumulated_duration:.2f}s")
+
+ # === 3. 如果换人了 ===
+ else:
+ logger.info(f"🔄 [换人推送] {self.last_speaker} -> {current_speaker}")
+
+ # A. 推送上一位说话人的所有积攒消息
+ for msg in self.buffer:
+ text_content = msg.get("full_text", "")
+ logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
+ await websocket.send(json.dumps(msg, ensure_ascii=False))
+
+ # B. 清空并开始缓存新人的消息
+ self.buffer = []
+ self.buffer.append(new_msg)
+ self.accumulated_duration = duration
+ self.last_speaker = current_speaker
+
+ # 打印新人入队日志
+ new_text = new_msg.get("full_text", "")
+ logger.info(f"📥 [新缓存] Spk:{current_speaker} | 文本:{new_text}")
+
+# ========================
+# 调用本地 ASR 接口(带说话人识别)
+# ========================
+async def call_local_asr_with_audio(wav_path: str, session: aiohttp.ClientSession):
+ try:
+ with open(wav_path, "rb") as f:
+ data = aiohttp.FormData()
+ data.add_field(
+ "audio_file",
+ f,
+ filename=os.path.basename(wav_path),
+ content_type="audio/wav"
+ )
+ params = {
+ "spk_diarization": "true",
+ "spk_threshold": "0.6",
+ "output": "json"
+ }
+ async with session.post(ASR_LOCAL_URL, data=data, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
+ if resp.status == 200:
+ return await resp.json()
+ else:
+ text = await resp.text()
+ logger.error(f"❌ 本地 ASR 接口错误: {resp.status} - {text}")
+ return None
+ except Exception as e:
+ logger.error(f"❌ 调用本地 ASR 异常: {e}")
+ return None
+
+
+# ========================
+# 后台处理:音频 → 本地 ASR → Session 缓存判断
+# ========================
+async def process_audio_with_local_asr(audio_bytes, asr_text, websocket, session, speaker_session):
+ if not audio_bytes:
+ return
+
+ wav_path = None
+ try:
+ # 1. 计算这段音频的时长,用于 Session 判断超时
+ audio_duration = len(audio_bytes) / BYTES_PER_SECOND
+
+ # 2. 保存为 WAV
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
+ wav_path = tmp.name
+
+ with wave.open(wav_path, 'wb') as wf:
+ wf.setnchannels(CHANNELS)
+ wf.setsampwidth(SAMPLE_WIDTH)
+ wf.setframerate(SAMPLE_RATE)
+ wf.writeframes(audio_bytes)
+
+ # 3. 调用本地 ASR 接口
+ asr_result = await call_local_asr_with_audio(wav_path, session)
+ if not asr_result or not asr_result.get("success"):
+ logger.error("❌ 本地 ASR (带声纹) 处理失败")
+ return
+
+ # 4. 构建消息
+ segments = asr_result.get("segments", [])
+ full_text = asr_result.get("text", "")
+
+ msg = {
+ "mode": "offline-speaker",
+ "full_text": full_text,
+ "segments": segments,
+ "original_asr_text": asr_text,
+ "type": "asr_with_speaker",
+ }
+
+ # 5. 🌟 传入 duration 参数
+ await speaker_session.process_and_send(msg, websocket, duration=audio_duration)
+
+ except websockets.exceptions.ConnectionClosed:
+ logger.warning("⚠️ WebSocket 连接已关闭")
+ except Exception as e:
+ logger.error(f"❌ 后台处理异常: {e}")
+ finally:
+ if wav_path and os.path.exists(wav_path):
+ os.unlink(wav_path)
+
+
+# ========================
+# 主 WebSocket 转发逻辑
+# ========================
+async def forward_audio(websocket):
+ logger.info("🟢 前端已连接。")
+
+ current_audio_chunks = []
+ local_accumulated_audio = bytearray()
+
+ speaker_session = SpeakerSession()
+
+ lock = asyncio.Lock()
+
+ async with aiohttp.ClientSession() as session:
+ try:
+ async with websockets.connect(TARGET_WS_URL) as target_ws:
+ logger.info(f"🔗 连接到远程 ASR: {TARGET_WS_URL}")
+ await target_ws.send(json.dumps(config))
+
+ # ---------------------------
+ # 任务1: 前端 -> 远程 ASR
+ # ---------------------------
+ async def frontend_to_target():
+ async for message in websocket:
+ if isinstance(message, bytes):
+ async with lock:
+ current_audio_chunks.append(message)
+ await target_ws.send(message)
+ else:
+ await target_ws.send(message)
+
+ # ---------------------------
+ # 任务2: 远程 ASR -> 前端
+ # ---------------------------
+ async def target_to_frontend():
+ async for message in target_ws:
+ # 实时推送
+ await websocket.send(message)
+
+ if isinstance(message, str):
+ try:
+ data = json.loads(message)
+ mode = data.get("mode")
+
+ if mode == "2pass-offline":
+ asr_text = data.get("text", "").strip()
+
+ async with lock:
+ chunk_bytes = b"".join(current_audio_chunks)
+ current_audio_chunks.clear()
+
+ local_accumulated_audio.extend(chunk_bytes)
+ current_duration = len(local_accumulated_audio) / BYTES_PER_SECOND
+
+ if current_duration < MIN_DURATION_THRESHOLD:
+ logger.info(f"⏳ 音频拼接中: {current_duration:.2f}s")
+ else:
+ logger.info(f"⚡ 触发本地声纹识别: {current_duration:.2f}s")
+
+ audio_to_send = bytes(local_accumulated_audio)
+ local_accumulated_audio.clear()
+
+ asyncio.create_task(
+ process_audio_with_local_asr(audio_to_send, asr_text, websocket, session, speaker_session)
+ )
+
+ elif mode == "2pass-online":
+ pass
+
+ except json.JSONDecodeError:
+ pass
+
+ await asyncio.gather(frontend_to_target(), target_to_frontend())
+
+ except websockets.exceptions.ConnectionClosedOK:
+ logger.info("🔗 远程 ASR 连接正常关闭。")
+ except Exception as e:
+ logger.error(f"❌ 连接异常: {e}")
+ finally:
+ logger.info("🔴 前端断开连接,清理资源...")
+
+ if len(local_accumulated_audio) > 0:
+ final_duration = len(local_accumulated_audio) / BYTES_PER_SECOND
+ logger.info(f"⚠️ 处理断开前的剩余音频: {final_duration:.2f}s")
+ try:
+ audio_to_send = bytes(local_accumulated_audio)
+ local_accumulated_audio.clear()
+ await process_audio_with_local_asr(audio_to_send, "Final-Flush", websocket, session, speaker_session)
+ except Exception as e:
+ logger.error(f"❌ 剩余音频处理异常: {e}")
+
+ # 强制推送最后残留缓存
+ try:
+ await speaker_session.process_and_send(None, websocket, force_flush=True)
+ except Exception as e:
+ logger.error(f"❌ 最终缓存推送失败: {e}")
+
+
+# ========================
+# 启动服务
+# ========================
+async def main():
+ logger.info(f"🚀 启动 WebSocket 中转服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
+ logger.info("📝 逻辑: 声纹识别缓存,同人累积满8s或换人时推送")
+ server = await websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT)
+ await server.wait_closed()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
\ No newline at end of file
diff --git a/fastapi_wss/src/test.py b/fastapi_wss/src/test.py
new file mode 100644
index 0000000..ceb537b
--- /dev/null
+++ b/fastapi_wss/src/test.py
@@ -0,0 +1,199 @@
+import asyncio
+import audioop
+from array import array
+import json
+import sys
+import time
+import wave
+import websockets
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+if hasattr(sys.stderr, "reconfigure"):
+ sys.stderr.reconfigure(encoding="utf-8")
+
+WS_URL = "ws://localhost:10095"
+TARGET_SAMPLE_RATE = 16000
+TARGET_SAMPLE_WIDTH = 2
+CHUNK_SECONDS = 0.06
+FINAL_RESULT_TIMEOUT = 60
+WAV_PATH = r"E:\ZKYNLP\gonwgendoc\fastapi_wss\fastapi_wss\src\多人会议90分钟.wav"
+
+
+def downmix_to_mono_pcm16(chunk: bytes, channels: int) -> bytes:
+ if channels == 1:
+ return chunk
+
+ samples = array("h")
+ samples.frombytes(chunk)
+ if sys.byteorder != "little":
+ samples.byteswap()
+
+ mono = array("h")
+ for index in range(0, len(samples), channels):
+ frame = samples[index:index + channels]
+ if len(frame) == channels:
+ mono.append(int(sum(frame) / channels))
+
+ if sys.byteorder != "little":
+ mono.byteswap()
+ return mono.tobytes()
+
+
+def validate_wav_for_streaming(wf: wave.Wave_read):
+ sample_rate = wf.getframerate()
+ sample_width = wf.getsampwidth()
+ channels = wf.getnchannels()
+
+ if sample_width != TARGET_SAMPLE_WIDTH:
+ raise ValueError(f"WAV sample width must be {TARGET_SAMPLE_WIDTH} bytes, got {sample_width} bytes")
+ if channels < 1:
+ raise ValueError(f"WAV channels must be >= 1, got {channels}")
+
+ if sample_rate != TARGET_SAMPLE_RATE:
+ print(f"Input WAV is {sample_rate}Hz; resampling to {TARGET_SAMPLE_RATE}Hz before sending.")
+ if channels > 1:
+ print(f"Input WAV has {channels} channels; downmixing to mono before sending.")
+ source_frames_per_chunk = max(1, int(sample_rate * CHUNK_SECONDS))
+ return sample_rate, channels, source_frames_per_chunk
+
+
+class Pcm16Mono16kConverter:
+ def __init__(self, source_rate: int, channels: int):
+ self.source_rate = source_rate
+ self.channels = channels
+ self.rate_state = None
+
+ def convert(self, chunk: bytes) -> bytes:
+ chunk = downmix_to_mono_pcm16(chunk, self.channels)
+ if self.source_rate != TARGET_SAMPLE_RATE:
+ chunk, self.rate_state = audioop.ratecv(
+ chunk,
+ TARGET_SAMPLE_WIDTH,
+ 1,
+ self.source_rate,
+ TARGET_SAMPLE_RATE,
+ self.rate_state,
+ )
+ return chunk
+
+def is_final_asr_message(data: dict) -> bool:
+ mode = data.get("mode")
+ if mode not in {"2pass-offline", "offline-speaker"}:
+ return False
+ return bool(data.get("is_final", True))
+
+
+async def recv_loop(ws, audio_sent_event: asyncio.Event, final_result_event: asyncio.Event, stats: dict):
+ async for msg in ws:
+ print("RECV:", msg)
+ if not isinstance(msg, str):
+ continue
+ try:
+ data = json.loads(msg)
+ except json.JSONDecodeError:
+ continue
+ if not is_final_asr_message(data):
+ continue
+
+ now = time.perf_counter()
+ stats["last_final_time"] = now
+ if audio_sent_event.is_set() and "first_final_after_send_time" not in stats:
+ stats["first_final_after_send_time"] = now
+ final_result_event.set()
+
+
+def print_timing_stats(stats: dict):
+ end_time = stats.get("first_final_after_send_time") or stats.get("end_time") or time.perf_counter()
+ total_elapsed = end_time - stats["start_time"]
+ send_elapsed = stats.get("send_end_time", end_time) - stats.get("send_start_time", stats["start_time"])
+ tail_elapsed = None
+ if "send_end_time" in stats and "first_final_after_send_time" in stats:
+ tail_elapsed = stats["first_final_after_send_time"] - stats["send_end_time"]
+
+ print("\n===== ASR Timing =====")
+ print(f"Audio duration: {stats.get('audio_duration_sec', 0.0):.2f}s")
+ print(f"Audio send time: {send_elapsed:.2f}s")
+ if tail_elapsed is not None:
+ print(f"Post-send ASR wait: {tail_elapsed:.2f}s")
+ else:
+ print("Post-send ASR wait: timeout/no final result")
+ print(f"Total elapsed: {total_elapsed:.2f}s")
+ if stats.get("audio_duration_sec"):
+ rtf = total_elapsed / stats["audio_duration_sec"]
+ print(f"RTF: {rtf:.3f}x")
+ print("======================\n")
+
+async def main():
+ stats = {"start_time": time.perf_counter()}
+ audio_sent_event = asyncio.Event()
+ final_result_event = asyncio.Event()
+
+ async with websockets.connect(
+ WS_URL,
+ max_size=None,
+ ping_interval=None,
+ ping_timeout=None,
+ ) as ws:
+ recv_task = asyncio.create_task(recv_loop(ws, audio_sent_event, final_result_event, stats))
+
+ init = {
+ "mode": "2pass",
+ "chunk_size": [10, 10, 10],
+ "chunk_interval": 10,
+ "wav_name": "test",
+ "is_speaking": True,
+ "wav_format": "pcm",
+ "itn": True
+ }
+ await ws.send(json.dumps(init, ensure_ascii=False))
+
+ connection_closed = False
+
+ with wave.open(WAV_PATH, "rb") as wf:
+ source_rate, channels, source_frames_per_chunk = validate_wav_for_streaming(wf)
+ stats["audio_duration_sec"] = wf.getnframes() / source_rate
+ converter = Pcm16Mono16kConverter(source_rate, channels)
+
+ stats["send_start_time"] = time.perf_counter()
+ while True:
+ chunk = wf.readframes(source_frames_per_chunk)
+ if not chunk:
+ break
+ chunk = converter.convert(chunk)
+ try:
+ await ws.send(chunk)
+ except websockets.exceptions.ConnectionClosed as e:
+ connection_closed = True
+ print(f"WebSocket closed while sending audio: {type(e).__name__}: {e}")
+ break
+ await asyncio.sleep(CHUNK_SECONDS)
+
+ if not connection_closed:
+ try:
+ await ws.send(json.dumps({"is_speaking": False}, ensure_ascii=False))
+ except websockets.exceptions.ConnectionClosed as e:
+ connection_closed = True
+ print(f"WebSocket closed before sending end marker: {type(e).__name__}: {e}")
+
+ stats["send_end_time"] = time.perf_counter()
+ audio_sent_event.set()
+
+ if connection_closed and not final_result_event.is_set():
+ print("Connection closed before final ASR result was received.")
+ stats["end_time"] = time.perf_counter()
+ print_timing_stats(stats)
+ else:
+ try:
+ await asyncio.wait_for(final_result_event.wait(), timeout=FINAL_RESULT_TIMEOUT)
+ except asyncio.TimeoutError:
+ print(f"Timed out waiting for final ASR result after {FINAL_RESULT_TIMEOUT}s.")
+ finally:
+ stats["end_time"] = time.perf_counter()
+ print_timing_stats(stats)
+
+ recv_task.cancel()
+ await asyncio.gather(recv_task, return_exceptions=True)
+
+asyncio.run(main())
+
diff --git a/fastapi_wss/test.json b/fastapi_wss/test.json
new file mode 100644
index 0000000..7807e61
--- /dev/null
+++ b/fastapi_wss/test.json
@@ -0,0 +1 @@
+[{"filename": "163-06A0015-B01001_发动机-维修手册.pdf", "info": "3.2 工作原理\n主发动机为低速二冲程柴油机。燃油经燃油供给单元加压后,由高压油泵按曲轴角度控制,通过喷油器喷入气缸内,与压缩空气混合燃烧。燃烧产生的高温高压推动活塞下行,经活塞杆、连杆带动曲轴旋转输出功率。废气经排气阀排出,扫气系统向气缸内补充新鲜空气,完成工作循环。\n船舶主发动机工作原理\n图3.2 船舶主发动机组成图\nimages/03350a052bf63656625384c43e3d7e9234ad2e870aae4fcd5ce258c78ddcb068.jpg\n图3.3 机座与机架组件整体图\nimages/5a7cfecb0102b017ce90ebd149e915d2bedb6d744171b9bc79c4e1f3cb6c85b6.jpg\nimages/9a172fd6b5d56f8f85269a10cb453b4364e0d77de3c6277f5a6c771bba984b1f.jpg\n图3.4 曲柄连杆机构工作原理图\nimages/6776d6a6216b182db92940f592f93847fb025558752279c0767e0451276171b7.jpg\n曲柄连杆机构的作用是提供燃烧场所,把燃料燃烧后产生的气体作用在活塞顶上的膨胀压力转变为曲轴旋转的转矩,不断输出动力。将气体的压力变为曲轴的转矩,将活塞的往复运动变为曲轴的旋转运动,把燃烧作用在活塞顶上的力转变为曲轴的转矩,以向工作机械输出机械能。\n图3.5 燃烧室部件工作原理图\nimages/588567cbbfea9581d8b03774818a41df387876e56c9af7c047ba8ef981b5b8f6.jpg\n图3.5是燃烧室部件的工作原理图,燃烧过程组成部分主要有:初始段、燃烧段、稀释段。在初始段,燃料喷射到回流区,混合比接近化学当量比,要求确保火焰稳定。在燃烧段,加入二次空气,促进燃料完全燃烧。在稀释段,加入空气与废气混合,降低温度,使燃烧室排气管温度分布,与汽轮机、喷口导向叶片相适应。\n图3.6 排气阀驱动系统工作原理图\nimages/25701a7504af7cc89949f025ee79b0e9529b68430ac483e18b091359fcb3670c.jpg\n船舶柴油机排气阀驱动系统原理主要是利用 机械连杆、凸轮轴和液压/气动装置,结合发动机的转速和气缸压力信号,通过精确的凸轮机构控制排气阀的开启时机与开启度,将燃烧后的废气顺畅排出,以实现高效换气,其核心是カム(凸轮)与挺杆/摇臂的配合动作,实现气门与气缸盖的精确密封与打开。\n核心原理与步骤\n动力源:曲轴通过正时齿轮(或链条)带动凸轮轴旋转,提供驱动动力。\n凸轮驱动:凸轮轴上特定的凸轮轮廓,在旋转时顶起与其配合的挺杆或摇臂。\n传递运动:挺杆/摇臂将凸轮的旋转运动转化为直线往复运动,传递给排气阀。\n阀门启闭:排气阀在弹簧(或液压)的弹力作用下,由凸轮顶开,并在凸轮离开后关闭,完成排气过程。\n精确控制:凸轮的形状和位置是经过精心设计,确保排气阀在活塞到达排气行程末端时准时开启,并在进入压缩行程前完全关闭,实现高效换气。\n图3.7 燃油喷射系统工作原理图\nimages/97308268ed00a2f07959a722b1e1307338839c5b29b67ed9cc9568898cce0a38.jpg\n如图3.7中船舶柴油机燃油喷射系统原理,核心在于高压将柴油雾化喷入高温高压的压燃气缸中,利用压强和温度自燃,无需火花塞点火,主要包括供油、增压、喷射、点火燃烧几个环节,系统通常由低压供油(油箱->低压油泵->滤清器),高压输送(高压油泵->油轨/柱塞),精密喷射(喷油器->气缸),以及电子控制单元(ECU)组成,实现精确控制燃油量、喷油正时和压力,满足不同工况下的动力和排放要求。\n图3.8 扫气与增压系统工作原理图\nimages/cc61f2a0733c2394c6133cc142a1bdcf0971a58fb55268ea8d6bad0af1c94c1a.jpg\n船舶柴油机扫气与增压系统原理核心在于废气涡轮增压和扫气过程配合,利用发动机排出的高温高压废气驱动涡轮,带动同轴压气机将新鲜空气高压送入气缸,增加空气密度,允许喷入更多燃油,从而大幅提高功率,而扫气过程则负责用压缩空气将气缸内的余热废气排出,实现进排气的高效混合与置换。\n增压系统原理(废气涡轮增压)能量回收:柴油机排出的约 700-900°C的高温废气进入增压器的涡轮端,驱动涡轮高速旋转。空气压缩:涡轮通过主轴带动同轴的压气机(离心式)高速旋转,将新鲜空气吸入并压缩,压力升高(2-3 个大气压)。进气增密:增压后的高密度、高压力空气被送入气缸,填满气缸,为喷入更多燃油创造条件。功率提升:更多的燃油在更多空气中燃烧,产生更大的热能和压力,有效提高柴油机功率。\n扫气系统原理(二冲程柴油机常见)扫气方式:在二冲程柴油机中,活塞下行至排气口打开后,气缸内的废气依靠自身压力排出,同时扫气箱(或增压箱)中的新鲜空气通过扫气口进入气缸,将剩余废气“扫”出。扫气作用:将气缸内的废气排出,为下一次进气做准备。利用扫气空气的压力,提高气缸内的初始空气密度,提升换气效率。增压器与扫气结合:增压器压气机提供的增压空气,直接送入扫气箱,再由扫气口进入气缸,实现增压与扫气的联动,形成高效的进排气过程。", "url": "http://192.168.0.46:59085/upload/163-06A0015-B01001_发动机-维修手册_c921f63a-f025-4d68-bb0a-47aac01dbc01.pdf#page=10"}, {"filename": "163-06A0015-B01001_发动机-操作使用手册.pdf", "info": "3.2.工作原理\n主要工作原理:通过将雾化的燃油喷入高温高压的空气并使其压燃,将燃烧产生的热能转化为活塞的往复机械能,再通过曲柄连杆机构转化为驱动螺旋桨旋转的扭矩。\n图 3.20 发动机工作原理\nimages/2cca76de0343ea25f56fc4cee61dcf2b9f0d75def4ce0e29c4d39578ad27f926.jpg\n其核心是一个 “进气-压缩-燃烧-膨胀-排气” 的能量转换循环。由于是二冲程,这个循环在活塞的两个冲程(即曲轴旋转一圈)内完成。\n一、核心工作原理\n图 3.21 发动机核心工作原理\nimages/785a6c0bbb4e89e506d9edff646902abb1999c36a7df1b8a7bbbc359c2033d64.jpg\n二、详细工作循环\n图 3.22 发动机详细工原理\nimages/f3a5c01471040ad6b52893deaa170e31cba30645415675b128ff76e118848328.jpg\n(1)扫气过程:在二冲程机中,进气和排气是重叠进行的(称为“扫气”)。新鲜空气由涡轮增压器提供压力(“扫气压力”),像扫帚一样冲入气缸,既供应了氧气,又将残余废气“扫”出。\n(2)压缩与点火:活塞上行压缩空气,使其温度和压力升高到燃油可自燃的程度(约500-700°C)。此时喷入的燃油无需火花塞,即可压燃。\n(3)做功:燃烧瞬间产生高达200巴的压力,推动活塞下行,这是唯一产生动力的冲程。\n(4)涡轮增压器的关键作用:排出的废气驱动涡轮增压器的涡轮,涡轮带动同轴的压气机,为下一个循环提供高压新鲜空气。这是柴油机高效节能的核心。\n三、不同类型主机的工作原理对比\n表 3.6 工作原理对比\n发动机类型 冲程数 点火方式 船舶应用特点 低速二冲程柴油机 2 压燃 主流选择,用于大型商船,直接驱动螺旋桨,烧重油。 中/高速四冲程柴油机 4 压燃 用于发电(副机)、滚装船、渡轮等,或作为电力推进系统的发电机原动机。 燃气轮机 持续燃烧 用于军舰、LNG 船(利用蒸发气)、高速渡轮,功率大、启动快,但油耗高。 蒸汽轮机 外部燃烧(锅炉) 主要用于液化天然气运输船(利用货舱蒸发气)和早期船舶,现较少用于主推进。 电力推进电机 电磁驱动 “主机”变为电动机,能量来源是柴油发电机组、燃料电池或电池。用于科考船、邮轮、工程船等,布局灵活,操控性好。", "url": "http://192.168.0.46:59085/upload/163-06A0015-B01001_发动机-操作使用手册_5509e447-a4a2-4f51-b7d4-e1faec9d92f7.pdf#page=26"}]
diff --git a/speakr/.dockerignore b/speakr/.dockerignore
new file mode 100644
index 0000000..b072677
--- /dev/null
+++ b/speakr/.dockerignore
@@ -0,0 +1,24 @@
+.git
+.codex/
+.agents/
+.env
+.env.*
+__pycache__/
+*.py[cod]
+*.pyo
+*.swp
+*.swo
+.pytest_cache/
+.coverage
+htmlcov/
+logs/
+node_modules/
+templates/index/node_modules/
+static/vite/
+instance/
+uploads/
+*.db
+*.log
+.DS_Store
+.idea/
+.vscode/
\ No newline at end of file
diff --git a/speakr/.gitattributes b/speakr/.gitattributes
new file mode 100644
index 0000000..f4f8a49
--- /dev/null
+++ b/speakr/.gitattributes
@@ -0,0 +1,3 @@
+# 保证 shell 脚本在任何平台检出时都是 LF,否则 Docker 镜像内脚本无法执行
+*.sh text eol=lf
+docker-entrypoint.sh text eol=lf
diff --git a/speakr/.gitignore b/speakr/.gitignore
new file mode 100644
index 0000000..3b8b537
--- /dev/null
+++ b/speakr/.gitignore
@@ -0,0 +1,65 @@
+# Python 字节码缓存
+__pycache__/
+*.py[cod]
+*$py.class
+*.pyo
+
+# 虚拟环境
+.venv/
+venv/
+env/
+ENV/
+
+# 环境变量(含敏感信息,禁止提交)
+.env
+.env.local
+.env.*.local
+
+# 日志文件
+logs/
+*.log
+
+# 编辑器 / IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+.trae/
+.codex/
+
+# 测试 / 覆盖率
+.pytest_cache/
+.coverage
+htmlcov/
+templates/index/node_modules/
+
+# 构建产物
+dist/
+build/
+*.egg-info/
+static/vite/
+skills/
+## mypy
+.mypy_cache/
+
+# data
+
+data/
+static/vite/
+instance/transcriptions.db
+ai-guide/
+音频web转文字前端示例/
+
+# 本地测试脚本
+tests/
+
+#本地调试文件
+*.backup
+
+# 本地数据库文件
+*.db
+instance/transcriptions.db
+node_modules/
+instance/transcriptions.db
+instance/transcriptions.db
diff --git a/speakr/.gitlab-ci.yml b/speakr/.gitlab-ci.yml
new file mode 100644
index 0000000..da94ecb
--- /dev/null
+++ b/speakr/.gitlab-ci.yml
@@ -0,0 +1,137 @@
+# GitLab CI/CD 配置 for speakr 项目
+# 自动构建并部署到 Kubernetes
+
+stages:
+ - build
+ - deploy
+
+variables:
+ # 使用阿里云镜像仓库
+ IMAGE_NAME: crpi-99azmmphmxwdoi76.cn-guangzhou.personal.cr.aliyuncs.com/sea_lee/speakr
+ IMAGE_TAG: $CI_COMMIT_SHORT_SHA
+
+# ============================================
+# 阶段 1: 构建 Docker 镜像并推送
+# ============================================
+build:
+ stage: build
+ image: docker:latest
+ services:
+ - docker:dind
+ before_script:
+ # 登录阿里云镜像仓库
+ - echo "$ALIYUN_REGISTRY_PASSWORD" | docker login crpi-99azmmphmxwdoi76.cn-guangzhou.personal.cr.aliyuncs.com -u "$ALIYUN_REGISTRY_USER" --password-stdin
+ script:
+ - echo "=========================================="
+ - echo "🚀 开始构建 speakr 镜像"
+ - echo "=========================================="
+ - echo "Git 提交: $CI_COMMIT_SHORT_SHA"
+ - echo "分支: $CI_COMMIT_BRANCH"
+ - echo "提交信息: $CI_COMMIT_TITLE"
+ - echo "提交者: $GITLAB_USER_NAME"
+ - echo ""
+
+ # 显示 Dockerfile 内容
+ - echo "Dockerfile:"
+ - cat Dockerfile
+ - echo ""
+
+ # 构建 Docker 镜像 (使用现有的 Dockerfile)
+ - echo "📦 构建 Docker 镜像..."
+ - docker build -t $IMAGE_NAME:$IMAGE_TAG .
+ - echo "✓ 镜像构建完成"
+ - docker images | grep speakr
+
+ # 打标签为 latest
+ - docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
+
+ # 推送到阿里云镜像仓库
+ - echo ""
+ - echo "📤 推送镜像到阿里云..."
+ - docker push $IMAGE_NAME:$IMAGE_TAG
+ - docker push $IMAGE_NAME:latest
+ - echo "✓ 镜像推送完成"
+
+ - echo ""
+ - echo "=========================================="
+ - echo "✅ 构建阶段完成!"
+ - echo "镜像: $IMAGE_NAME:$IMAGE_TAG"
+ - echo "=========================================="
+ only:
+ - main
+ - master
+ - develop
+
+# ============================================
+# 阶段 2: 部署到 Kubernetes
+# ============================================
+deploy:
+ stage: deploy
+ image: bitnami/kubectl:latest
+ environment:
+ name: production
+ url: http://172.18.127.124
+ before_script:
+ # 配置 kubectl
+ - echo "🔧 配置 kubectl..."
+ - echo "$KUBECONFIG_CONTENT" | base64 -d > /tmp/kubeconfig
+ - export KUBECONFIG=/tmp/kubeconfig
+ - chmod 600 /tmp/kubeconfig
+
+ # 验证 Kubernetes 连接
+ - echo ""
+ - echo "验证 Kubernetes 连接..."
+ - kubectl config current-context
+ - kubectl version --client
+ - kubectl get nodes
+ script:
+ - echo "=========================================="
+ - echo "🚀 部署 speakr 到 Kubernetes"
+ - echo "=========================================="
+ - echo "命名空间: default"
+ - echo "Deployment: speakr"
+ - echo "镜像: $IMAGE_NAME:$IMAGE_TAG"
+ - echo ""
+
+ # 检查 deployment 是否存在
+ - |
+ if kubectl get deployment speakr &>/dev/null; then
+ echo "✓ Deployment 已存在,更新镜像..."
+ kubectl set image deployment/speakr speakr=$IMAGE_NAME:$IMAGE_TAG
+ else
+ echo "✗ Deployment 不存在,创建新的..."
+ kubectl create deployment speakr --image=$IMAGE_NAME:$IMAGE_TAG --replicas=2
+ fi
+
+ # 等待 Deployment rollout 完成
+ - echo ""
+ - echo "⏳ 等待部署完成..."
+ - kubectl rollout status deployment/speakr --timeout=10m
+
+ # 确保服务存在
+ - |
+ if ! kubectl get service speakr &>/dev/null; then
+ echo "创建 Service..."
+ kubectl expose deployment speakr --port=8899 --target-port=8899 --type=NodePort
+ fi
+
+ # 显示最终状态
+ - echo ""
+ - echo "=========================================="
+ - echo "✅ 部署完成!"
+ - echo "=========================================="
+ - kubectl get pods -l app=speakr
+ - echo ""
+ - kubectl get service speakr
+ - echo ""
+
+ # 获取访问地址
+ - |
+ NODEPORT=$(kubectl get service speakr -o jsonpath='{.spec.ports[0].nodePort}')
+ echo "🌐 访问地址:"
+ echo " NodePort: http://172.18.127.124:$NODEPORT"
+ echo " 直接访问: http://172.18.127.124:8899"
+ echo ""
+ only:
+ - main
+ - master
diff --git a/speakr/Dockerfile b/speakr/Dockerfile
new file mode 100644
index 0000000..25f24fa
--- /dev/null
+++ b/speakr/Dockerfile
@@ -0,0 +1,62 @@
+FROM node:20-slim AS frontend-builder
+
+WORKDIR /build/templates/index
+
+# 安装前端依赖,并在镜像构建阶段生成 Vite 产物。
+COPY templates/index/package.json templates/index/pnpm-lock.yaml ./
+RUN corepack enable && \
+ corepack prepare pnpm@9.15.9 --activate && \
+ pnpm install --frozen-lockfile
+
+COPY templates/index/ ./
+
+RUN rm -f pnpm-workspace.yaml && pnpm build
+
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# 安装运行时系统依赖。
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+# 先安装 Python 依赖,提高 Docker 层缓存命中率。
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# 先准备 vendor 目录,便于离线依赖下载脚本写入文件。
+RUN mkdir -p /app/static/vendor
+
+# 只复制下载脚本,避免业务代码变化导致 vendor 依赖重复下载。
+COPY scripts/download_offline_deps.py scripts/
+RUN pip install --no-cache-dir requests && \
+ python scripts/download_offline_deps.py
+
+# 复制应用代码。
+COPY . .
+
+# 使用前端构建阶段生成的产物覆盖源码中的旧产物。
+COPY --from=frontend-builder /build/static/vite/index /app/static/vite/index
+
+# 创建容器运行时数据目录。
+RUN mkdir -p /data/uploads /data/instance && \
+ chmod 755 /data/uploads /data/instance
+
+# 设置默认运行环境变量。
+ENV FLASK_APP=src/app.py
+ENV SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
+ENV UPLOAD_FOLDER=/data/uploads
+ENV PYTHONPATH=/app
+
+# 安装容器入口脚本。
+COPY scripts/docker-entrypoint.sh /usr/local/bin/
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+# 暴露 Flask/Gunicorn 服务端口。
+EXPOSE 8899
+
+# 设置入口脚本和默认启动命令。
+ENTRYPOINT ["docker-entrypoint.sh"]
+CMD ["gunicorn", "--workers", "3", "--threads", "8", "--bind", "0.0.0.0:8899", "--timeout", "600", "src.app:app"]
diff --git a/speakr/Dockerfile20260710 b/speakr/Dockerfile20260710
new file mode 100644
index 0000000..5e0f7ae
--- /dev/null
+++ b/speakr/Dockerfile20260710
@@ -0,0 +1,61 @@
+FROM node:20-slim AS frontend-builder
+
+WORKDIR /build/templates/index
+
+# 安装前端依赖,并在镜像构建阶段生成 Vite 产物。
+COPY templates/index/package.json templates/index/pnpm-lock.yaml templates/index/pnpm-workspace.yaml ./
+RUN corepack enable && \
+ corepack prepare pnpm@9.15.9 --activate && \
+ pnpm install --frozen-lockfile
+
+COPY templates/index/ ./
+RUN pnpm build
+
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# 安装运行时系统依赖。
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+# 先安装 Python 依赖,提高 Docker 层缓存命中率。
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# 先准备 vendor 目录,便于离线依赖下载脚本写入文件。
+RUN mkdir -p /app/static/vendor
+
+# 只复制下载脚本,避免业务代码变化导致 vendor 依赖重复下载。
+COPY scripts/download_offline_deps.py scripts/
+RUN pip install --no-cache-dir requests && \
+ python scripts/download_offline_deps.py
+
+# 复制应用代码。
+COPY . .
+
+# 使用前端构建阶段生成的产物覆盖源码中的旧产物。
+COPY --from=frontend-builder /build/static/vite/index /app/static/vite/index
+
+# 创建容器运行时数据目录。
+RUN mkdir -p /data/uploads /data/instance && \
+ chmod 755 /data/uploads /data/instance
+
+# 设置默认运行环境变量。
+ENV FLASK_APP=src/app.py
+ENV SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
+ENV UPLOAD_FOLDER=/data/uploads
+ENV PYTHONPATH=/app
+
+# 安装容器入口脚本。
+COPY scripts/docker-entrypoint.sh /usr/local/bin/
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+# 暴露 Flask/Gunicorn 服务端口。
+EXPOSE 8899
+
+# 设置入口脚本和默认启动命令。
+ENTRYPOINT ["docker-entrypoint.sh"]
+CMD ["gunicorn", "--workers", "3", "--threads", "8", "--bind", "0.0.0.0:8899", "--timeout", "600", "src.app:app"]
diff --git a/speakr/LICENSE b/speakr/LICENSE
new file mode 100644
index 0000000..ada1a81
--- /dev/null
+++ b/speakr/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/speakr/README.md b/speakr/README.md
new file mode 100644
index 0000000..01db3bd
--- /dev/null
+++ b/speakr/README.md
@@ -0,0 +1,513 @@
+# Speakr - 定制版
+
+> 基于 [murtaza-nasir/speakr](https://github.com/murtaza-nasir/speakr) 二次开发的语音转录与智能笔记平台。
+
+## 项目简介
+
+本项目是一个自托管的 AI 语音转录和智能笔记平台,支持:
+
+- **语音录制与上传** - 浏览器直接录音或上传已有音频文件
+- **AI 转录** - 高准确率语音转文字,支持说话人识别
+- **交互式对话** - 对录音提问,获取 AI 回答
+- **智能标签** - 支持带自定义 AI 提示词的标签
+- **安全分享** - 生成安全链接分享录音
+- **草稿功能** - 本地草稿保存与上传
+- **声纹管理** - 自定义说话人声纹识别
+- **标准词映射** - 自动纠正专业术语和人名
+
+---
+
+## 项目结构
+
+```
+speakr/
+├── app.py # 入口文件(启动脚本)
+├── src/
+│ ├── app.py # Flask 应用主逻辑(create_app 工厂模式)
+│ ├── config.py # 统一配置文件(所有环境变量集中管理)
+│ ├── logging_config.py # 日志配置模块(格式化、输出目标、日志级别)
+│ ├── flask_ext.py # Flask 扩展实例集合(LoginManager、Bcrypt、Limiter、CSRFProtect)
+│ ├── clients/ # 外部服务客户端层
+│ │ ├── __init__.py # 统一导出所有客户端和配置
+│ │ ├── database.py # 数据库客户端:db 实例 + 配置 + 初始化
+│ │ ├── llm_client.py # LLM 客户端:OpenAI 客户端初始化
+│ │ └── voiceprint_client.py # 声纹客户端:VoiceprintClient
+│ ├── api/ # API 路由层(Flask 蓝图)
+│ │ ├── __init__.py # 统一蓝图注册函数 register_blueprints()
+│ │ ├── template_filters.py # 模板过滤器注册(now、localdatetime)
+│ │ ├── auth.py # 认证路由(登录、注册、账户管理)
+│ │ ├── recording.py # 录音管理(CRUD、上传、转录、下载)
+│ │ ├── share.py # 分享功能
+│ │ ├── tag.py # 标签管理
+│ │ ├── speaker.py # 说话人管理
+│ │ ├── admin.py # 管理后台
+│ │ ├── search.py # 搜索和 Inquire
+│ │ ├── voiceprint.py # 声纹管理
+│ │ ├── config.py # 系统配置管理
+│ │ ├── hotword.py # 热词管理
+│ │ ├── template.py # 转录模板
+│ │ ├── event.py # 事件管理
+│ │ └── main.py # 主页和杂项
+│ ├── models/ # 数据模型层(每个模型独立文件)
+│ │ ├── __init__.py # 统一导出所有模型
+│ │ ├── user.py # 用户模型
+│ │ ├── speaker.py # 说话人模型
+│ │ ├── recording.py # 录音模型
+│ │ ├── tag.py # 标签模型
+│ │ ├── recording_tag.py # 录音-标签关联模型
+│ │ ├── event.py # 事件模型
+│ │ ├── share.py # 分享模型
+│ │ ├── transcript_chunk.py # 转录块模型
+│ │ ├── transcript_template.py # 转录模板模型
+│ │ ├── inquire_session.py # 询问会话模型
+│ │ ├── voiceprint.py # 声纹模型
+│ │ ├── system_setting.py # 系统设置模型
+│ │ ├── system_config.py # 系统配置模型
+│ │ ├── hotword.py # 热词模型
+│ │ ├── draft_recording.py # 草稿录音模型
+│ │ ├── draft_segment.py # 草稿片段模型
+│ │ └── forms.py # 表单类(注册、登录)
+│ ├── services/ # 业务逻辑层
+│ │ ├── __init__.py # 统一导出所有服务函数
+│ │ ├── llm_service.py # LLM 服务
+│ │ ├── transcription_service.py # 转录服务
+│ │ ├── summary_service.py # 摘要服务
+│ │ ├── embedding_service.py # 嵌入向量服务
+│ │ ├── event_service.py # 事件服务
+│ │ ├── document_service.py # 文档服务
+│ │ ├── speaker_service.py # 说话人服务
+│ │ ├── voiceprint_service.py # 声纹服务(业务逻辑)
+│ │ ├── auto_process_service.py # 自动处理服务
+│ │ ├── auth_service.py # 认证服务
+│ │ ├── sync_service.py # 远程数据同步服务
+│ │ ├── audio_chunking.py # 音频分块服务
+│ │ └── file_monitor.py # 文件监控服务
+│ ├── utils/ # 工具函数层
+│ │ ├── __init__.py
+│ │ ├── markdown.py # Markdown 转 HTML 工具
+│ │ ├── datetime.py # 时区转换工具
+│ │ ├── text_utils.py # 文本处理工具
+│ │ ├── json_utils.py # JSON 处理工具
+│ │ └── standard_word_mappings.py # 标准词映射工具
+├── templates/ # Jinja2 模板
+├── static/ # 静态资源(CSS/JS/图片)
+├── instance/ # SQLite 数据库文件
+├── uploads/ # 上传的音频文件
+└── .env # 环境变量配置
+```
+
+---
+
+## 启动方式
+
+### 前置条件
+
+- Python 环境(已安装 conda 环境 `speakr`)
+- Nginx 反向代理(必须启动)
+
+### 启动步骤
+
+```bash
+# 1. 进入项目根目录(speakr 2 目录)
+cd "c:\Work\zdht\python-projects\speakr 2"
+
+# 2. 启动 Nginx 反向代理(必须先启动)
+# Nginx 负责将 https://localhost:8083/tool/speakr/ 请求转发到 Flask 后端
+# ⚠️ 注意:不启动 Nginx 将无法访问应用
+nginx # 或根据你的安装方式启动 nginx
+
+# 3. 激活 conda 环境并启动应用
+conda activate speakr
+python .\speakr\app.py
+```
+
+Flask 后端默认监听 **5000 端口**,Nginx 负责将 `https://localhost:8083/tool/speakr/` 请求转发到 `http://localhost:5000`。
+
+### 访问地址
+
+- 主页面:`https://localhost:8083/tool/speakr/`
+- 管理后台:`https://localhost:8083/tool/speakr/admin`
+- 账户设置:`https://localhost:8083/tool/speakr/account`
+
+### 常见错误
+
+| 问题 | 原因 | 解决 |
+|------|------|------|
+| 页面无法访问 | Nginx 未启动 | 启动 Nginx 反向代理 |
+| `ModuleNotFoundError: No module named 'xxx'` | conda 环境未激活 | 执行 `conda activate speakr` |
+| 数据库文件位置错误 | 从错误目录启动 | 确保从 `speakr 2/` 目录执行 `python .\speakr\app.py` |
+
+---
+
+## 环境配置
+
+编辑 `.env` 文件,主要配置项:
+
+```ini
+# ASR 服务配置
+USE_ASR_ENDPOINT=true
+ASR_BASE_URL=http://10.100.3.22:6688
+ASR_DIARIZE=true
+
+# 文本生成模型配置
+TEXT_MODEL_BASE_URL=http://10.100.3.22:9800/v1
+TEXT_MODEL_API_KEY=EMPTY
+TEXT_MODEL_NAME=minimax-m2.5
+
+# 数据库配置
+SQLALCHEMY_DATABASE_URI=sqlite:///transcriptions.db
+# PostgreSQL 配置示例(切换时只需修改 URI 即可)
+# SQLALCHEMY_DATABASE_URI=postgresql://user:password@localhost/speakr
+# DB_POOL_SIZE=10
+# DB_MAX_OVERFLOW=20
+# DB_POOL_TIMEOUT=30
+# DB_POOL_RECYCLE=3600
+
+# 外部访问端口(Nginx 监听端口)
+EXTERNAL_PORT=8083
+
+# 时区配置
+TIMEZONE=Asia/Shanghai
+```
+
+---
+
+## 重构计划与当前进度
+
+### 拆分原则
+
+1. ⭐ 标记的模块是业务特色,拆分时只改代码组织结构,不改业务逻辑
+2. 拆分顺序:
+ - **第一步**:抽出 Models(最安全,纯数据定义)
+ - **第二步**:抽出 Services(把 app.py 中的业务逻辑函数提取到独立文件)
+ - **第三步**:抽出 API 蓝图(把路由按功能域分组注册)
+ - **第四步**:抽出 Utils(工具函数独立化)
+ - **第五步**:模板组件化(可选,优先级较低)
+3. `app.py` 最终只保留:Flask 初始化、数据库初始化、蓝图注册、全局错误处理
+4. 保持向后兼容:拆分过程中所有 API 路径不变,前端无需改动
+
+### 需要特别注意的业务逻辑
+
+| 功能 | 说明 | 拆分注意事项 |
+|------|------|-------------|
+| ⭐ 声纹管理 | VoiceprintClient 类、声纹注册/比对/同步 | 保持 VoiceprintClient 与外部平台的交互逻辑不变 |
+| ⭐ 系统配置 | SystemConfig 动态配置读写 | 保持配置缓存和热加载机制 |
+| ⭐ 草稿 API | 已有独立蓝图 draft_bp | 直接迁移,几乎不需改动 |
+| ⭐ 标准词映射 | 转写后的文本纠正 | 保持映射规则和应用逻辑 |
+| ⭐ 自动处理 | 定时/触发式自动转写 | 保持调度逻辑和状态管理 |
+| ⭐ 收件箱 | 录音的收件箱/高亮状态 | 保持查询和状态切换逻辑 |
+| ⭐ 热词 | 热词管理和应用 | 保持与转写服务的集成 |
+
+### 当前进度
+
+#### ✅ 已完成:Phase 1 - Models 层抽取
+
+已将 `src/app.py` 中的 **16 个数据模型** 和 **2 个表单类** 从主文件中抽取到独立模块:
+
+| 状态 | 任务 | 说明 |
+|------|------|------|
+| ✅ | `src/database.py`(已移至 clients) | 创建独立数据库实例文件,集中管理 SQLAlchemy db 对象(Phase 2.5 移至 clients/database.py) |
+| ✅ | `src/models/` | 每个模型独立文件(user.py, speaker.py, recording.py 等 16 个模型) |
+| ✅ | `src/models/forms.py` | 表单类独立(RegistrationForm, LoginForm, password_check) |
+| ✅ | `src/utils/markdown.py` | Markdown 转 HTML 工具函数 |
+| ✅ | `src/utils/datetime.py` | 时区转换工具函数 |
+| ✅ | `src/app.py` | 删除模型定义,改为从 `src.models` 导入 |
+
+**验证结果**:应用可正常启动,数据库文件路径正确(`speakr/instance/transcriptions.db`)
+
+#### ✅ 已完成:Phase 2 - Services 层抽取
+
+**服务文件已全部创建**(`src/services/` 目录下 11 个服务文件),app.py 中的旧函数定义已全部删除。
+
+| 状态 | 服务文件 | 包含函数 |
+|------|---------|---------|
+| ✅ | `llm_service.py` | `call_llm_completion`, `clean_llm_response`, `format_transcription_for_llm`, `process_streaming_with_thinking`, `extract_thinking_content`, `preprocess_long_transcription`, `extract_corrected_text`, `format_api_error_message` |
+| ✅ | `transcription_service.py` | `transcribe_audio_asr`, `transcribe_audio_task`, `transcribe_single_file`, `transcribe_with_chunking`, `extract_audio_from_video`, `convert_to_wav` |
+| ✅ | `summary_service.py` | `generate_title_task`, `generate_summary_only_task` |
+| ✅ | `embedding_service.py` | `get_embedding_model`, `chunk_transcription`, `generate_embeddings`, `serialize_embedding`, `deserialize_embedding`, `process_recording_chunks`, `basic_text_search_chunks`, `semantic_search_chunks` |
+| ✅ | `event_service.py` | `extract_events_from_transcript`, `generate_ics_content`, `escape_ical_text` |
+| ✅ | `document_service.py` | `process_markdown_to_docx`, `get_recording_display_title`, `sanitize_download_filename_component`, `build_docx_download_filename`, `set_download_filename_header` |
+| ✅ | `speaker_service.py` | `update_speaker_usage`, `identify_speakers_from_text`, `identify_unidentified_speakers_from_text` |
+| ✅ | `voiceprint_service.py` | `sync_voiceprint_to_platform`(VoiceprintClient 已移至 clients 层) |
+| ✅ | `auto_process_service.py` | `initialize_file_monitor`, `get_file_monitor_functions` |
+| ✅ | `auth_service.py` | `is_safe_url`, `auto_login` |
+| ✅ | `__init__.py` | 统一导出所有服务函数 |
+
+**已完成的工作:**
+
+1. ✅ 删除了 app.py 中的 22 个重复函数定义,改为从服务层导入
+2. ✅ 创建了 `src/utils/text_utils.py` 和 `src/utils/json_utils.py`,将工具函数移出 app.py
+3. ✅ 添加了服务层和工具层的导入
+4. ✅ 核心功能测试通过,应用运行正常
+
+#### ✅ 已完成:Phase 2.5 - Clients 层创建
+
+按照行业规范创建了 `src/clients/` 层,统一管理与外部服务的连接客户端:
+
+| 状态 | 客户端 | 说明 |
+|------|--------|------|
+| ✅ | `database.py` | 数据库客户端:`db` 实例 + 配置 + `init_database()` 初始化逻辑 |
+| ✅ | `llm_client.py` | LLM 客户端:OpenAI 客户端初始化(client, TEXT_MODEL_NAME, TEXT_MODEL_API_KEY 等) |
+| ✅ | `voiceprint_client.py` | 声纹客户端:VoiceprintClient 类 + voiceprint_client() 延迟初始化 |
+| ✅ | `__init__.py` | 统一导出所有客户端 |
+
+**重构成果:**
+
+- 删除了旧 `src/database.py`(空实例文件),合并到 `clients/database.py`
+- 删除了 `services/voiceprint_service.py` 中的 `VoiceprintClient` 类,移到 `clients/voiceprint_client.py`
+- 17 个模型文件 + 6 个服务文件的 `db` 导入统一改为 `from src.clients import db`
+- 消除了层层传递 `client` 参数的问题
+- 外部服务客户端与业务逻辑分离,架构更清晰
+
+#### ✅ 已完成:Phase 2.6 - 服务层注释中文化
+
+将所有服务层文件中的英文注释和日志消息翻译为中文,提升代码可读性:
+
+| 文件 | 翻译内容 |
+|------|---------|
+| `clients/llm_client.py` | URL 注释清理逻辑、客户端初始化逻辑 |
+| `clients/voiceprint_client.py` | 声纹客户端注释和日志(新建文件,全中文) |
+| `clients/database.py` | 数据库配置和初始化注释(新建文件,全中文) |
+| `services/llm_service.py` | 思考内容提取、流式处理、API 错误格式化、分段总结等函数的注释和日志 |
+| `services/summary_service.py` | 摘要生成全流程的注释和日志(标签提示词、语言要求、系统/用户消息构建等) |
+| `services/transcription_service.py` | 音频提取、ASR 转录、分块转录、Whisper 调用等全流程注释和日志 |
+| `services/embedding_service.py` | 文本分块、嵌入向量生成、语义搜索等全流程注释和日志 |
+| `services/voiceprint_service.py` | 声纹同步业务逻辑注释 |
+
+#### ✅ 已完成:Phase 3 - API 蓝图抽取
+
+将 `src/app.py` 中的约 100 个路由定义按功能域分组抽取到独立的 Flask 蓝图中,app.py 从约 10000 行精简到约 700 行:
+
+| 状态 | 蓝图文件 | 路由数量 | 说明 |
+|------|---------|---------|------|
+| ✅ | `api/auth.py` | 5 | 注册、登录、登出、账户管理、修改密码 |
+| ✅ | `api/recording.py` | 24 | 录音 CRUD、上传、转录、下载、状态管理、ASR 校正 |
+| ✅ | `api/share.py` | 7 | 分享创建、查看、访问 |
+| ✅ | `api/tag.py` | 6 | 标签 CRUD、应用到录音 |
+| ✅ | `api/speaker.py` | 5 | 说话人管理、搜索 |
+| ✅ | `api/admin.py` | 18 | 管理后台、统计分析、录音管理、配置管理 |
+| ✅ | `api/search.py` | 7 | 搜索、Inquire 对话、收件箱 |
+| ✅ | `api/voiceprint.py` | 6 | 声纹注册、管理、同步 |
+| ✅ | `api/config.py` | 6 | 系统配置读写、分段服务管理 |
+| ✅ | `api/hotword.py` | 2 | 热词管理 |
+| ✅ | `api/template.py` | 5 | 转录模板 CRUD |
+| ✅ | `api/event.py` | 3 | 事件提取、ICS 导出 |
+| ✅ | `api/main.py` | 5 | 主页、摘要生成、对话 |
+
+**架构改进:**
+- 创建 `api/__init__.py` 中的 `register_blueprints()` 函数,统一管理所有蓝图注册和初始化
+- 使用 `init_xxx_bp()` 延迟注入模式解决循环导入问题(limiter、csrf、bcrypt 等全局对象)
+- 所有 API 路径保持不变,前端无需任何修改
+- `app.py` 仅保留:Flask 初始化、配置加载、蓝图注册、全局错误处理、启动逻辑
+
+**重构后修复的问题:**
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 重定向循环 | `main.py` 中的 `index()` 路由误加了 `@login_required` 装饰器,丢失了自动登录逻辑 | 恢复备份中的 token 验证和自动登录逻辑,移除装饰器 |
+| WebSocket 连接失败 | `getUserConfig` 路由缺少 `FUNASR_WEBSOCKET_IP` 和 `SCREEN_PUSH_IP` 配置 | 在 `main.py` 的 `get_user_config()` 中补充 WebSocket 配置字段 |
+| 循环导入 bcrypt | `main.py` 在模块级别导入 `bcrypt` 导致循环依赖 | 改为在 `init_main_bp()` 中延迟注入 `bcrypt_instance` |
+
+#### ✅ 已完成:统一配置管理
+
+创建 `src/config.py` 统一配置文件,将所有散落的环境变量读取集中管理:
+
+| 配置域 | 包含变量 |
+|--------|---------|
+| 基础配置 | `SECRET_KEY`, `UPLOAD_FOLDER`, `LOG_LEVEL`, `MAX_FILE_SIZE_MB` |
+| ASR 配置 | `USE_ASR_ENDPOINT`, `ASR_BASE_URL`, `ASR_DIARIZE`, `ASR_MIN_SPEAKERS`, `ASR_MAX_SPEAKERS` |
+| LLM 配置 | `TEXT_MODEL_API_KEY`, `TEXT_MODEL_BASE_URL`, `TEXT_MODEL_NAME` |
+| 分段配置 | `ENABLE_CHUNKING` |
+| Inquire 配置 | `ENABLE_INQUIRE_MODE` |
+| 数据库配置 | `SQLALCHEMY_DATABASE_URI`, `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE` |
+| 外部 API | `TRANSCRIPTION_BASE_URL`, `VALID_TOKEN`, `EMBEDDINGS_AVAILABLE` |
+| 代理配置 | `HTTP_PROXY`, `HTTPS_PROXY` |
+
+**重构成果:**
+- `clients/llm_client.py`、`clients/voiceprint_client.py` 从 `src.config` 导入配置
+- `clients/__init__.py` 统一导出所有配置项
+- `app.py` 通过 `from src.clients import ...` 统一导入配置,消除散落的环境变量读取
+- 提供 `validate_config()` 函数在启动时验证关键配置
+
+#### ✅ 已完成:Phase 3.5 - app.py 清理与同步服务抽取
+
+将 `src/app.py` 中残留的约 400 行业务逻辑彻底清理,app.py 从约 700 行精简到约 234 行:
+
+| 状态 | 任务 | 说明 |
+|------|------|------|
+| ✅ | 删除重复的 preprocess 函数 | 删除了 `preprocess_long_transcription` 等 6 个重复定义的函数(已在 llm_service.py 中) |
+| ✅ | 抽取远程同步服务 | 创建了 `services/sync_service.py`,将约 180 行同步逻辑从 app.py 移出 |
+| ✅ | 清理无用 import | 删除了 30+ 个未使用的导入(render_template、OpenAI、httpx、subprocess、markdown 等) |
+| ✅ | 注释中文化 | 将所有英文注释翻译为中文,删除无意义注释 |
+| ✅ | 修复函数签名 | `start_sync_thread()` 增加 `app` 参数,调用处同步更新 |
+| ✅ | 优化配置读取 | `preprocess_long_transcription` 等函数直接从 `src.config` 读取配置,不再通过参数传递 |
+
+#### ✅ 已完成:Phase 4 - app.py 工厂模式重构
+
+采用 Flask `create_app()` 工厂模式,将 `src/app.py` 从 234 行进一步精简到 60 行,各层职责清晰分离:
+
+**新建模块:**
+
+| 文件 | 职责 |
+|------|------|
+| `src/logging_config.py` | 日志配置模块 |
+| `src/flask_ext.py` | Flask 扩展实例集合(login_manager、bcrypt、limiter、csrf) |
+| `src/services/register.py` | 服务层统一初始化(音频分段、同步线程、文件监控、表结构迁移) |
+| `src/api/template_filters.py` | 模板过滤器注册 |
+
+**修改模块:**
+
+| 文件 | 修改内容 |
+|------|---------|
+| `src/config.py` | 新增 `get_app_config()` 函数,一次性返回所有需写入 `app.config` 的配置项。新增配置只需改此函数,无需修改 `app.py` |
+| `src/clients/__init__.py` | 集中管理 `chunking_service` 和 `EMBEDDINGS_AVAILABLE` 的初始化与导出 |
+| `src/api/__init__.py` | 简化 `register_blueprints()` 函数,直接从 `src.flask_ext` 导入扩展实例 |
+| `src/app.py` | 采用 `create_app()` 工厂模式,仅负责编排各层初始化顺序 |
+
+**`app.py` 最终结构(60 行):**
+
+```
+create_app()
+├── 1. configure_logging() ← src/logging_config.py
+├── 2. 创建 Flask 应用实例
+├── 3. 加载应用配置 ← src/config.py 的 get_app_config()
+├── 4. 配置反向代理支持
+├── 5. 初始化 Flask 扩展 ← src/flask_ext.py
+├── 6. 初始化数据库 ← src/clients/database.py
+├── 7. 注册蓝图 ← src/api/__init__.py
+├── 8. 注册模板过滤器 ← src/api/template_filters.py
+└── 9. 初始化服务层 ← src/services/register.py
+```
+
+**关键设计决策:**
+
+| 决策 | 理由 |
+|------|------|
+| `flask_ext.py` 独立 | Flask 扩展是全局单例,不应放在 clients 或 services 中 |
+| `get_app_config()` 集中管理 | 新增配置只需改 config.py,app.py 永远不需要改动 |
+| `register_blueprints` 自行读扩展 | 蓝图注册最清楚自己需要什么扩展,无需 app.py 中转 |
+| `init_services` 放在最后 | 服务依赖数据库和蓝图,必须在它们之后初始化 |
+
+---
+
+## 开发规范
+
+### API 接口返回格式规范
+
+> **重要:所有新建/修改的 API 接口必须使用 `ApiResponse` 工具类统一返回格式。旧接口暂不强制迁移。**
+
+#### 工具位置
+
+`src/api/api_response.py`
+
+#### 使用方式
+
+```python
+from src.api.api_response import ApiResponse
+
+# 成功响应(返回数据)
+@blueprint.route('/xxx', methods=['POST'])
+def create_xxx():
+ result = do_something()
+ return ApiResponse.success({'xxx': result.to_dict()})
+
+# 成功响应(带提示消息)
+ return ApiResponse.success({'xxx': result.to_dict()}, '创建成功')
+
+# 成功响应(自定义状态码,如 201 Created)
+ return ApiResponse.success({'xxx': result.to_dict()}, '创建成功', status_code=201)
+
+# 错误响应
+ if not xxx:
+ return ApiResponse.error('资源未找到', 404)
+
+# 错误响应(带详情)
+ if not valid:
+ return ApiResponse.error('参数校验失败', 400, detail='email 格式不正确')
+
+# 分页响应
+ return ApiResponse.paginated(
+ items=[item.to_dict() for item in items],
+ pagination={
+ 'page': page,
+ 'per_page': per_page,
+ 'total': total,
+ 'total_pages': total_pages,
+ 'has_next': has_next,
+ 'has_prev': has_prev
+ }
+ )
+```
+
+#### 响应格式约定
+
+**成功响应结构:**
+```json
+{
+ "success": true,
+ "message": "操作成功(可选)",
+ "xxx": {...} // 业务数据直接合并到顶层
+}
+```
+
+**分页响应结构:**
+```json
+{
+ "success": true,
+ "items": [...],
+ "pagination": {
+ "page": 1,
+ "total": 100,
+ ...
+ }
+}
+```
+
+**错误响应结构:**
+```json
+{
+ "error": "用户友好的错误提示"
+}
+```
+
+#### 注意事项
+
+1. `data` 参数必须是字典类型,列表数据请使用 `paginated()` 或直接合并到字典中
+2. 前端通过 `response.ok`(HTTP 状态码)判断成功/失败,通过 `data.error` 获取错误信息
+3. 旧接口暂不迁移,新接口和修改的接口必须使用 `ApiResponse`
+4. 不要在 `ApiResponse` 中直接传递原始异常信息给用户(`str(e)`),应转为友好提示
+
+---
+
+## 技术栈
+
+- **后端**: Python/Flask + SQLAlchemy
+- **前端**: Vue.js 3 + Tailwind CSS
+- **AI**: OpenAI Whisper (ASR) + Ollama/OpenRouter (LLM)
+- **数据库**: SQLite(默认)/ PostgreSQL(支持环境变量切换)
+- **部署**: Nginx 反向代理 + Flask 开发服务器
+
+---
+
+## 依赖安装
+
+```bash
+conda activate speakr
+pip install -r requirements.txt
+```
+
+主要依赖:
+- `flask==2.3.3`
+- `flask-sqlalchemy==3.1.1`
+- `flask-login==0.6.3`
+- `flask-wtf==1.2.2`
+- `flask-bcrypt==1.0.1`
+- `openai==1.3.0`
+- `markdown==3.5.1`
+- `python-docx==1.1.0`
+
+---
+
+## 许可证
+
+本项目基于 AGPL v3.0 开源协议。
diff --git a/speakr/Readme_deploy.md b/speakr/Readme_deploy.md
new file mode 100644
index 0000000..b1e5245
--- /dev/null
+++ b/speakr/Readme_deploy.md
@@ -0,0 +1,44 @@
+# 离线部署
+
+cd /opt/speakr
+
+# 1. 导入镜像(无需网络)
+docker load -i speakr.tar
+
+# 2. 一次性:从镜像里取出前端构建产物和完整 vendor 离线依赖,补进宿主机代码目录
+docker create --name speakr-tmp speakr:latest
+docker cp speakr-tmp:/app/static/vite ./static/
+docker cp speakr-tmp:/app/static/vendor ./static/
+docker rm speakr-tmp
+
+# 3. 准备 .env(按转写方式二选一,然后编辑填入密钥等配置)
+cp config/env.asr.example .env # 自建 ASR 服务用这个
+# cp config/env.whisper.example .env # Whisper API 用这个
+
+
+cd /home/wjs/speakr/speakr
+
+# 1. 把服务器上这份脚本的 CRLF 转成 LF,并确保可执行
+sed -i 's/\r$//' scripts/docker-entrypoint.sh
+chmod +x scripts/docker-entrypoint.sh
+
+# 2. 删掉一直重启的旧容器
+docker rm -f speakr
+
+# 3. 重新启动:多加一条单文件挂载,用修好的脚本覆盖镜像里那份坏的
+docker run -d \
+ --name speakr \
+ --restart unless-stopped \
+ -p 5000:8899 \
+ -v /home/wjs/speakr/speakr:/app \
+ -v /home/wjs/speakr/speakr/scripts/docker-entrypoint.sh:/usr/local/bin/docker-entrypoint.sh \
+ -v /home/wjs/speakr/uploads:/data/uploads \
+ -v /home/wjs/speakr/instance:/data/instance \
+ speakr:v2
+
+# 4. 确认启动正常(应看到"数据库不存在,正在创建..."之类的日志)
+docker logs -f speakr
+
+# 合并压缩
+
+cat speakr.tar.part* > speakr.tar
\ No newline at end of file
diff --git a/speakr/VERSION b/speakr/VERSION
new file mode 100644
index 0000000..c70db8f
--- /dev/null
+++ b/speakr/VERSION
@@ -0,0 +1 @@
+v0.5.6
\ No newline at end of file
diff --git a/speakr/app.py b/speakr/app.py
new file mode 100644
index 0000000..9b057e6
--- /dev/null
+++ b/speakr/app.py
@@ -0,0 +1,4 @@
+from src.app import app
+
+if __name__ == "__main__":
+ app.run(debug=True, host="0.0.0.0", port=5000)
\ No newline at end of file
diff --git a/speakr/config/docker-compose.example.yml b/speakr/config/docker-compose.example.yml
new file mode 100644
index 0000000..73d8a04
--- /dev/null
+++ b/speakr/config/docker-compose.example.yml
@@ -0,0 +1,43 @@
+services:
+ app:
+ image: learnedmachine/speakr:latest
+ container_name: speakr
+ restart: unless-stopped
+ ports:
+ - "8899:8899"
+
+ # --- Configuration ---
+ # Environment variables are loaded from the .env file.
+ #
+ # To get started:
+ # 1. Choose your desired transcription method.
+ # 2. Copy the corresponding example file to .env:
+ #
+ # For standard Whisper API:
+ # cp config/env.whisper.example .env
+ #
+ # For a custom ASR endpoint:
+ # cp config/env.asr.example .env
+ #
+ # 3. Edit the .env file to add your API keys and settings.
+ env_file:
+ - .env
+
+ environment:
+ # Set log level for troubleshooting
+ # Use ERROR for production (minimal logs)
+ # Use INFO for debugging issues (recommended when troubleshooting)
+ # Use DEBUG for detailed development logging
+ - LOG_LEVEL=ERROR
+
+ # --- Volume Configuration ---
+ # Choose ONE of the following volume configurations.
+ # Option 1 (Recommended): Bind mounts to local folders.
+ volumes:
+ - ./uploads:/data/uploads
+ - ./instance:/data/instance
+
+ # Option 2: Docker-managed volumes.
+ # volumes:
+ # - speakr-uploads:/data/uploads
+ # - speakr-instance:/data/instance
diff --git a/speakr/config/env.asr.example b/speakr/config/env.asr.example
new file mode 100644
index 0000000..0cabd94
--- /dev/null
+++ b/speakr/config/env.asr.example
@@ -0,0 +1,74 @@
+# -----------------------------------------------------------------------------
+# Speakr Configuration: ASR Endpoint
+#
+# Instructions:
+# 1. Copy this file to a new file named .env
+# cp env.asr.example .env
+# 2. Fill in the required URLs, API keys, and settings below.
+# -----------------------------------------------------------------------------
+
+# --- Text Generation Model (for summaries, titles, etc.) ---
+TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
+TEXT_MODEL_API_KEY=your_openrouter_api_key
+TEXT_MODEL_NAME=openai/gpt-4o-mini
+
+# --- Transcription Service (ASR Endpoint) ---
+# Setting this to true enables all ASR features including speaker diarization
+# Note: File chunking is NOT supported with ASR endpoints
+USE_ASR_ENDPOINT=true
+
+# ASR Endpoint URL
+# For containers in same docker-compose: Use container name and internal port
+# Example: http://whisper-asr:9000 (NOT the host port 6002 or external IP)
+# For external ASR: Use http://192.168.1.100:9000 or http://asr.example.com:9000
+ASR_BASE_URL=http://whisper-asr:9000
+
+# Optional: Override default speaker settings (defaults shown)
+# These are automatically set when USE_ASR_ENDPOINT=true
+# ASR_DIARIZE=true # Auto-enabled with ASR (set to false to disable)
+# ASR_MIN_SPEAKERS=1 # Default minimum speakers
+# ASR_MAX_SPEAKERS=5 # Default maximum speakers
+
+# --- Application Settings ---
+# Set to "true" to allow user registration, "false" to disable
+ALLOW_REGISTRATION=false
+SUMMARY_MAX_TOKENS=8000
+CHAT_MAX_TOKENS=5000
+
+# Timezone for displaying dates and times in the UI
+# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC")
+TIMEZONE="UTC"
+
+# Set the logging level for the application.
+# Options: DEBUG, INFO, WARNING, ERROR
+LOG_LEVEL="INFO"
+
+# --- Admin User (created on first run) ---
+ADMIN_USERNAME=admin
+ADMIN_EMAIL=admin@example.com
+ADMIN_PASSWORD=changeme
+
+# --- Inquire Mode (AI search across all recordings) ---
+# Set to "true" to enable semantic search and chat across all recordings
+# Requires additional dependencies (already included in Docker image)
+ENABLE_INQUIRE_MODE=false
+
+# --- Automated File Processing (Black Hole Directory) ---
+# Set to "true" to enable automated file processing
+ENABLE_AUTO_PROCESSING=false
+
+# Processing mode: admin_only, user_directories, or single_user
+AUTO_PROCESS_MODE=admin_only
+
+# Directory to watch for new audio files
+AUTO_PROCESS_WATCH_DIR=/data/auto-process
+
+# How often to check for new files (seconds)
+AUTO_PROCESS_CHECK_INTERVAL=30
+
+# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user)
+# AUTO_PROCESS_DEFAULT_USERNAME=admin
+
+# --- Docker Settings (rarely need to be changed) ---
+SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
+UPLOAD_FOLDER=/data/uploads
diff --git a/speakr/config/env.whisper.example b/speakr/config/env.whisper.example
new file mode 100644
index 0000000..5ea9fe7
--- /dev/null
+++ b/speakr/config/env.whisper.example
@@ -0,0 +1,77 @@
+# -----------------------------------------------------------------------------
+# Speakr Configuration: Standard Whisper API
+#
+# Instructions:
+# 1. Copy this file to a new file named .env
+# cp env.whisper.example .env
+# 2. Fill in the required API keys and settings below.
+# -----------------------------------------------------------------------------
+
+# --- Text Generation Model (for summaries, titles, etc.) ---
+TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
+TEXT_MODEL_API_KEY=your_openrouter_api_key
+TEXT_MODEL_NAME=openai/gpt-4o-mini
+
+# --- Transcription Service (OpenAI Whisper API) ---
+TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
+TRANSCRIPTION_API_KEY=your_openai_api_key
+WHISPER_MODEL=whisper-1
+
+# --- Application Settings ---
+# Set to "true" to allow user registration, "false" to disable
+ALLOW_REGISTRATION=false
+SUMMARY_MAX_TOKENS=8000
+CHAT_MAX_TOKENS=5000
+
+# Timezone for displaying dates and times in the UI
+# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC")
+TIMEZONE="UTC"
+
+# Set the logging level for the application.
+# Options: DEBUG, INFO, WARNING, ERROR
+LOG_LEVEL="INFO"
+
+# --- Large File Chunking ---
+# Automatically splits large files to work with API limits
+ENABLE_CHUNKING=true
+
+# Choose chunking method based on your provider's limits:
+# For size-based limits (e.g., OpenAI's 25MB limit):
+CHUNK_LIMIT=20MB
+
+# For duration-based limits (uncomment and use instead):
+# CHUNK_LIMIT=1200s # 20 minutes
+# CHUNK_LIMIT=20m # Alternative format
+
+# Overlap between chunks (seconds)
+CHUNK_OVERLAP_SECONDS=3
+
+# --- Admin User (created on first run) ---
+ADMIN_USERNAME=admin
+ADMIN_EMAIL=admin@example.com
+ADMIN_PASSWORD=changeme
+
+# --- Inquire Mode (AI search across all recordings) ---
+# Set to "true" to enable semantic search and chat across all recordings
+# Requires additional dependencies (already included in Docker image)
+ENABLE_INQUIRE_MODE=false
+
+# --- Automated File Processing (Black Hole Directory) ---
+# Set to "true" to enable automated file processing
+ENABLE_AUTO_PROCESSING=false
+
+# Processing mode: admin_only, user_directories, or single_user
+AUTO_PROCESS_MODE=admin_only
+
+# Directory to watch for new audio files
+AUTO_PROCESS_WATCH_DIR=/data/auto-process
+
+# How often to check for new files (seconds)
+AUTO_PROCESS_CHECK_INTERVAL=30
+
+# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user)
+# AUTO_PROCESS_DEFAULT_USERNAME=admin
+
+# --- Docker Settings (rarely need to be changed) ---
+SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
+UPLOAD_FOLDER=/data/uploads
diff --git a/speakr/deployment/setup.sh b/speakr/deployment/setup.sh
new file mode 100644
index 0000000..9433372
--- /dev/null
+++ b/speakr/deployment/setup.sh
@@ -0,0 +1,92 @@
+#!/bin/bash
+
+# 停止转录服务
+sudo systemctl stop transcription
+
+# 创建应用目录并设置所有者
+sudo mkdir -p /opt/transcription-app
+sudo chown $USER:$USER /opt/transcription-app
+
+# 复制应用文件
+cp app.py /opt/transcription-app/
+cp -r templates /opt/transcription-app/
+cp requirements.txt /opt/transcription-app/
+cp scripts/reset_db.py /opt/transcription-app/
+cp scripts/create_admin.py /opt/transcription-app/
+cp .env /opt/transcription-app/ # 复制包含 API 密钥的 .env 文件
+
+# 如果 .env 文件中不存在 SECRET_KEY,则添加
+if ! grep -q "SECRET_KEY" /opt/transcription-app/.env; then
+ echo "SECRET_KEY=$(openssl rand -hex 32)" >> /opt/transcription-app/.env
+ echo "已添加 SECRET_KEY 到 .env 文件"
+fi
+
+# 创建并激活虚拟环境
+python3 -m venv /opt/transcription-app/venv
+source /opt/transcription-app/venv/bin/activate
+
+# 安装依赖
+cd /opt/transcription-app
+pip install -r requirements.txt
+
+# 创建上传和数据库目录并设置权限
+mkdir -p /opt/transcription-app/uploads
+mkdir -p /opt/transcription-app/instance
+chmod 755 /opt/transcription-app/uploads
+chmod 755 /opt/transcription-app/instance
+
+# 初始化或迁移数据库
+if [ ! -f /opt/transcription-app/instance/transcriptions.db ]; then
+ # 如果数据库不存在,从头创建
+ echo "数据库不存在,正在创建新数据库..."
+ python reset_db.py
+else
+ # 如果数据库存在,迁移模式以保留数据
+ echo "数据库已存在,正在迁移模式以保留数据..."
+ python migrate_db.py
+fi
+
+# 设置所有文件的正确所有者
+sudo chown -R $USER:$USER /opt/transcription-app
+
+# 创建 systemd 服务文件
+sudo tee /etc/systemd/system/transcription.service << EOF
+[Unit]
+Description=Transcription Web Application
+After=network.target
+
+[Service]
+User=$USER
+EnvironmentFile=/opt/transcription-app/.env
+WorkingDirectory=/opt/transcription-app
+Environment="PATH=/opt/transcription-app/venv/bin"
+Environment="PYTHONPATH=/opt/transcription-app"
+ExecStart=/opt/transcription-app/venv/bin/gunicorn --workers 3 --threads 8 --bind 0.0.0.0:8899 --timeout 600 app:app
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+# 重新加载 systemd 并启动服务
+sudo systemctl daemon-reload
+sudo systemctl restart transcription
+sudo systemctl disable transcription
+sudo systemctl enable transcription
+
+# 检查服务状态
+echo "正在检查服务状态..."
+sleep 3
+sudo systemctl status transcription
+
+echo "安装完成!应用应在 8899 端口上运行。"
+
+# 询问是否创建管理员账户
+read -p "是否现在创建管理员账户?(y/n): " create_admin
+if [[ $create_admin == "y" || $create_admin == "Y" ]]; then
+ echo "正在创建管理员账户..."
+ python create_admin.py
+else
+ echo "你可以稍后运行以下命令来创建管理员账户:python create_admin.py"
+fi
diff --git a/speakr/docs/.gitignore b/speakr/docs/.gitignore
new file mode 100644
index 0000000..509897c
--- /dev/null
+++ b/speakr/docs/.gitignore
@@ -0,0 +1,31 @@
+# Jekyll build output
+_site/
+.sass-cache/
+.jekyll-cache/
+.jekyll-metadata
+
+# Bundle directory
+.bundle/
+vendor/
+
+# Local development files
+_config_local.yml
+local-serve.sh
+docker-serve.sh
+simple-serve.sh
+serve-local.sh
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# IDE files
+.vscode/
+.idea/
+
+# Ruby version files
+.ruby-version
+.ruby-gemset
+
+# Documentation verification (internal use)
+DOCUMENTATION_VERIFICATION_CHECKLIST.md
\ No newline at end of file
diff --git a/speakr/docs/Dockerfile b/speakr/docs/Dockerfile
new file mode 100644
index 0000000..0154b42
--- /dev/null
+++ b/speakr/docs/Dockerfile
@@ -0,0 +1,28 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Install git (required for git-revision-date plugin)
+RUN apt-get update && \
+ apt-get install -y git && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+# Install requirements
+COPY requirements-docs.txt .
+RUN pip install --no-cache-dir -r requirements-docs.txt
+
+# Create docs directory structure
+RUN mkdir -p docs
+
+# Copy mkdocs config to root
+COPY mkdocs.yml .
+
+# Copy all documentation files to docs subdirectory
+COPY . ./docs/
+
+# Expose MkDocs development server port
+EXPOSE 8000
+
+# Run MkDocs server
+CMD ["mkdocs", "serve", "--dev-addr=0.0.0.0:8000"]
\ No newline at end of file
diff --git a/speakr/docs/Gemfile.simple b/speakr/docs/Gemfile.simple
new file mode 100644
index 0000000..e9762a6
--- /dev/null
+++ b/speakr/docs/Gemfile.simple
@@ -0,0 +1,8 @@
+source "https://rubygems.org"
+
+gem "jekyll", "~> 4.3"
+gem "jekyll-seo-tag"
+gem "jekyll-sitemap"
+gem "jekyll-feed"
+gem "webrick", "~> 1.8"
+gem "kramdown-parser-gfm"
\ No newline at end of file
diff --git a/speakr/docs/_includes/sidebar.html b/speakr/docs/_includes/sidebar.html
new file mode 100644
index 0000000..12fa7da
--- /dev/null
+++ b/speakr/docs/_includes/sidebar.html
@@ -0,0 +1,33 @@
+
\ No newline at end of file
diff --git a/speakr/docs/_layouts/default.html b/speakr/docs/_layouts/default.html
new file mode 100644
index 0000000..3211aba
--- /dev/null
+++ b/speakr/docs/_layouts/default.html
@@ -0,0 +1,403 @@
+
+
+
+
+
+
+
+ {% seo %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% include sidebar.html %}
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/speakr/docs/_layouts/docs.html b/speakr/docs/_layouts/docs.html
new file mode 100644
index 0000000..20f37ed
--- /dev/null
+++ b/speakr/docs/_layouts/docs.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ {% if page.title %}{{ page.title }} - {% endif %}{{ site.title }}
+
+
+
+
+
+
+ {% include sidebar.html %}
+
+
+
+ {{ content }}
+
+
+
+
+
\ No newline at end of file
diff --git a/speakr/docs/admin-guide/index.md b/speakr/docs/admin-guide/index.md
new file mode 100644
index 0000000..75eb0bf
--- /dev/null
+++ b/speakr/docs/admin-guide/index.md
@@ -0,0 +1,99 @@
+# 管理员指南
+
+欢迎使用 Speakr 管理员指南!作为管理员,你可以控制 Speakr 实例的核心,管理用户、监控系统健康状况以及配置 AI 行为。
+
+## 管理控制面板
+
+
+
+
👥
+
用户管理
+
创建账户、管理权限、监控使用情况以及控制对 Speakr 实例的访问。
+
管理用户 →
+
+
+
+
📊
+
系统统计
+
监控系统健康状况、跟踪使用模式,并在问题影响用户之前发现潜在问题。
+
查看统计 →
+
+
+
+
🔧
+
系统设置
+
配置全局限制、超时、文件大小以及影响所有用户的系统级行为。
+
配置系统 →
+
+
+
+
✨
+
默认提示词
+
通过默认摘要提示词自定义 AI 行为,塑造内容处理方式。
+
设置提示词 →
+
+
+
+
🔍
+
向量存储
+
管理语义搜索功能、监控嵌入状态以及控制 Inquire 模式。
+
管理搜索 →
+
+
+
+## 快速操作
+
+
+
+
➕
+
+
添加新用户
+
用户管理 → 添加用户按钮 → 输入详细信息 → 设置权限
+
+
+
+
+
📈
+
+
检查系统健康状况
+
系统统计 → 查看指标 → 检查处理状态 → 监控存储
+
+
+
+
+
⚙️
+
+
更新设置
+
系统设置 → 调整限制 → 配置超时 → 保存更改
+
+
+
+
+
🔄
+
+
处理嵌入
+
向量存储 → 检查状态 → 处理待处理项 → 监控进度
+
+
+
+
+## 需要管理员帮助?
+
+
+
+
+ 🐛
+ 检查 Docker 日志:docker-compose logs -f app
+
+
+ 💾
+ 定期备份你的数据目录
+
+
+
+---
+
+准备好管理你的 Speakr 实例了吗?从[用户管理](user-management.md)开始 →
diff --git a/speakr/docs/admin-guide/prompts.md b/speakr/docs/admin-guide/prompts.md
new file mode 100644
index 0000000..2a48f15
--- /dev/null
+++ b/speakr/docs/admin-guide/prompts.md
@@ -0,0 +1,91 @@
+# 默认提示词
+
+默认提示词选项卡允许你塑造 AI 如何在你的整个 Speakr 实例中[解释和总结录音](../features.md#automatic-summarization)。在这里,你可以建立用户在没有自定义[个人提示词](../user-guide/settings.md#custom-prompts-tab)时体验的基线智能。
+
+
+
+## 理解提示词层级
+
+Speakr 使用复杂的层级来确定在任何给定录音中使用哪个提示词。该系统在保持控制的同时提供灵活性,确保用户获得适当的摘要,同时在需要时允许自定义。
+
+层级的最顶层是标签特定的提示词。当录音具有[标签](../user-guide/settings.md#tag-management-tab)且关联了提示词时,这些具有绝对优先级。在功能指南中了解[标签管理](../features.md#tagging-system)。多个标签提示词会智能地拼接,允许为专业内容类型进行复杂的提示词堆叠。
+
+接下来是用户的个人自定义提示词,在其[账户设置](../user-guide/settings.md#custom-prompts-tab)中设置。用户还可以为摘要配置[语言偏好](../user-guide/settings.md#language-preferences)。这允许个人在不影响其他人的情况下根据自己的需求定制摘要。许多用户从不设置这个,因此你的管理员默认值更加重要。
+
+你在此页面上配置的管理员默认提示词是大多数摘要的基础。这是新用户体验的内容,也是未自定义设置的长期用户所依赖的内容。它决定了你的 Speakr 实例的整体智能和实用性。
+
+最后,如果所有其他方法都失败,硬编码的系统回退确保始终生成摘要。你在实践中很少看到这个,但它提供了一个安全网,确保系统永远不会无法生成输出。
+
+## 设计有效的默认提示词
+
+你的默认提示词不仅仅是技术指令——它是理解内容的模板。界面中显示的提示词演示了一种平衡的方法,要求关键问题、决策和待办事项。这种结构适用于商务会议,但可能不适合所有场景。
+
+在设计提示词时考虑你的用户群体。研究机构可能会强调方法和发现。律师事务所可能会关注案件细节和判例。对于多语言支持,请参阅[语言配置](../features.md#language-support)和[语言问题故障排除](../troubleshooting.md#summary-language-doesnt-match-preference)。创意机构可能会突出概念和客户反馈。提示词应反映对用户最重要的内容。
+
+AI 对清晰、结构化的请求响应最佳。使用项目符号或编号部分来组织输出。指定你想要的详细程度——"简要概述"与"全面分析"会产生截然不同的结果。如果某些格式至关重要,请包含示例。
+
+请记住,此提示词适用于从五分钟检查到两小时研讨会的所有内容。设计时要考虑多样性。避免对可能不适用于所有内容的过于具体的要求。专注于提取具有普遍价值的信息,同时允许 AI 灵活地适应不同的录音类型。
+
+## 默认提示词编辑器
+
+大型文本区域显示你的当前默认提示词,完全支持 markdown 格式。你可以使用粗体进行强调、列表用于结构化,甚至可以使用代码块(如果需要显示示例格式)。编辑器会扩展以适应更长的提示词,尽管简洁通常会产生更好的结果。
+
+当你点击"保存更改"按钮时,更改会立即保存。没有草稿或暂存——修改会立即影响所有新摘要。用户可以[重新生成摘要](../user-guide/transcripts.md)以将更新的提示词应用于现有录音。"重置为默认"按钮提供了安全网,如果你的自定义效果不如预期,可以恢复到原始提示词。
+
+时间戳显示提示词的最后修改时间,有助于跟踪随时间的更改。如果多个管理员管理你的实例,这有助于协调谁更改了什么以及何时更改的。
+
+## 理解 LLM 提示词结构
+
+可展开的"查看完整 LLM 提示词结构"部分揭示了你的提示词如何融入发送给 AI 的完整指令。这个技术视图显示了系统提示词、你的自定义提示词以及转录文本的集成。
+
+理解这个结构有助于你编写更好的提示词。你会看到某些指令已经由系统提示词处理,因此你不需要重复它们。你会理解你的提示词如何与转录文本交互,以及为什么某些措辞比其他措辞更有效。
+
+这种透明性也有助于故障排除。如果摘要不符合预期,查看完整提示词结构通常会揭示原因。也许你的指令与系统指令冲突,或者你可能在请求转录文本中通常不存在的信息。
+
+## 实用提示词策略
+
+从经过验证的结构开始,并根据结果进行迭代。默认提示词之所以有效,是因为它请求具体、可操作的信息。关键问题提供上下文,决策记录结果,待办事项推动跟进。
+
+在广泛部署之前,使用各种录音类型测试你的提示词。适用于正式演示的提示词可能在随意对话中失效。上传具有不同特征的测试录音并评估生成的摘要。
+
+考虑季节性或基于项目的调整。在规划季节,你可能会强调目标和策略。在执行阶段,专注于进展和阻碍因素。随着组织需求的发展,你可以更新默认提示词。
+
+监控用户关于摘要质量的反馈。如果用户经常编辑摘要或抱怨缺少信息,你的提示词可能需要调整。最好的提示词是用户很少需要修改的提示词。
+
+## 高级提示词技巧
+
+分层指令以获得细致的输出。不要只请求"待办事项",而是指定"带责任方和提及的截止日期的待办事项"。这种精度有助于 AI 在可用时提取更有价值的信息。
+
+使用条件语言增加灵活性。诸如"如果适用"或"当讨论时"之类的短语允许 AI 跳过不适用于每个录音的部分。这可以防止摘要中出现无关的内容。
+
+考虑 AI 模型的优势和局限性。当前模型擅长识别主题、提取具体信息和组织内容。它们在复杂推理、数学计算和未明确陈述的信息方面存在困难。设计发挥这些优势的提示词。
+
+在细节与可读性之间取得平衡。极其详细的提示词可能会生成用户不阅读的综合性摘要。有时简洁、聚焦的摘要比详尽的文档更好地为用户服务。
+
+## 与用户提示词协调
+
+你的默认提示词应补充而非与用户自定义竞争。将其设计为适用于大多数情况的坚实基础,同时鼓励高级用户根据自己的特定需求进行自定义。
+
+向用户传达你的提示词策略。让他们知道默认提示词强调什么,以便他们可以决定自定义是否对他们有利。分享基于你默认值构建的有效用户提示词示例。
+
+考虑为用户记录提示词最佳实践。如果某些部门需要专业摘要,提供他们可以使用的推荐提示词。这赋能了用户,同时在重要的地方保持一致性。
+
+## 衡量提示词有效性
+
+跟踪用户修改 AI 生成摘要的频率。频繁编辑表明你的提示词没有捕获用户需要的内容。最少的编辑表明你的提示词有效地提取了有价值的信息。
+
+定期审查摘要样本。它们是否始终包含请求的部分?信息是否准确且相关?用户是否添加了提示词应该请求的类似信息?
+
+在用户审查或支持交互期间收集反馈。具体询问摘要质量以及默认格式是否满足他们的需求。不自定义提示词的用户完全依赖你的默认值,因此他们的反馈至关重要。
+
+## 常见提示词陷阱
+
+避免过于死板的提示词,将结构强加于不兼容的内容上。并非每个录音都有"决策"或"待办事项"。强制 AI 在不存在时找到这些内容会产生无意义的填充。
+
+不要请求 AI 无法提供的信息。要求"未说出的担忧"或"未讨论的内容"超出了转录分析的范围。AI 只能处理实际说出和记录的内容。
+
+抵制使提示词过长的诱惑。每个指令都会增加复杂性和潜在的混淆。专注于最重要的内容,而不是试图捕获每个可能的细节。
+
+---
+
+下一步:[向量存储](vector-store.md) →
diff --git a/speakr/docs/admin-guide/statistics.md b/speakr/docs/admin-guide/statistics.md
new file mode 100644
index 0000000..1b8f6e6
--- /dev/null
+++ b/speakr/docs/admin-guide/statistics.md
@@ -0,0 +1,39 @@
+# 系统统计
+
+系统统计选项卡将原始数据转化为关于你的 Speakr 实例的可操作洞察。一目了然,你可以看到你正在服务多少用户、他们创建了多少录音、他们消耗了多少存储,以及一切是否处理顺畅。
+
+
+
+## 关键指标概览
+
+统计页面顶部的四个醒目卡片让你立即了解系统的规模。总用户数显示你当前的用户基础规模,帮助你了解实例的覆盖范围。总录音数揭示了系统中的累积内容,而总存储显示了实际消耗的磁盘空间。当启用 Inquire 模式时,总查询数表明用户搜索录音的活跃程度。
+
+这些数字讲述了你的实例的健康状况和增长的故事。不断增长的用户数与按比例增长的录音数表明健康的采用率。存储增长速度超过录音数增长可能表明用户正在上传更长的文件。查询数揭示了用户是否从语义搜索功能中获得价值。
+
+## 录音状态分布
+
+状态分布部分将你的录音分解为四种关键状态。已完成的录音已完全处理并准备好使用——这应该是你内容的绝大多数。处理中的录音当前正在进行转录或分析。待处理的录音已排队等待处理。失败的录音遇到错误需要关注。
+
+在健康的系统中,你会看到大部分是已完成的录音,可能随时只有少数在处理中。大量待处理的录音可能表明你的系统不堪重负或后台处理已停止。失败的录音始终值得调查——它们可能会揭示配置问题、API 问题或用户尝试上传的损坏文件。
+
+## 存储分析
+
+"按存储排名的顶级用户"部分揭示了谁在你的系统中消耗了最多的资源。每个用户都列出了他们的总存储消耗和录音数量,让你了解他们是拥有许多小文件还是较少的大文件。
+
+此信息对于容量规划和用户教育非常有价值。如果一个用户消耗了不成比例的存储,你可能需要更好地了解他们的使用场景。他们是在录制数小时的会议吗?永远保留所有内容?理解数字背后的原因有助于你做出更好的策略决策。
+
+## 理解使用模式
+
+统计不仅仅是数字——它们是等待发现的洞察。录音的突然激增可能与项目启动、学术学期或公司计划相吻合。超过录音增长的存储增长可能表明用户正在上传更长的内容或更高质量的音频文件。
+
+定期监控有助于你在趋势成为问题之前发现它们。如果存储每月增长 10%,你可以预测何时需要扩展容量。如果失败的录音突然激增,你可以调查是否是 API 密钥过期或服务宕机。
+
+## 容量规划
+
+系统统计是你的基础设施需求的水晶球。存储增长趋势告诉你何时需要更多磁盘空间。用户增长模式表明何时可能需要扩展服务器资源。处理队列揭示了当前设置是否能够处理工作负载。
+
+主动使用这些洞察。如果你看到存储以每月 50GB 的速度增长且你有 200GB 可用空间,你知道你在需要干预之前还有大约四个月的时间。这个提前期让你可以预算升级、规划迁移或在达到临界限制之前实施保留策略。
+
+---
+
+下一步:[系统设置](system-settings.md) →
diff --git a/speakr/docs/admin-guide/system-settings.md b/speakr/docs/admin-guide/system-settings.md
new file mode 100644
index 0000000..110d2a0
--- /dev/null
+++ b/speakr/docs/admin-guide/system-settings.md
@@ -0,0 +1,57 @@
+# 系统设置
+
+系统设置是你配置影响 Speakr 实例中每个用户和录音的基本行为的地方。这些全局参数塑造系统的运行方式,从技术限制到面向用户的功能。
+
+
+
+## 转录文本长度限制
+
+转录文本长度限制决定了在生成摘要或响应聊天时有多少文本会被发送给 AI。这个看似简单的数字对质量和成本都有很大影响。
+
+当设置为"无限制"时,无论长度如何,整个转录文本都会发送给 AI。这确保了 AI 拥有完整的上下文,但对于长录音来说可能会变得昂贵。两小时的会议可能会生成 20,000 字的转录文本,消耗大量 API 令牌并可能超出 AI 模型的上下文窗口。此限制也将应用于说话人识别模态中的说话人自动检测功能。
+
+设置字符限制(如 50,000 个字符)会为 API 消耗设置上限。系统将截断非常长的转录文本,仅将开头部分发送给 AI。这使成本可预测,但可能意味着 AI 会错过录音后面部分的重要内容。
+
+最佳点取决于你的使用场景。对于典型的一小时以内的会议,50,000 个字符通常可以捕获所有内容。对于更长的会话,你可以增加此限制或培训用户拆分录音。监控你的 API 成本和用户反馈以找到正确的平衡。
+
+## 最大文件大小
+
+文件大小限制保护你的系统免受大量上传的压倒性影响,同时确保用户可以使用合理的录音。默认的 300MB 可容纳数小时的压缩音频,这涵盖了大多数使用场景。
+
+提高此限制允许更长的录音,但需要仔细考虑。较大的文件需要更长的上传时间、消耗更多存储,并且可能在处理期间超时。你的服务器需要足够的内存来处理这些文件,并且你的存储必须能够容纳它们。网络超时、浏览器限制和用户耐心都影响着什么是实际的。
+
+如果用户经常触及限制,请考虑他们是否真的需要这么长的单个录音。通常,将长会话拆分为逻辑片段会产生更好的结果——更容易查看、处理更快、摘要更聚焦。
+
+## ASR 超时设置
+
+ASR 超时决定了 Speakr 将等待高级转录服务完成工作的时间。默认的 1,800 秒(30 分钟)可处理大多数录音,但你可能需要根据你的转录服务和典型文件大小进行调整。
+
+设置过低会导致较长的录音即使转录服务正常工作也会失败。录音似乎卡在处理中,然后最终失败,令必须重试或放弃的用户感到沮丧。设置过高会使系统资源等待可能已经失败的服务。
+
+最佳超时取决于你的转录服务的性能和用户的录音长度。监控成功转录的处理时间,并将超时设置为舒适地高于你最长的正常处理时间。如果你经常处理数小时的录音,你可能需要 3,600 秒或更多。
+
+## 录音免责声明
+
+录音免责声明会在用户开始任何录音会话之前出现,非常适合法律通知、策略提醒或使用指南。这个 markdown 格式的消息确保用户在创建内容之前了解他们的责任。
+
+组织通常将其用于合规要求——提醒用户关于同意要求、数据处理策略或适当使用指南。教育机构可能会指出录音仅用于学术目的。医疗机构可能会参考 HIPAA 合规要求。
+
+保持免责声明简洁且相关。用户会频繁看到此消息,因此冗长的法律文本会变成被忽视的点击-through。专注于最重要的要点,并在需要时链接到详细策略。markdown 支持让你可以使用粗体文本进行强调或链接到其他资源来清晰地格式化消息。
+
+## 系统级影响
+
+此页面上的每个设置都会立即影响所有用户。更改在你保存后立即生效,无需系统重启或用户注销。这种即时应用意味着你应该仔细测试更改并将重大修改传达给你的用户。
+
+刷新按钮从数据库重新加载设置,如果多个管理员可能在进行更改或你想确保你看到的是最新值,这将非常有用。界面显示每个设置的最后更新时间,帮助你跟踪随时间的更改。
+
+## 常见问题故障排除
+
+当录音持续失败时,请检查它们是否触及了你配置的限制。错误日志会指示文件是否过大或处理是否超时。用户可能没有意识到他们的录音超出了限制,尤其是如果他们上传现有内容而不是直接录音。
+
+如果 API 成本意外飙升,请检查你的转录文本长度限制。如果未设置限制,单个用户上传许多长录音可能会大幅增加消耗。用户活动与系统设置的组合决定了你的实际成本。
+
+处理积压可能表明你的超时设置过高。如果系统每次失败的转录尝试等待 30 分钟,一系列有问题的文件可能会阻塞队列数小时。在对慢速处理的耐心与服务实际宕机时快速失败之间取得平衡。
+
+---
+
+下一步:[默认提示词](prompts.md) →
diff --git a/speakr/docs/admin-guide/user-management.md b/speakr/docs/admin-guide/user-management.md
new file mode 100644
index 0000000..cffb3c6
--- /dev/null
+++ b/speakr/docs/admin-guide/user-management.md
@@ -0,0 +1,47 @@
+# 用户管理
+
+用户管理选项卡是控制谁可以访问你的 Speakr 实例以及他们可以用它做什么的指挥中心。每个用户账户都从这里流转,从初始创建到最终删除,以及沿途所需的所有监控和调整。有关系统级使用模式,请参阅[系统统计](statistics.md)。
+
+
+
+## 理解用户表格
+
+当你打开用户管理时,你会看到一个显示系统中每个用户的综合表格。每一行都讲述了一个故事——电子邮件地址标识用户,管理员徽章显示他们的权限级别,录音数量揭示他们的活跃程度,存储度量展示他们的资源消耗。
+
+顶部的搜索栏在你输入时会立即响应,过滤表格以帮助你快速找到特定用户。随着你的用户基础增长到超出单个屏幕可容纳的范围,这变得非常宝贵。当你在进行更改时,表格会实时更新,因此你始终看到系统的当前状态。
+
+## 添加新用户
+
+创建用户账户从点击右上角的"添加用户"按钮开始。出现的模态框会询问基本信息——用户名、电子邮件地址和密码。你还需要立即决定此人是否需要管理员权限,尽管你以后可以随时更改。
+
+用户名成为他们在 Speakr 中的身份,出现在界面中并组织他们的录音。电子邮件地址具有双重用途——它是他们的登录凭证以及任何系统通信的联系方式。你设置的密码是临时的;用户应在首次登录后立即通过其[账户设置](../user-guide/settings.md)更改它。配置初始[语言偏好](../user-guide/settings.md#language-preferences)和[自定义提示词](../user-guide/settings.md#custom-prompts-tab)。
+
+管理员权限很强大,应谨慎授予。管理员用户可以看到和修改所有[系统设置](system-settings.md)、管理其他用户(包括其他管理员)、配置[默认提示词](prompts.md)以及监控[向量存储](vector-store.md)。大多数用户永远不需要这些功能。
+
+## 管理现有用户
+
+每个用户行都包含操作按钮,让你对该账户拥有完全控制权。编辑按钮会打开一个模态框,你可以在其中更新他们的用户名或电子邮件地址。当人们更改姓名、切换电子邮件提供商或需要纠正初始输入错误时,这非常有用。
+
+管理员切换可能是界面中最强大的单次点击。将用户提升为管理员授予他们可以访问和执行的所有权限。将管理员降级为普通用户会立即撤销他们的所有管理功能。系统会阻止你从自己的账户中删除管理员权限,确保你不会意外将自己锁定。
+
+删除按钮需要仔细考虑。删除用户是永久性的,无法通过界面撤销。他们的所有录音、笔记和设置将与其账户一起删除。系统会请求确认,但一旦确认,删除立即且彻底。
+
+## 监控使用模式
+
+录音数量和存储列揭示了用户如何与你的 Speakr 实例交互。高录音数量可能表明严重依赖系统的重度用户,而低数量可能表明需要培训或可能根本不需要账户的用户。
+
+存储消耗讲述了另一个重要故事。存储消耗过高的用户可能正在上传非常长的录音、无限期保留所有内容,或者可能滥用系统。你可以根据需要调整[文件大小限制](system-settings.md#maximum-file-size)并审查[分块设置](../troubleshooting.md#files-over-25mb-fail-with-openai)。此信息帮助你进行有关资源使用的知情对话并制定适当的策略。
+
+当你定期审查这些数据时,通常会出现模式。你可能会注意到学术环境中的季节性变化、企业环境中基于项目的激增,或表明需要基础设施扩展的逐渐增长。
+
+## 安全影响
+
+每个用户账户都是潜在的安全向量。强密码是你的第一道防线,但仅靠它们是不够的。鼓励用户使用唯一密码、定期更改密码,并且永远不要与他人共享密码。
+
+管理员账户需要额外的警惕。每个管理员都可以执行你可以做的所有操作,包括创建更多管理员或删除所有用户。将管理员访问限制在操作所需的绝对最小值。当某人不再需要管理员权限时,立即撤销它们。
+
+不活动的账户构成特殊风险。它们可能拥有无人监控的弱密码或已泄露的密码。定期审核有助于你在这些漏洞成为问题之前识别并消除它们。
+
+---
+
+下一步:[系统统计](statistics.md) →
diff --git a/speakr/docs/admin-guide/vector-store.md b/speakr/docs/admin-guide/vector-store.md
new file mode 100644
index 0000000..ac714bf
--- /dev/null
+++ b/speakr/docs/admin-guide/vector-store.md
@@ -0,0 +1,71 @@
+# 向量存储管理
+
+向量存储选项卡控制 Inquire 模式背后的智能,这是 Speakr 的语义搜索功能,允许用户使用自然语言问题在其所有录音中查找信息。在这里,你可以监控和管理将转录文本转换为可搜索知识的嵌入系统。
+
+
+
+## 理解 Inquire 模式
+
+在深入管理之前,了解你在管理什么是有价值的。Inquire 模式将每个转录文本分解为重叠的文本块,将这些块转换为称为嵌入的数学表示,并以可搜索的格式存储它们。当用户提问时,他们的查询会被转换为相同的数学格式,并与所有存储的块进行比较,以找到最相关的信息。
+
+这种方法超越了简单的关键字匹配。系统理解"预算担忧"与"财务约束"和"成本超支"相关,即使确切的词语不同。这种语义理解使 Inquire 模式在发现用户可能无法准确记住的信息时非常强大。
+
+## 嵌入模型
+
+你的 Speakr 实例使用 all-MiniLM-L6-v2 模型,在界面中显著显示。该模型生成 384 维向量——想象每个文本块映射到 384 维空间中的一个点,相似含义聚集在一起。
+
+这个特定模型是经过精心选择的。存在具有更好准确性的更大模型,但它们需要 GPU 和大量计算资源。MiniLM 模型在仅 CPU 的系统上高效运行,使高级搜索无需昂贵的基础设施即可实现。它处理文本速度快、上下文理解好,并生成不会压倒你存储的紧凑嵌入。
+
+该模型最适合英文文本,因为这是其训练数据的主要部分。其他语言可能会以不同的成功率运行,但英文内容始终会产生最可靠的结果。这个限制是模型效率和可访问性的权衡。
+
+## 处理状态概览
+
+状态卡片让你立即了解向量存储的健康状况。总录音数显示你的系统中有多少音频文件,而为 Inquire 处理的数量显示有多少已转换为可搜索的嵌入。这些数字最终应该匹配,尽管随着后台处理赶上通常会存在滞后。
+
+需要处理显示等待嵌入生成的录音。当用户上传新内容时这个数字会增加,随着后台处理器处理队列而减少。持续高数字可能表明处理已停滞或你的系统不堪重负。
+
+总块数显示你的录音已被分解为的细粒度片段。根据转录密度,典型的一小时录音可能会生成 50-60 个块。这种分块确保即使在非常长的录音中也能找到相关片段。
+
+嵌入状态指示器提供快速健康检查。绿色的"可用"表示一切正常运行。其他状态可能表明模型正在加载、处理正在运行或需要注意。
+
+## 处理进度
+
+处理进度条显示通过嵌入队列的实时进展。当达到 100% 时,所有录音都已处理并可搜索。较低的百分比表示正在进行的工作,随着录音完成条形图会填充。
+
+这种视觉反馈帮助你一目了然地了解系统状态。卡住的进度条表明处理已停止。缓慢的进度可能表明系统资源受限。快速的进度显示一切高效运行。
+
+## 管理处理队列
+
+刷新状态按钮更新所有统计信息和进度指示器,用于监控活动处理或验证最近上传的内容已排队。界面不会自动刷新,因此手动刷新确保你看到的是当前信息。
+
+当系统显示录音需要处理但进度没有推进时,可能有几个因素在起作用。后台处理器可能已停止、嵌入模型可能加载失败,或者系统资源可能已耗尽。检查你的日志以获取具体的错误消息。
+
+处理系统被设计为具有弹性。如果特定录音的处理失败,系统会标记它并继续而不是卡住。这些失败会出现在你的日志中,可能需要手动干预来解决。
+
+## 优化性能
+
+处理性能在很大程度上取决于你的系统资源。嵌入模型在加载时需要大约 500MB 的 RAM,再加上处理文本的额外内存。CPU 速度直接影响嵌入生成的速度——现代多核处理器可以同时处理多个录音。
+
+磁盘 I/O 也很重要。系统读取转录文本、处理它们并将嵌入写回数据库。快速存储,特别是 SSD,可以显著提高处理吞吐量。如果你的向量存储与转录文本在不同的磁盘上,请确保两者都有足够的性能。
+
+网络延迟不应该影响处理,因为一切都在本地发生,但数据库性能很重要。定期数据库维护,包括索引优化和清理操作,即使你的向量存储增长也能保持查询快速。
+
+## 常见问题故障排除
+
+当 Inquire 模式在处理过的录音下返回较差的结果时,问题可能是查询表达而非向量存储。鼓励用户问完整的问题而不是输入关键字。"John 关于预算说了什么?"比仅仅"John 预算"效果更好。
+
+如果处理似乎冻结了,请检查 sentence-transformers 库是否正确安装。没有它系统会优雅降级,禁用 Inquire 模式而不是崩溃,但处理不会推进。你的日志会显示嵌入模型是否成功加载。
+
+处理期间的内存错误通常表明你的系统试图同时处理太多内容。分块系统防止单个录音压倒内存,但并行处理多个大录音可能会超出可用 RAM。
+
+## 扩展考虑
+
+向量存储随着你的内容可预测地增长。每个块需要大约 2KB 的存储用于其嵌入和元数据。生成 50 个块的典型一小时录音需要大约 100KB 的嵌入存储。一万小时的录音可能需要 100MB 的嵌入存储——即使在适度的系统上也是可管理的。
+
+得益于高效索引,即使使用大型向量存储,搜索性能仍然保持快速。然而,极其大型的实例(数十万条录音)可能受益于专用向量数据库解决方案,而不是内置的 SQLite 存储。
+
+如果你的实例增长到超出舒适的限制,请考虑归档旧录音。向量存储仅包含活动录音,因此删除过时内容可以同时改善存储和搜索性能。
+
+---
+
+返回[管理员指南概览](index.md) →
diff --git a/speakr/docs/assets/css/style.scss b/speakr/docs/assets/css/style.scss
new file mode 100644
index 0000000..c30a8b4
--- /dev/null
+++ b/speakr/docs/assets/css/style.scss
@@ -0,0 +1,347 @@
+---
+---
+
+@import "{{ site.theme }}";
+
+// Custom colors
+:root {
+ --primary-color: #6366f1;
+ --secondary-color: #8b5cf6;
+ --accent-color: #ec4899;
+ --success-color: #10b981;
+ --warning-color: #f59e0b;
+ --danger-color: #ef4444;
+ --dark-bg: #1f2937;
+ --light-bg: #f9fafb;
+}
+
+// Override theme colors
+.page-header {
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
+ padding: 3rem 1rem;
+}
+
+.site-footer {
+ background: var(--dark-bg);
+ color: #e5e7eb;
+ padding: 2rem 1rem;
+ margin-top: 3rem;
+}
+
+// Logo in header
+.project-logo {
+ width: 60px;
+ height: 60px;
+ margin-bottom: 1rem;
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.project-name {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+}
+
+// Navigation cards
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 1.5rem;
+ margin: 2rem 0;
+}
+
+.card {
+ background: white;
+ border: 1px solid #e5e7eb;
+ border-radius: 12px;
+ padding: 1.5rem;
+ transition: all 0.3s ease;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
+ border-color: var(--primary-color);
+}
+
+.card h3 {
+ color: var(--primary-color);
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ font-size: 1.25rem;
+}
+
+.card p {
+ color: #6b7280;
+ margin-bottom: 1rem;
+ line-height: 1.6;
+}
+
+.card .btn {
+ display: inline-block;
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
+ color: white;
+ padding: 0.5rem 1.25rem;
+ border-radius: 8px;
+ text-decoration: none;
+ font-weight: 500;
+ transition: opacity 0.3s ease;
+}
+
+.card .btn:hover {
+ opacity: 0.9;
+}
+
+// Feature badges
+.badge {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 9999px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ margin: 0 0.25rem;
+}
+
+.badge-new {
+ background: var(--success-color);
+ color: white;
+}
+
+.badge-beta {
+ background: var(--warning-color);
+ color: white;
+}
+
+// Screenshots
+img[src*="screenshots"] {
+ border: 1px solid #e5e7eb;
+ border-radius: 12px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ margin: 1.5rem 0;
+ max-width: 100%;
+ height: auto;
+}
+
+// Code blocks
+pre {
+ background: #1f2937;
+ color: #e5e7eb;
+ border-radius: 8px;
+ padding: 1rem;
+ overflow-x: auto;
+ margin: 1rem 0;
+ border: 1px solid #374151;
+}
+
+code {
+ background: #f3f4f6;
+ padding: 0.125rem 0.375rem;
+ border-radius: 4px;
+ font-size: 0.875em;
+ color: var(--primary-color);
+}
+
+pre code {
+ background: none;
+ padding: 0;
+ color: inherit;
+}
+
+// Tables
+table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ overflow: hidden;
+ margin: 1.5rem 0;
+}
+
+th {
+ background: var(--light-bg);
+ padding: 0.75rem;
+ text-align: left;
+ font-weight: 600;
+ color: #374151;
+ border-bottom: 2px solid #e5e7eb;
+}
+
+td {
+ padding: 0.75rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+tr:last-child td {
+ border-bottom: none;
+}
+
+tr:hover {
+ background: #f9fafb;
+}
+
+// Alerts and callouts
+.note, .warning, .tip {
+ padding: 1rem 1.25rem;
+ border-radius: 8px;
+ margin: 1.5rem 0;
+ border-left: 4px solid;
+}
+
+.note {
+ background: #eff6ff;
+ border-color: var(--primary-color);
+ color: #1e40af;
+}
+
+.warning {
+ background: #fef3c7;
+ border-color: var(--warning-color);
+ color: #92400e;
+}
+
+.tip {
+ background: #d1fae5;
+ border-color: var(--success-color);
+ color: #065f46;
+}
+
+// Sidebar navigation
+.sidebar {
+ background: white;
+ border-radius: 12px;
+ padding: 1.5rem;
+ border: 1px solid #e5e7eb;
+ margin-bottom: 2rem;
+}
+
+.sidebar h3 {
+ color: var(--primary-color);
+ margin-top: 0;
+ margin-bottom: 1rem;
+ font-size: 1.125rem;
+}
+
+.sidebar ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.sidebar li {
+ margin-bottom: 0.5rem;
+}
+
+.sidebar a {
+ color: #6b7280;
+ text-decoration: none;
+ display: block;
+ padding: 0.5rem 0.75rem;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+}
+
+.sidebar a:hover {
+ background: var(--light-bg);
+ color: var(--primary-color);
+}
+
+.sidebar a.active {
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
+ color: white;
+}
+
+// Feature list
+.feature-list {
+ list-style: none;
+ padding: 0;
+}
+
+.feature-list li {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #e5e7eb;
+ display: flex;
+ align-items: flex-start;
+}
+
+.feature-list li:last-child {
+ border-bottom: none;
+}
+
+.feature-list li:before {
+ content: "✓";
+ color: var(--success-color);
+ font-weight: bold;
+ margin-right: 0.75rem;
+ font-size: 1.25rem;
+}
+
+// Responsive design
+@media (max-width: 768px) {
+ .grid {
+ grid-template-columns: 1fr;
+ }
+
+ .page-header {
+ padding: 2rem 1rem;
+ }
+
+ .project-name {
+ font-size: 1.75rem;
+ }
+}
+
+// Smooth scrolling
+html {
+ scroll-behavior: smooth;
+}
+
+// Better typography
+.main-content {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ line-height: 1.7;
+ color: #374151;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-weight: 600;
+ line-height: 1.3;
+ margin-top: 2rem;
+ margin-bottom: 1rem;
+}
+
+h1 {
+ font-size: 2.5rem;
+ color: var(--primary-color);
+}
+
+h2 {
+ font-size: 2rem;
+ color: #1f2937;
+ border-bottom: 2px solid #e5e7eb;
+ padding-bottom: 0.5rem;
+}
+
+h3 {
+ font-size: 1.5rem;
+ color: #374151;
+}
+
+// Animations
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.main-content > * {
+ animation: fadeIn 0.5s ease-out;
+}
\ No newline at end of file
diff --git a/speakr/docs/assets/images/logo.png b/speakr/docs/assets/images/logo.png
new file mode 100644
index 0000000..7def29e
Binary files /dev/null and b/speakr/docs/assets/images/logo.png differ
diff --git a/speakr/docs/assets/images/main2.png b/speakr/docs/assets/images/main2.png
new file mode 100644
index 0000000..5b863a2
Binary files /dev/null and b/speakr/docs/assets/images/main2.png differ
diff --git a/speakr/docs/assets/images/screenshots/Admin dashboard.png b/speakr/docs/assets/images/screenshots/Admin dashboard.png
new file mode 100644
index 0000000..bf44097
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Admin dashboard.png differ
diff --git a/speakr/docs/assets/images/screenshots/Admin default prompts.png b/speakr/docs/assets/images/screenshots/Admin default prompts.png
new file mode 100644
index 0000000..7916359
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Admin default prompts.png differ
diff --git a/speakr/docs/assets/images/screenshots/Admin stats.png b/speakr/docs/assets/images/screenshots/Admin stats.png
new file mode 100644
index 0000000..30ac814
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Admin stats.png differ
diff --git a/speakr/docs/assets/images/screenshots/Admin system settings.png b/speakr/docs/assets/images/screenshots/Admin system settings.png
new file mode 100644
index 0000000..281a156
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Admin system settings.png differ
diff --git a/speakr/docs/assets/images/screenshots/Admin vector store.png b/speakr/docs/assets/images/screenshots/Admin vector store.png
new file mode 100644
index 0000000..dacf6c7
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Admin vector store.png differ
diff --git a/speakr/docs/assets/images/screenshots/Filters.png b/speakr/docs/assets/images/screenshots/Filters.png
new file mode 100644
index 0000000..cc5fa2c
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Filters.png differ
diff --git a/speakr/docs/assets/images/screenshots/Multilingual.png b/speakr/docs/assets/images/screenshots/Multilingual.png
new file mode 100644
index 0000000..15212e0
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Multilingual.png differ
diff --git a/speakr/docs/assets/images/screenshots/Record mic or system audio (for online meetings) and take markdown notes.png b/speakr/docs/assets/images/screenshots/Record mic or system audio (for online meetings) and take markdown notes.png
new file mode 100644
index 0000000..1b72b54
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Record mic or system audio (for online meetings) and take markdown notes.png differ
diff --git a/speakr/docs/assets/images/screenshots/Summary View.png b/speakr/docs/assets/images/screenshots/Summary View.png
new file mode 100644
index 0000000..c19fb6c
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/Summary View.png differ
diff --git a/speakr/docs/assets/images/screenshots/after recording, finalize tags and settings and notes before uploading or discard.png b/speakr/docs/assets/images/screenshots/after recording, finalize tags and settings and notes before uploading or discard.png
new file mode 100644
index 0000000..3bf3279
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/after recording, finalize tags and settings and notes before uploading or discard.png differ
diff --git a/speakr/docs/assets/images/screenshots/chat view with question.png b/speakr/docs/assets/images/screenshots/chat view with question.png
new file mode 100644
index 0000000..748248d
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/chat view with question.png differ
diff --git a/speakr/docs/assets/images/screenshots/edit recording modal.png b/speakr/docs/assets/images/screenshots/edit recording modal.png
new file mode 100644
index 0000000..85bf736
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/edit recording modal.png differ
diff --git a/speakr/docs/assets/images/screenshots/edit recording tags modal.png b/speakr/docs/assets/images/screenshots/edit recording tags modal.png
new file mode 100644
index 0000000..9d22da6
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/edit recording tags modal.png differ
diff --git a/speakr/docs/assets/images/screenshots/edit transcript with ASR.png b/speakr/docs/assets/images/screenshots/edit transcript with ASR.png
new file mode 100644
index 0000000..37cccf9
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/edit transcript with ASR.png differ
diff --git a/speakr/docs/assets/images/screenshots/event extraction.png b/speakr/docs/assets/images/screenshots/event extraction.png
new file mode 100644
index 0000000..1f87f6a
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/event extraction.png differ
diff --git a/speakr/docs/assets/images/screenshots/full recording reprocessing.png b/speakr/docs/assets/images/screenshots/full recording reprocessing.png
new file mode 100644
index 0000000..42f94a6
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/full recording reprocessing.png differ
diff --git a/speakr/docs/assets/images/screenshots/iinquire mode contextual searches.png b/speakr/docs/assets/images/screenshots/iinquire mode contextual searches.png
new file mode 100644
index 0000000..4e5ba34
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/iinquire mode contextual searches.png differ
diff --git a/speakr/docs/assets/images/screenshots/inquire mode 2.png b/speakr/docs/assets/images/screenshots/inquire mode 2.png
new file mode 100644
index 0000000..933413e
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/inquire mode 2.png differ
diff --git a/speakr/docs/assets/images/screenshots/inquire mode.png b/speakr/docs/assets/images/screenshots/inquire mode.png
new file mode 100644
index 0000000..78d8d0c
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/inquire mode.png differ
diff --git a/speakr/docs/assets/images/screenshots/live recording view with notes.png b/speakr/docs/assets/images/screenshots/live recording view with notes.png
new file mode 100644
index 0000000..48a6438
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/live recording view with notes.png differ
diff --git a/speakr/docs/assets/images/screenshots/main view no diarization.png b/speakr/docs/assets/images/screenshots/main view no diarization.png
new file mode 100644
index 0000000..1723ef6
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/main view no diarization.png differ
diff --git a/speakr/docs/assets/images/screenshots/new recording view.png b/speakr/docs/assets/images/screenshots/new recording view.png
new file mode 100644
index 0000000..13b9565
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/new recording view.png differ
diff --git a/speakr/docs/assets/images/screenshots/notes tab.png b/speakr/docs/assets/images/screenshots/notes tab.png
new file mode 100644
index 0000000..b4f5ae0
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/notes tab.png differ
diff --git a/speakr/docs/assets/images/screenshots/record from screen.png b/speakr/docs/assets/images/screenshots/record from screen.png
new file mode 100644
index 0000000..57ec65b
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/record from screen.png differ
diff --git a/speakr/docs/assets/images/screenshots/record from tab.png b/speakr/docs/assets/images/screenshots/record from tab.png
new file mode 100644
index 0000000..a3beb1c
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/record from tab.png differ
diff --git a/speakr/docs/assets/images/screenshots/recording view with simple transcription view and chat visible.png b/speakr/docs/assets/images/screenshots/recording view with simple transcription view and chat visible.png
new file mode 100644
index 0000000..9ed3b39
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/recording view with simple transcription view and chat visible.png differ
diff --git a/speakr/docs/assets/images/screenshots/settings Tags that allow custom prompts stackable and custom asr settings.png b/speakr/docs/assets/images/screenshots/settings Tags that allow custom prompts stackable and custom asr settings.png
new file mode 100644
index 0000000..c13e8ec
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/settings Tags that allow custom prompts stackable and custom asr settings.png differ
diff --git a/speakr/docs/assets/images/screenshots/settings about page.png b/speakr/docs/assets/images/screenshots/settings about page.png
new file mode 100644
index 0000000..df9fc0b
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/settings about page.png differ
diff --git a/speakr/docs/assets/images/screenshots/settings account info.png b/speakr/docs/assets/images/screenshots/settings account info.png
new file mode 100644
index 0000000..7c665a4
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/settings account info.png differ
diff --git a/speakr/docs/assets/images/screenshots/settings custom prompts.png b/speakr/docs/assets/images/screenshots/settings custom prompts.png
new file mode 100644
index 0000000..1b6c8d1
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/settings custom prompts.png differ
diff --git a/speakr/docs/assets/images/screenshots/settings shared transcripts.png b/speakr/docs/assets/images/screenshots/settings shared transcripts.png
new file mode 100644
index 0000000..f55a09b
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/settings shared transcripts.png differ
diff --git a/speakr/docs/assets/images/screenshots/settings speakr management.png b/speakr/docs/assets/images/screenshots/settings speakr management.png
new file mode 100644
index 0000000..d447369
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/settings speakr management.png differ
diff --git a/speakr/docs/assets/images/screenshots/share recording modal.png b/speakr/docs/assets/images/screenshots/share recording modal.png
new file mode 100644
index 0000000..bd075da
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/share recording modal.png differ
diff --git a/speakr/docs/assets/images/screenshots/share recording.png b/speakr/docs/assets/images/screenshots/share recording.png
new file mode 100644
index 0000000..00b3a3e
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/share recording.png differ
diff --git a/speakr/docs/assets/images/screenshots/shared recording.png b/speakr/docs/assets/images/screenshots/shared recording.png
new file mode 100644
index 0000000..81d81fc
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/shared recording.png differ
diff --git a/speakr/docs/assets/images/screenshots/shared transcript modal.png b/speakr/docs/assets/images/screenshots/shared transcript modal.png
new file mode 100644
index 0000000..9340b61
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/shared transcript modal.png differ
diff --git a/speakr/docs/assets/images/screenshots/speaker id modal.png b/speakr/docs/assets/images/screenshots/speaker id modal.png
new file mode 100644
index 0000000..1345e58
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/speaker id modal.png differ
diff --git a/speakr/docs/assets/images/screenshots/summary reprocessing modal.png b/speakr/docs/assets/images/screenshots/summary reprocessing modal.png
new file mode 100644
index 0000000..921f4b0
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/summary reprocessing modal.png differ
diff --git a/speakr/docs/assets/images/screenshots/tag selection and stacking.png b/speakr/docs/assets/images/screenshots/tag selection and stacking.png
new file mode 100644
index 0000000..413e267
Binary files /dev/null and b/speakr/docs/assets/images/screenshots/tag selection and stacking.png differ
diff --git a/speakr/docs/assets/images/speakr-logo.png b/speakr/docs/assets/images/speakr-logo.png
new file mode 100644
index 0000000..7def29e
Binary files /dev/null and b/speakr/docs/assets/images/speakr-logo.png differ
diff --git a/speakr/docs/ci-cd-setup.md b/speakr/docs/ci-cd-setup.md
new file mode 100644
index 0000000..9a9db5f
--- /dev/null
+++ b/speakr/docs/ci-cd-setup.md
@@ -0,0 +1,89 @@
+# Speakr CI/CD 设置
+
+本文档解释了 Speakr 项目的持续集成和持续部署(CI/CD)流水线是如何设置的。
+
+## 自动 Docker 镜像构建与发布
+
+Speakr 项目使用 GitHub Actions 在代码推送到仓库时自动构建 Docker 镜像并发布到 Docker Hub。
+
+### 工作原理
+
+1. 当代码推送到 `master` 分支或创建新标签(格式为 `v*.*.*`)时,GitHub Actions 工作流将被触发。
+2. 工作流使用项目的 Dockerfile 构建 Docker 镜像。
+3. 如果触发原因是推送到 `master` 分支,镜像将被标记为:
+ - `latest`
+ - 提交的短 SHA 值
+ - 分支名称(`master`)
+4. 如果触发原因是新标签(例如 `v1.2.3`),镜像将被标记为:
+ - 完整版本号(`1.2.3`)
+ - 主.次版本号(`1.2`)
+ - 标签名称(`v1.2.3`)
+5. 然后镜像会被推送到你账户下的 Docker Hub。
+
+### 设置步骤
+
+要设置自动 Docker 镜像发布,请按照以下步骤操作:
+
+1. **创建 Docker Hub 账户**(如果你还没有的话):
+ - 访问 [Docker Hub](https://hub.docker.com/) 并注册账户。
+ - 创建一个名为 `speakr` 的仓库。
+
+2. **创建 Docker Hub 访问令牌**:
+ - 登录 Docker Hub。
+ - 点击右上角的用户名,选择"Account Settings"进入账户设置。
+ - 导航到"Security"选项卡。
+ - 点击"New Access Token"。
+ - 给令牌取个名字(例如"GitHub Actions")并设置适当的权限(至少需要"Read & Write")。
+ - 复制生成的令牌(你将无法再次看到它)。
+
+3. **将 Docker Hub 凭据添加到 GitHub Secrets**:
+ - 进入你的 GitHub 仓库。
+ - 点击"Settings" > "Secrets and variables" > "Actions"。
+ - 点击"New repository secret"。
+ - 添加以下密钥:
+ - 名称:`DOCKERHUB_USERNAME`,值:你的 Docker Hub 用户名
+ - 名称:`DOCKERHUB_TOKEN`,值:你在上一步生成的访问令牌
+
+4. **验证工作流**:
+ - 工作流文件位于 `.github/workflows/docker-publish.yml`。
+ - 它已经配置好可以构建并推送 Docker 镜像到 Docker Hub。
+ - 如果需要,你可以进一步自定义它。
+
+5. **触发工作流**:
+ - 推送更改到 `master` 分支或创建新标签来触发工作流。
+ - 进入 GitHub 仓库的"Actions"选项卡来监控工作流进度。
+
+### 使用已发布的 Docker 镜像
+
+镜像发布到 Docker Hub 后,你可以在 docker-compose.yml 文件中使用它们:
+
+```yaml
+version: '3.8'
+
+services:
+ app:
+ image: learnedmachine/speakr:latest
+ container_name: speakr
+ restart: unless-stopped
+ ports:
+ - "8899:8899"
+ volumes:
+ - ./uploads:/data/uploads
+ - ./instance:/data/instance
+ env_file:
+ - .env
+```
+
+### 版本控制策略
+
+要创建新版本的应用程序:
+
+1. 进行更改并提交到仓库。
+2. 按照语义化版本控制创建新标签:
+ ```bash
+ git tag v1.0.0 # 替换为适当的版本号
+ git push origin v1.0.0
+ ```
+3. GitHub Actions 工作流将自动构建并发布带有相应版本标签的新 Docker 镜像。
+
+这样用户可以选择使用最新版本(`learnedmachine/speakr:latest`)或特定版本(`learnedmachine/speakr:1.0.0`)。
diff --git a/speakr/docs/create_docs.py b/speakr/docs/create_docs.py
new file mode 100644
index 0000000..410d346
--- /dev/null
+++ b/speakr/docs/create_docs.py
@@ -0,0 +1,67 @@
+import os
+from pathlib import Path
+
+def create_markdown_doc(base_dir):
+ output = []
+
+ # Add header
+ output.append("# Project Files\n")
+ output.append("Generated documentation of all project files.\n")
+
+ # Function to read and format file content
+ def add_file_content(filepath, relative_path):
+ output.append(f"\n## {relative_path}\n")
+ output.append("```" + get_file_extension(filepath) + "\n")
+ try:
+ with open(filepath, 'r', encoding='utf-8') as f:
+ output.append(f.read())
+ except Exception as e:
+ output.append(f"Error reading file: {e}")
+ output.append("```\n")
+
+ def get_file_extension(filepath):
+ ext = os.path.splitext(filepath)[1][1:].lower()
+ # Map file extensions to markdown code block languages
+ extension_map = {
+ 'py': 'python',
+ 'html': 'html',
+ 'js': 'javascript',
+ 'css': 'css',
+ 'sh': 'bash',
+ 'md': 'markdown',
+ 'txt': 'text'
+ }
+ return extension_map.get(ext, '')
+
+ # List of important file patterns to include
+ patterns = [
+ '*.py',
+ '*.html',
+ '*.js',
+ '*.css',
+ '*.sh',
+ 'requirements.txt'
+ ]
+
+ # Walk through directory and add files
+ for root, _, _ in os.walk(base_dir):
+ for pattern in patterns:
+ for filepath in Path(root).glob(pattern):
+ if 'venv' not in str(filepath) and '__pycache__' not in str(filepath):
+ relative_path = os.path.relpath(filepath, base_dir)
+ add_file_content(filepath, relative_path)
+
+ # Write to output file
+ output_path = os.path.join(base_dir, 'project_files.md')
+ with open(output_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(output))
+
+ return output_path
+
+if __name__ == "__main__":
+ # Get the current directory
+ current_dir = os.getcwd()
+
+ # Create the markdown file
+ output_file = create_markdown_doc(current_dir)
+ print(f"Created markdown documentation at: {output_file}")
\ No newline at end of file
diff --git a/speakr/docs/faq.md b/speakr/docs/faq.md
new file mode 100644
index 0000000..7630018
--- /dev/null
+++ b/speakr/docs/faq.md
@@ -0,0 +1,189 @@
+# 常见问题
+
+这些是人们开始使用 Speakr 时最常遇到的问题。这里的答案将为你节省时间,并帮助你了解如何充分利用该平台。
+
+## 一般问题
+
+### Speakr 究竟是什么?
+
+Speakr 是一个自托管的 Web 应用程序,可将你的音频录音转化为有组织、可搜索且智能的笔记。它将[转录](features.md#core-transcription-features)、[AI 摘要](features.md#automatic-summarization)、[说话人识别](features.md#speaker-diarization)和[语义搜索](user-guide/inquire-mode.md)整合到一个你完全掌控的单一平台中。如果你自托管了像 Whisper 端点或推荐的 ASR 服务这样的 ASR 模型,以及用于 LLM 的 OpenAI 兼容 API,你的数据永远不会离开你的基础设施,从而为你提供完整的隐私和控制权。
+
+### Speakr 与其他转录服务有何不同?
+
+关键区别在于自托管——你在自己的服务器上运行 Speakr,完全掌控自己的数据。除了隐私保护之外,Speakr 还将转录与 AI 驱动的功能(如[智能摘要](features.md#automatic-summarization)、与录音的[交互式聊天](user-guide/transcripts.md)以及跨所有内容的[语义搜索](user-guide/inquire-mode.md))进行了集成。它不仅仅将语音转换为文本,还让这些文本变得有用且易于访问。
+
+### Speakr 支持哪些音频格式?
+
+Speakr 支持大多数常见音频格式,包括 MP3、WAV、M4A、OGG、FLAC 等。系统在内部使用 FFmpeg 来处理音频,因此基本上任何 FFmpeg 支持的格式都能正常工作。包含音轨的视频文件也受到支持——Speakr 会提取并处理其音频部分。
+
+### 多人可以使用同一个 Speakr 实例吗?
+
+可以,Speakr 被设计为一个多用户系统。每个用户都有自己的账户,拥有独立的录音、设置和说话人库。管理员可以[创建和管理用户账户](admin-guide/user-management.md)。请参阅[系统统计](admin-guide/statistics.md)以监控使用情况、监控使用情况以及配置系统范围的设置。除非通过[分享链接](user-guide/sharing.md)明确共享,否则用户无法看到彼此的录音。了解[分享安全性](user-guide/sharing.md#security-and-privacy-considerations)。
+
+## 安装与设置
+
+### 最低系统要求是什么?
+
+Speakr 可以在适度的硬件上舒适运行。你至少需要 2GB RAM,但推荐使用 4GB 以获得更好的性能。CPU 需求取决于你的使用情况——双核处理器可以应对单用户实例,而繁忙的多用户安装则受益于更多核心。存储需求取决于你的录音量,但请至少预留 20GB 可用空间用于应用程序和初始录音。
+
+### 安装 Speakr 需要了解 Docker 吗?
+
+基本的 Docker 知识会有帮助,但并非必需。[快速入门指南](getting-started.md)提供了可以复制和运行的确切命令。对于生产部署,请参阅[安装指南](getting-started/installation.md)。你需要在服务器上安装 Docker 和 Docker Compose,创建包含 API 密钥的配置文件,然后运行一条命令启动所有内容。通常最难的部分是从 OpenAI 或 OpenRouter 获取 API 密钥。
+
+### 我可以在树莓派上运行 Speakr 吗?
+
+可以,Speakr 可以在配备至少 4GB RAM 的树莓派 4 或更新版本上运行。性能无法与完整服务器相比,尤其是在转录处理方面,但对于个人使用来说完全足够。ARM 兼容的 Docker 镜像开箱即用。只需对大录音的较长处理时间保持耐心即可。
+
+### 我可以在 Mac 上使用 ASR webservice 进行说话人分离吗?
+
+提供[说话人分离](features.md#speaker-diarization)功能的可选 ASR webservice(`onerahmet/openai-whisper-asr-webservice`)在 macOS 上有特定要求:
+
+**GPU 限制**:GPU 直通在 macOS 上无法工作,因为 Docker 在 Linux 虚拟机内运行容器。这是 Mac 上 Docker 的根本限制。
+
+**解决方案**:使用标准 CPU 镜像而非 GPU 版本:
+- 使用 `onerahmet/openai-whisper-asr-webservice:latest`(而非 `:latest-gpu`)
+- `:latest` 标签同时提供 amd64(Intel)和 arm64(Apple Silicon)架构
+- 没有 GPU 加速时处理速度会变慢,但功能完全正常
+
+Mac 的配置示例:
+```bash
+docker run -d -p 9000:9000 \
+ -e ASR_MODEL=base \
+ -e ASR_ENGINE=whisperx \
+ -e HF_TOKEN=your_huggingface_token \
+ onerahmet/openai-whisper-asr-webservice:latest
+```
+
+注意:如果你不需要在转录中进行说话人识别,可以直接使用 Speakr 配合标准 Whisper API,这不需要额外的容器。
+
+### 如何备份我的 Speakr 数据?
+
+你的 Speakr 数据由三个基本组件组成:`instance/` 目录中的 SQLite 数据库、`uploads/` 目录中的音频文件和转录内容,以及 `.env` 文件中的配置。要创建完整备份,请先停止容器以确保数据库一致性,然后备份所有三个目录:
+
+```bash
+docker compose down
+tar czf speakr_backup_$(date +%Y%m%d).tar.gz uploads/ instance/ .env
+docker compose up -d
+```
+
+强烈建议在生产环境中定期进行自动化备份。
+
+## 转录与 AI 功能
+
+### 转录的准确度如何?
+
+转录准确度取决于多个因素——音频质量、说话人清晰度、背景噪音和专业词汇。请参阅[故障排除指南](troubleshooting.md#poor-transcription-quality)获取建议。为专业词汇配置[自定义提示词](admin-guide/prompts.md)。在音频质量良好的情况下,清晰的英语语音预计可达到 90-95% 的准确度。在口音较重、多人重叠说话或录音质量差的情况下,准确度会下降。配备说话人分离功能的 ASR 端点通常能提供更好的实际可用性,即使原始准确度相近。
+
+### Whisper API 和 ASR 端点有什么区别?
+
+Whisper API 提供基本转录功能——将语音转换为文本,但不包含说话人识别。[推荐的 ASR 容器](getting-started.md#option-b-custom-asr-endpoint-configuration)(`onerahmet/openai-whisper-asr-webservice`)提供高级功能,如[说话人分离](features.md#speaker-diarization),可以识别并标记对话中的不同说话人。了解转录后如何[管理说话人](user-guide/transcripts.md#speaker-identification)。对于有多位参与者的会议,说话人分离是必不可少的,而对于单人录音(如口述或播客),Whisper API 就足够了。
+
+**关于 ASR 引擎的说明**:为了让说话人分离在 ASR webservice 中正常工作,你必须使用 `ASR_ENGINE=whisperx`,而不是 `faster_whisper`。虽然 faster_whisper 提供转录功能,但它不支持说话人识别。
+
+### Speakr 可以转录英语以外的语言吗?
+
+可以,Speakr 通过其转录服务支持多种语言。Whisper 模型可以处理数十种语言,准确度各不相同——西班牙语、法语、德语和中文等主要语言效果良好,而不太常见的语言准确度可能会降低。你可以在[账户设置](user-guide/settings.md#language-preferences)中设置首选语言,或将其留空以自动检测。请参阅[语言支持详情](features.md#language-support)。
+
+**中文转录的重要提示**:使用 ASR 端点进行中文音频转录时,请避免使用 distil 模型(如 distil-large-v3),因为它们可能会将中文错误识别为英文。请使用完整 large-v3 模型或类似的非蒸馏模型以获得准确的中文转录。
+
+### 我的录音可以有多长?
+
+录音长度没有硬性限制,但需要考虑实际因素。非常长的录音(超过 2-3 小时)处理时间更长,消耗更多 API 额度,并且可能导致界面响应变慢。文件上传限制默认为 300MB,足以容纳数小时的压缩音频。对于全天候研讨会等超长内容,建议将其拆分为逻辑段落。
+
+### 什么 AI 模型生成摘要?
+
+摘要生成使用在[环境配置文件](getting-started.md#step-3-configure-your-transcription-service)中配置的语言模型。通过 [AI 提示词](admin-guide/prompts.md)自定义摘要——可以通过本地 LLM 端点或 OpenAI、OpenRouter 等云服务提供商。模型选择会影响[摘要质量](features.md#automatic-summarization)、成本和处理速度。在[系统统计](admin-guide/statistics.md)中监控性能。
+
+## 隐私与安全
+
+### 我的数据真的私密吗?
+
+正确自托管时,你的音频和转录内容永远不会离开你的服务器。但是,转录和摘要 API(OpenAI、OpenRouter)确实会在他们的服务器上处理你的内容。要实现完全隐私,你需要对转录和摘要都使用本地模型,这需要大量的计算资源。
+
+### 我可以将 Speakr 用于机密商务会议吗?
+
+可以,但需要采取适当的预防措施。自托管让数据处于你的控制之下,但请考虑你的 API 提供商的数据政策。OpenAI 和 OpenRouter 有不同的数据保留和使用政策。为了获得最高安全性,请使用本地转录和摘要模型,但这需要强大的硬件和技术专长。
+
+### 分享链接安全吗?
+
+[分享链接](user-guide/sharing.md)使用密码学安全的随机令牌,无法被猜测。你可以从[分享仪表板](user-guide/sharing.md#managing-your-shared-recordings)管理已分享的录音。但是,任何拥有链接的人都可以在未经身份验证的情况下访问共享内容。请将分享链接视为密码——仅通过安全渠道发送它们,并在不再需要时撤销访问权限。对于敏感内容,请考虑需要身份验证的替代分享方式。
+
+### 谁可以看到我的录音?
+
+默认情况下,只有你可以看到自己的录音。[管理员用户](admin-guide/user-management.md)无法通过界面直接查看其他用户的录音,尽管他们可以监控[使用模式](admin-guide/statistics.md),但理论上他们可以访问数据库。已分享的录音对拥有分享链接的任何人可访问。同一 Speakr 实例上的其他用户无法看到你的录音,除非你明确分享。
+
+## 功能与特性
+
+### 什么是 Inquire Mode?
+
+[Inquire Mode](user-guide/inquire-mode.md) 是 Speakr 的语义搜索功能,让你可以使用自然语言问题在所有录音中查找信息。必须配置[向量存储](admin-guide/vector-store.md)才能使其正常工作。与其搜索精确的关键词,你可以提出诸如"我们关于营销预算做了什么决定?"这样的问题,并获得讨论过该主题的任何录音的相关摘录。它使用 AI 嵌入来理解含义和上下文。
+
+### 说话人档案如何工作?
+
+当你在转录中[识别说话人](user-guide/transcripts.md#speaker-identification)时,通过点击通用标签(SPEAKER_01 等)并分配名称,Speakr 会将这些保存为说话人档案。你可以在[账户设置](user-guide/settings.md#speakers-management-tab)中管理它们。在未来的更新中,我们打算添加功能,允许录音根据声音特征自动建议说话人身份。随着时间的推移,你会建立一个已识别说话人的库,使多人转录变得更加有用。
+
+### 转录生成后可以编辑吗?
+
+可以,转录内容完全可编辑。点击任何转录上方的"编辑"按钮进行修改。请参阅[转录指南](user-guide/transcripts.md#editing-transcriptions)了解编辑选项。这对于修正识别错误的专业术语、专有名词或更正说话人分配特别有用。你的编辑会被保留——如果你重新生成摘要或使用[聊天功能](user-guide/transcripts.md),它们不会丢失。可以使用[各种格式](user-guide/transcripts.md)导出已编辑的转录。
+
+### 有哪些导出格式可用?
+
+Speakr 可以以多种格式导出录音。你可以直接将转录内容复制到剪贴板,以便粘贴到其他应用程序中。了解[导出选项](features.md#export-options)和[分享](user-guide/sharing.md)。可以下载完整的录音为 Word 文档(.docx),包括转录、摘要和笔记。[分享链接](user-guide/sharing.md)提供只读的 Web 访问。你可以在[分享设置](user-guide/sharing.md#creating-a-share-link)中配置可见内容。聊天历史记录也可以导出用于文档目的。
+
+## 故障排除
+
+### 为什么转录需要这么长时间?
+
+多个因素会影响转录速度——文件大小、API 服务负载、网络速度和模型选择。大文件自然需要更长时间。API 服务在高峰使用时段可能会变慢。慢速互联网连接会在上传音频时造成瓶颈。使用更大、更准确的模型(如 Whisper Large)比使用较小模型耗时更长。
+
+### 我的录音卡在"待处理"状态
+
+这通常意味着后台处理器已停止或遇到错误。请检查 Docker 日志中的错误消息。请参阅[故障排除指南](troubleshooting.md#transcription-never-starts)了解详情。在[向量存储](admin-guide/vector-store.md)中监控处理进度。常见原因包括 API 密钥无效、API 配额超限或网络连接问题。重启容器通常可以解决临时问题。请检查你的 API 提供商仪表板,了解使用限制或账单问题。
+
+### 为什么所有说话人都显示为"UNKNOWN_SPEAKER"?
+
+这是说话人分离配置不正确时的常见问题。以下是修复方法:
+
+1. **检查 ASR_ENGINE**:确保你在 ASR 容器中使用的是 `ASR_ENGINE=whisperx`,而不是 `faster_whisper`
+2. **验证 ASR_DIARIZE**:虽然在 `USE_ASR_ENDPOINT=true` 时默认设置为 `true`,但请在 .env 文件中显式设置 `ASR_DIARIZE=true`
+3. **HuggingFace Token**:ASR 容器需要有效的 `HF_TOKEN` 环境变量来下载说话人分离模型
+4. **Docker 网络**:如果容器在同一个 docker-compose 中,请使用容器名称(例如 `http://whisper-asr:9000`),而不是 localhost 或外部 IP
+5. **检查日志**:在 ASR 容器日志中查找 pyannote/VAD 消息以确认说话人分离已激活
+
+ASR 服务应在转录中返回类似 "SPEAKER_00"、"SPEAKER_01" 的说话人标签。然后你可以[识别这些说话人](user-guide/transcripts.md#speaker-identification)并赋予真实姓名。
+
+### 为什么我无法分享录音?
+
+[分享](user-guide/sharing.md)需要你的 Speakr 实例可以从互联网通过 HTTPS/SSL 加密访问。请检查[分享要求](user-guide/sharing.md#requirements-for-sharing)和[故障排除](troubleshooting.md#sharing-links-dont-work)。本地安装或未配置 HTTPS 的环境无法生成可用的分享链接。当不满足这些要求时,系统会禁用分享功能。要启用分享功能,请在具有域名和 SSL 证书的公共服务器上部署 Speakr。
+
+### 大转录时界面响应缓慢
+
+浏览器在显示大量文本时会遇到困难,尤其是在带有说话人分离的气泡视图中。对于超过 2 小时的录音,请考虑使用简单视图而非气泡视图。如果性能随时间下降,请清除浏览器缓存。将超长录音拆分为多个段落可以提高性能和可用性。
+
+## 最佳实践
+
+### 我应该如何组织我的录音?
+
+尽早开发一个一致的[标签系统](user-guide/settings.md#tag-management-tab)。为不同的项目、会议类型或客户创建标签。标签可以包含用于专门处理的[自定义提示词](admin-guide/prompts.md)。使用描述性标题,帮助你在数月后找到录音。在录音后立即添加笔记,此时上下文仍然清晰。定期维护——归档旧录音和清理测试文件——让你的录音库保持易于管理的状态。
+
+### 我需要告知他人他们正在被录音吗?
+
+法律要求因司法管辖区而异。许多地区要求所有被录制方的明确同意。Speakr 包含可配置的[录音免责声明](admin-guide/system-settings.md#recording-disclaimer)功能。请参阅[合规性考虑](troubleshooting.md#recording-disclaimer-for-legal-compliance)。设置适当的法律文本,在录音开始前显示。请咨询当地法律以确保合规——这在欧盟、加州或澳大利亚等有严格录音法律的地区尤为重要。
+
+### 什么音频质量最适合转录?
+
+尽可能在安静的环境中录音。使用靠近说话人的优质麦克风。对于会议,请将录音设备放置在所有参与者都能清晰听到的中心位置。避免背景音乐或电视噪音。高质量的音频不仅能提高转录准确度,还能减少处理时间和 API 成本。
+
+### 如何最大化转录准确度?
+
+说话清晰,避免互相打断。尽量减少背景噪音和回声。对于技术内容,请考虑在你的[提示词](admin-guide/prompts.md)中添加自定义词汇表或术语表。用户可以为其录音设置[个人提示词](user-guide/settings.md#custom-prompts-tab)。请使用适当的[语言设置](user-guide/settings.md#language-preferences),而不是依赖自动检测。请参阅[语言支持](features.md#language-support)以获得最佳效果。对于多人录音,请使用 [ASR 端点](getting-started.md#option-b-custom-asr-endpoint-configuration)并设置适当的说话人数量。转录后[识别说话人](user-guide/transcripts.md#speaker-identification)以获得最佳效果。
+
+对于中文转录,请使用 large-v3 模型,因为较小的模型可能无法正确输出中文字符。对于其他语言,请测试不同模型,为你的特定语言和口音找到最佳准确度。
+
+### 按大小分块和按持续时间分块有什么区别?
+
+按文件大小分块(如 CHUNK_LIMIT=20MB)适用于比特率一致的音频。当转录服务有时间限制时(如 Azure 的 1500 秒上限),按持续时间分块(如 CHUNK_LIMIT=1400s)更为合适。基于持续时间的分块确保无论文件压缩或质量如何,都不会有任何分块超过时间限制。
+
+---
+
+返回 [首页](index.md) →
\ No newline at end of file
diff --git a/speakr/docs/favicon.ico b/speakr/docs/favicon.ico
new file mode 100644
index 0000000..edf51c5
Binary files /dev/null and b/speakr/docs/favicon.ico differ
diff --git a/speakr/docs/features.md b/speakr/docs/features.md
new file mode 100644
index 0000000..99ffa2a
--- /dev/null
+++ b/speakr/docs/features.md
@@ -0,0 +1,109 @@
+# 功能特性
+
+Speakr 将强大的转录能力与智能 AI 功能相结合,将您的音频录音转化为有价值的、可操作的知识。每个功能的设计都旨在节省时间并从您的语音内容中提取最大价值。
+
+## 核心转录功能
+
+### 多引擎支持
+
+Speakr 支持多种转录引擎,以满足您的需求和预算。使用 [OpenAI 的 Whisper API](getting-started.md#option-a-openai-whisper-configuration) 进行快速、基于云的转录,并具有出色的准确率。部署 [推荐的 ASR 容器](getting-started.md#option-b-custom-asr-endpoint-configuration) 以实现说话人分离和本地处理等高级功能。有关详细设置说明,请参阅[安装指南](getting-started/installation.md)。系统会自动处理不同的音频格式,并根据需要进行转换以获得最佳转录质量。
+
+### 说话人分离
+
+使用 [ASR 端点](getting-started.md#option-b-custom-asr-endpoint-configuration) 时,Speakr 会自动识别录音中的不同说话人。如果遇到问题,请查看[故障排除指南](troubleshooting.md#speaker-identification-not-working)。每个说话人都会获得一个唯一标签,您可以稍后使用真实姓名进行自定义。系统会记住这些说话人档案,构建一个随时间推移不断提高识别准确率的库。在[账户设置](user-guide/settings.md)中管理您的说话人库。此功能将多人会议从大段文本转换为有组织的对话。
+
+### 语言支持
+
+使用自动检测或手动选择,转录数十种语言的内容。英语、西班牙语、法语、德语和中文等主要语言获得出色支持,准确率高。系统能优雅地处理多语言内容,在同一次录音中根据需要切换语言。
+
+## AI 驱动的智能功能
+
+### 自动摘要
+
+每条录音都会收到一个 AI 生成的摘要,捕捉关键点、决策和行动项。通过[自定义提示词](admin-guide/prompts.md)进行配置。用户还可以为他们的录音设置[个人提示词](user-guide/settings.md#custom-prompts-tab)。摘要会根据您的内容类型进行调整——技术会议获得详细的技术摘要,而非正式对话则获得更轻松的概述。自定义提示词让您可以根据特定需求调整摘要。
+
+### 事件提取
+
+在摘要处理过程中,Speakr 可以自动从录音中提取值得添加到日历的事件。在账户设置中启用后,系统会识别会议、截止日期、预约和其他时间敏感项目的提及。每个检测到的事件都可以导出为与任何日历应用程序兼容的 ICS 文件。该功能能智能解析相对日期引用,并在未提及具体时间时提供合理的默认值。在工作流程中了解更多关于[使用事件提取](user-guide/transcripts.md#event-extraction)的信息。
+
+### 交互式对话
+
+通过集成的对话功能,将静态转录稿转换为动态对话。了解如何在[转录稿指南](user-guide/transcripts.md)中有效使用 AI 对话。针对您的录音提问,并基于实际内容获得智能回答。请求自定义摘要、提取特定信息,或生成衍生内容如电子邮件或报告。AI 在整个对话过程中保持上下文,支持复杂的多轮交互。
+
+### 语义搜索(Inquire 模式)
+
+通过 [Inquire 模式](user-guide/inquire-mode.md) 使用自然语言问题而非关键词搜索您的所有录音。必须配置 [向量存储](admin-guide/vector-store.md) 此功能才能工作。语义搜索理解含义和上下文,即使确切的词语不匹配也能找到相关内容。提出诸如"我们什么时候讨论过预算增加?"这样的问题,即可从任何涉及该主题的录音中获取结果,无论使用了什么特定术语。
+
+## 组织与管理
+
+### 标签系统
+
+使用灵活的[标签系统](user-guide/settings.md#tag-management-tab)组织录音,超越简单的标签。标签可以包含用于专门处理的[自定义 AI 提示词](admin-guide/prompts.md)。每个标签可以携带[自定义 AI 提示词](admin-guide/prompts.md)和转录设置,根据内容类型启用自动的专业处理。标签使用颜色进行视觉组织,当同一录音应用多个标签时智能堆叠。
+
+### 说话人管理
+
+构建和维护跨录音持久的说话人档案库。每次转录后[识别说话人](user-guide/transcripts.md#speaker-identification)以构建您的库。识别后,系统会在未来的录音中记住并建议这些说话人。系统跟踪使用统计信息,显示每个说话人出现的频率以及最后一次被识别的时间。批量管理工具有助于维护干净、有序的说话人库。
+
+### 自定义提示词
+
+通过多个层级的[自定义提示词](admin-guide/prompts.md)塑造 AI 行为。了解[提示词层级](admin-guide/prompts.md#understanding-prompt-hierarchy)以进行有效配置。个人提示词适用于您的所有录音,而特定标签的提示词会为特定内容类型激活。层级系统确保正确的提示词应用于每条录音,当多个提示词相关时智能堆叠。
+
+## 分享与协作
+
+### 安全分享链接
+
+生成加密安全链接以[分享录音](user-guide/sharing.md)给您 Speakr 实例之外的用户。注意[分享要求](user-guide/sharing.md#requirements-for-sharing)。精确控制接收者看到的内容——根据需要包含或排除摘要和笔记。分享链接可在任何设备上使用,无需账户或身份验证。
+
+### 导出选项
+
+以多种格式导出录音以满足不同用途。生成包含完整转录稿、摘要和笔记的 Word 文档用于正式文档。复制格式化文本到剪贴板以便快速分享。导出单个组件如摘要或笔记用于定向分发。使用[可自定义模板](user-guide/transcript-templates.md)下载转录稿,为您的内容格式化为不同用例,从字幕到采访格式。
+
+### 实时监控
+
+从中央仪表板跟踪所有已分享的录音。查看已分享的内容、时间以及权限。修改分享设置而无需生成新链接,或在分享不再合适时立即撤销访问权限。
+
+## 高级功能
+
+### 音频分块
+
+通过智能分块处理超出 API 限制的大型音频文件。查看[故障排除指南](troubleshooting.md#files-over-25mb-fail-with-openai)获取配置详情。在 FAQ 中了解[分块策略](faq.md#whats-the-difference-between-chunking-by-size-vs-duration)。系统自动将长录音分割为可管理的片段,分别处理,然后无缝组装结果。按持续时间或文件大小配置分块大小,以匹配您的 API 提供商的要求。
+
+### 黑洞处理
+
+设置一个监视目录,放入其中的音频文件会被自动处理。在[系统设置](admin-guide/system-settings.md)中配置此功能以实现自动化工作流。非常适合自动化工作流或批量处理,黑洞目录会监视新文件并将其排队进行转录,无需手动干预。
+
+### 自定义 ASR 配置
+
+使用自定义 ASR 设置针对特定场景微调转录。查看 [ASR 配置](troubleshooting.md#asr-endpoint-returns-405-or-404-errors) 获取常见设置问题。为不同会议类型设置预期说话人数量。为技术词汇或特定口音配置专用模型。通过标签系统自动应用这些设置。
+
+## 用户体验
+
+### 渐进式 Web 应用
+
+将 Speakr 安装为渐进式 Web 应用,在任何设备上获得原生般的体验。PWA 在离线状态下可查看现有录音,并在恢复连接时同步。移动优化的界面确保在手机和平板电脑上流畅运行。
+
+### 深色模式
+
+通过影响每个界面元素的完整深色模式实现,减少眼睛疲劳。主题偏好在会话和设备之间持久保存,每次登录时自动应用您的选择。
+
+### 响应式设计
+
+从任何设备访问 Speakr,界面会自动适应屏幕尺寸。桌面用户获得完整的三面板布局,可同时访问录音、转录稿和对话。移动用户获得为触摸交互和小屏幕优化的简化界面。
+
+## 管理控制
+
+### 多用户支持
+
+为整个团队运行单个 Speakr 实例,具有隔离的用户空间。查看[用户管理](admin-guide/user-management.md)了解详情。[FAQ](faq.md#can-multiple-people-use-the-same-speakr-instance) 解释了多用户架构。每个用户维护自己的录音、设置和说话人库。管理员管理用户、监控使用情况并配置系统级设置,而无需访问个人录音。
+
+### 系统监控
+
+通过全面的统计信息和指标跟踪系统健康状况。监控转录队列、存储使用情况和处理性能。识别瓶颈并根据实际使用模式优化配置。
+
+### 灵活配置
+
+通过[环境变量](getting-started.md#step-3-configure-your-transcription-service)和[管理员设置](admin-guide/index.md)配置 Speakr 的各个方面。检查[系统设置](admin-guide/system-settings.md)获取全局配置选项。设置 API 端点、调整处理限制、启用或禁用功能,以及自定义用户体验。系统会适应您的基础设施和需求。
+
+---
+
+返回 [首页](index.md) →
diff --git a/speakr/docs/getting-started.md b/speakr/docs/getting-started.md
new file mode 100644
index 0000000..64083df
--- /dev/null
+++ b/speakr/docs/getting-started.md
@@ -0,0 +1,188 @@
+# 快速入门指南
+
+只需几分钟即可使用预构建的 Docker 镜像运行 Speakr!本指南将带你了解部署 Speakr 的最快方法,支持使用 OpenAI Whisper API 或[自定义 ASR 端点](features.md#speaker-diarization)。
+
+> **注意:** 如果你想使用 ASR 端点选项来实现说话人分离功能,你需要运行一个额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)。请参阅[运行 ASR 服务以支持说话人分离](getting-started/installation.md#running-asr-service-for-speaker-diarization)获取详细的设置说明。
+
+## 前置条件
+
+开始之前,请确保你的系统上已安装 Docker 和 Docker Compose。你还需要一个 API 密钥(OpenAI 或 OpenRouter,或兼容服务),至少 2GB 可用内存,以及约 10GB 可用磁盘空间用于存储录音和转录内容。
+
+## 步骤 1:创建项目目录
+
+首先,为 Speakr 安装创建一个目录并进入:
+
+```bash
+mkdir speakr
+cd speakr
+```
+
+## 步骤 2:下载配置文件
+
+下载 Docker Compose 配置,并根据你选择的转录服务选择合适的环境模板:
+
+```bash
+# Download docker compose example
+wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/docker-compose.example.yml -O docker-compose.yml
+```
+
+现在下载环境配置模板。根据你要使用的转录服务,你有两种选择。
+
+使用标准 OpenAI Whisper API(推荐大多数用户使用):
+```bash
+wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.whisper.example -O .env
+```
+
+或使用带说话人分离功能的自定义 ASR 端点(需要额外的 ASR 容器——见下方说明):
+```bash
+wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.asr.example -O .env
+```
+
+> **重要:** ASR 端点选项需要与 Speakr 一起运行一个额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)。有关完整的设置说明,包括两个容器的 docker-compose 配置,请参阅[运行 ASR 服务以支持说话人分离](getting-started/installation.md#running-asr-service-for-speaker-diarization)。
+
+## 步骤 3:配置你的转录服务
+
+在你喜欢的文本编辑器中打开 `.env` 文件,并根据你选择的服务进行配置。
+
+### 选项 A:OpenAI Whisper 配置
+
+如果你使用 OpenAI Whisper,你需要同时设置转录服务和文本生成模型。文本生成模型用于创建摘要、生成标题和驱动聊天功能。
+
+编辑你的 `.env` 文件并更新以下关键变量:
+
+```bash
+# For text generation (summaries, chat, titles)
+TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
+TEXT_MODEL_API_KEY=your_openrouter_api_key_here
+TEXT_MODEL_NAME=openai/gpt-4o-mini
+
+# For transcription
+TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
+TRANSCRIPTION_API_KEY=your_openai_api_key_here
+WHISPER_MODEL=whisper-1
+```
+
+文本模型可以使用 OpenRouter 来访问各种 AI 模型,或者你可以将其直接指向 OpenAI,使用与转录服务相同的基础 URL 和 API 密钥。OpenRouter 提供对多种模型的访问,包括 GPT-4、Claude 等,对于文本生成任务来说可能更具成本效益。
+
+### 选项 B:自定义 ASR 端点配置
+
+> **前置条件:** 此选项需要运行一个额外的 ASR 服务容器(`onerahmet/openai-whisper-asr-webservice`)。你可以:
+>
+> - 在同一个 Docker Compose 栈中运行两个容器(推荐)——请参阅[完整设置指南](getting-started/installation.md#running-asr-service-for-speaker-diarization)
+> - 在同一台或不同的机器上单独运行 ASR 服务
+> - 如果你已经部署了现有的 ASR 服务,可以直接使用
+
+如果你使用的是自定义 ASR 服务(如 WhisperX 或自托管的 Whisper 服务器),请配置以下变量:
+
+```bash
+# For text generation (summaries, chat, titles)
+TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
+TEXT_MODEL_API_KEY=your_openrouter_api_key_here
+TEXT_MODEL_NAME=openai/gpt-4o-mini
+
+# Enable ASR endpoint
+USE_ASR_ENDPOINT=true
+
+# ASR service URL (use container name if in same docker compose)
+ASR_BASE_URL=http://whisper-asr:9000
+```
+
+当使用 ASR 端点时,[说话人分离](features.md#speaker-diarization)功能会自动启用,使 Speakr 能够识别录音中的不同说话人。转录完成后,你需要[识别说话人](user-guide/transcripts.md#speaker-identification)来构建你的说话人库。ASR_BASE_URL 应指向你的 ASR 服务。如果你在同一个 Docker Compose 栈中运行 ASR 服务,请使用容器名称和内部端口(如 `http://whisper-asr:9000`)。对于外部服务,请使用带有适当 IP 地址或域名的完整 URL。
+
+## 步骤 4:配置管理员账户
+
+Speakr 会在首次启动时自动创建一个管理员用户。在启动之前,请在 `.env` 文件中配置这些凭据:
+
+```bash
+ADMIN_USERNAME=admin
+ADMIN_EMAIL=admin@example.com
+ADMIN_PASSWORD=changeme
+```
+
+确保将这些值更改为更安全的值,尤其是密码。当你首次启动 Speakr 时,该管理员账户将自动创建,你将使用这些凭据进行登录。通过此方法创建的第一个用户将成为系统管理员,拥有对所有功能的完全访问权限,包括用户管理和系统设置。
+
+## 步骤 5:启动 Speakr
+
+配置完成后,使用 Docker Compose 启动 Speakr:
+
+```bash
+docker compose up -d
+```
+
+首次启动需要几分钟时间,因为 Docker 需要下载预构建镜像(约 3GB)并初始化数据库。你可以通过查看日志来监控启动过程:
+
+```bash
+docker compose logs -f app
+```
+
+查找表示 Flask 应用程序正在运行并已准备好接受连接的消息。按 Ctrl+C 退出日志视图(这不会停止容器)。
+
+## 步骤 6:访问 Speakr
+
+容器运行后,打开你的 Web 浏览器并访问:
+
+```
+http://localhost:8899
+```
+
+使用你在步骤 4 中配置的管理员凭据登录。现在你应该能看到 Speakr 仪表盘,已准备好进行你的第一次录音。
+
+## 你的第一次录音
+
+登录后,你可以立即开始使用 Speakr。点击顶部导航栏中的"New Recording"按钮,上传现有音频文件或开始[现场录音](user-guide/recording.md)。有关详细说明,请参阅[录音指南](user-guide/recording.md)。对于上传文件,Speakr 支持[常见音频格式](faq.md#what-audio-formats-does-speakr-support),如 MP3、M4A、WAV 等,默认文件大小限制为 500MB。你可以在[系统设置](admin-guide/system-settings.md)中调整此限制。对于现场录音,你可以从麦克风、系统音频或同时进行录制。
+
+## 可选功能
+
+### 启用 Inquire 模式
+
+[Inquire 模式](user-guide/inquire-mode.md)允许你使用自然语言问题在所有录音中进行搜索。在功能指南中了解更多关于[语义搜索功能](features.md#semantic-search-inquire-mode)的信息。要启用它,请在 `.env` 文件中设置:
+
+```bash
+ENABLE_INQUIRE_MODE=true
+```
+
+然后使用 `docker compose restart` 重启容器以使更改生效。
+
+### 启用用户注册
+
+默认情况下,只有管理员可以创建新用户。在管理员指南中了解更多关于[用户管理](admin-guide/user-management.md)的信息。要允许自行注册,请设置:
+
+```bash
+ALLOW_REGISTRATION=true
+```
+
+### 配置你的时区
+
+设置本地时区以显示准确的时间戳:
+
+```bash
+TIMEZONE="America/New_York"
+```
+
+使用 TZ 数据库中的任何有效时区,如"Europe/London"、"Asia/Tokyo"或"UTC"。
+
+## 停止和启动 Speakr
+
+要停止 Speakr 同时保留所有数据:
+
+```bash
+docker compose down
+```
+
+要再次启动:
+
+```bash
+docker compose up -d
+```
+
+你的录音、转录内容和设置将保存在主机系统上的 `./uploads` 和 `./instance` 目录中。
+
+## 故障排除
+
+如果 Speakr 无法正常启动,请使用 `docker compose logs app` 检查日志中的错误信息。有关更详细的帮助,请参阅[故障排除指南](troubleshooting.md),特别是[安装问题](troubleshooting.md#installation-and-setup-issues)部分。常见问题包括 API 密钥不正确(日志中会显示身份验证错误)或端口冲突(如果其他服务正在使用 8899 端口)。你可以通过编辑 `docker-compose.yml` 文件并修改 ports 部分来更改端口。
+
+如果转录失败,请验证你的 API 密钥是否正确,并且你选择的服务有足够的额度。日志将显示详细的错误信息,有助于识别问题。
+
+---
+
+下一步:[安装指南](getting-started/installation.md)(用于生产部署和高级配置)
diff --git a/speakr/docs/getting-started/installation.md b/speakr/docs/getting-started/installation.md
new file mode 100644
index 0000000..cde30fe
--- /dev/null
+++ b/speakr/docs/getting-started/installation.md
@@ -0,0 +1,709 @@
+# 安装指南
+
+本综合指南涵盖了 Speakr 的生产环境部署,包括详细的配置选项、性能调优和部署最佳实践。虽然快速入门指南能让你快速运行,但本指南提供了稳健生产部署所需的一切。
+
+## 了解 Speakr 的架构
+
+在开始安装之前,了解 Speakr 的工作原理会很有帮助。该应用集成了外部 API,主要用于两个目的:将音频转换为文本的转录服务,以及为摘要、标题和交互式聊天等功能提供支持的文本生成服务。Speakr 设计灵活,既支持 OpenAI 等云端服务,也支持运行在你自有基础设施上的自托管方案。
+
+Speakr 使用特定的 API 端点格式进行这些集成。对于转录,它支持标准的 OpenAI Whisper API 格式,使用 `/audio/transcriptions` 端点,该格式被 OpenAI、OpenRouter 和许多自托管方案所实现。另外,对于说话人分离等高级功能,Speakr 可以连接提供 `/asr` 端点的 ASR Web 服务。**注意:使用 ASR 端点选项需要与 Speakr 一起运行额外的 Docker 容器**(`onerahmet/openai-whisper-asr-webservice`)——完整的设置说明请参考下方的[运行 ASR 服务用于说话人分离](#running-asr-service-for-speaker-diarization)部分。对于文本生成,Speakr 使用 OpenAI Chat Completions API 格式的 `/chat/completions` 端点,该格式被不同的 AI 提供商广泛支持。
+
+## 前置要求
+
+对于生产环境部署,请确保你的系统满足以下要求。你需要 Docker Engine 20.10 或更高版本以及 Docker Compose 2.0 或更高版本。系统应至少有 4GB 内存,推荐 8GB 以获得最佳性能,尤其是处理较长录音时。预留至少 20GB 可用磁盘空间来存放录音和转录文件,实际需求将取决于你的使用模式。除非你在本地运行所有服务,否则服务器应有稳定的互联网连接以进行转录和 AI 服务的 API 调用。
+
+## 选择部署方式
+
+你有两种主要的部署方式。第一种(推荐)是使用 Docker Hub 上的预构建 Docker 镜像,无需源代码即可快速运行。第二种是从源码构建,适用于需要修改代码或偏好自行构建镜像的场景。两种方法都使用 Docker Compose 进行编排和管理。
+
+## 使用预构建镜像的标准安装
+
+### 步骤 1:创建安装目录
+
+为你的 Speakr 安装选择合适的位置。该目录将包含你的配置文件和数据卷。对于生产环境部署,建议使用专用目录如 `/opt/speakr` 或 `/srv/speakr`,这样可以与用户主目录清晰分离,并遵循 Linux 文件系统层次标准。
+
+```bash
+mkdir -p /opt/speakr
+cd /opt/speakr
+```
+
+如果只是测试或个人使用,也可以在主文件夹中创建目录。位置不是关键,但将所有内容组织在一个地方更便于管理。
+
+### 步骤 2:创建 Docker Compose 配置
+
+创建 `docker-compose.yml` 文件,内容如下:
+
+```yaml
+services:
+ app:
+ image: learnedmachine/speakr:latest
+ container_name: speakr
+ restart: unless-stopped
+ ports:
+ - "8899:8899"
+ env_file:
+ - .env
+ volumes:
+ - ./uploads:/data/uploads
+ - ./instance:/data/instance
+```
+
+或下载示例配置:
+
+```bash
+wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/docker-compose.example.yml -O docker-compose.yml
+```
+
+重启策略 `unless-stopped` 确保 Speakr 在系统重启后自动启动,除非你显式停止了它。数据卷挂载本地目录用于持久化存储上传文件和数据库文件。
+
+### 步骤 3:环境配置
+
+环境配置用于告诉 Speakr 使用哪些 AI 服务以及如何连接它们。根据你的转录服务选择下载相应的环境模板。该模板包含所有配置变量,并附有 helpful 注释解释每个设置。
+
+#### 使用 OpenAI Whisper API
+
+如果你使用 OpenAI 的 Whisper API 或任何兼容服务,下载 Whisper 环境模板:
+
+```bash
+wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.whisper.example -O .env
+```
+
+然后编辑 `.env` 文件添加你的 API 密钥并自定义设置。配置按逻辑部分组织。首先,配置用于摘要、标题和聊天功能的文本生成模型。这里推荐使用 OpenRouter,因为它能以有竞争力的价格访问多个 AI 模型,但你也可以使用任何 OpenAI 兼容服务:
+
+```bash
+TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
+TEXT_MODEL_API_KEY=sk-or-v1-your-key-here
+TEXT_MODEL_NAME=openai/gpt-4o-mini
+```
+
+如果你更喜欢直接使用 OpenAI 进行文本生成,只需将 base URL 改为 `https://api.openai.com/v1` 并使用你的 OpenAI API 密钥。你也可以通过 Ollama 或 LM Studio 使用本地模型,指向 `http://localhost:11434/v1` 或类似地址。
+
+接下来,配置转录服务。这是将音频文件转换为文本的服务:
+
+```bash
+TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
+TRANSCRIPTION_API_KEY=sk-your-openai-key-here
+WHISPER_MODEL=whisper-1
+```
+
+#### 使用自定义 ASR 端点配合说话人分离
+
+如果你想要说话人分离功能来识别录音中的不同说话者,你需要使用 ASR Web 服务端点。**这需要与 Speakr 一起运行额外的 Docker 容器**(`onerahmet/openai-whisper-asr-webservice`),但为会议转录和多说话者录音提供了强大的功能。
+
+> **重要提示:** 在进行此配置之前,你需要先设置 ASR 服务容器。请参考[运行 ASR 服务用于说话人分离](#running-asr-service-for-speaker-diarization)获取完整的说明,了解如何一起或分别部署两个容器。
+
+下载 ASR 配置模板:
+
+```bash
+wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.asr.example -O .env
+```
+
+ASR 配置启用自定义端点并告诉 Speakr 在哪里找到它:
+
+```bash
+USE_ASR_ENDPOINT=true
+ASR_BASE_URL=http://your-asr-service:9000
+```
+
+ASR_BASE_URL 取决于你的部署架构。如果你在与 Speakr 相同的 Docker Compose 堆栈中运行 ASR 服务,请使用 docker-compose.yml 中的服务名,如 `http://whisper-asr:9000`。这使用 Docker 的内部网络进行通信。如果 ASR 服务运行在其他地方,请使用其完整 URL 和适当的 IP 地址或域名。
+
+使用 ASR 端点时会自动启用说话人分离。系统将识别录音中的不同说话者并标记为 Speaker 1、Speaker 2 等。你可以选择取消注释并调整环境文件中的 ASR_MIN_SPEAKERS 和 ASR_MAX_SPEAKERS 来覆盖默认的说话者检测设置。
+
+### 步骤 4:配置系统设置
+
+Speakr 的一个便利功能是自动创建管理员账户。无需经过注册流程,你只需在环境文件中定义管理员凭据,Speakr 会在首次启动时自动创建账户。这确保你可以立即登录,无需额外的设置步骤:
+
+```bash
+ADMIN_USERNAME=admin
+ADMIN_EMAIL=admin@your-domain.com
+ADMIN_PASSWORD=your-secure-password-here
+```
+
+为管理员账户选择强密码,因为它拥有完整的系统访问权限,包括管理用户和查看所有录音的能力。管理员账户是特殊的,无法通过常规注册流程创建,只能通过环境变量创建。
+
+接下来,配置应用程序的行为方式。这些设置控制用户访问和系统操作:
+
+```bash
+ALLOW_REGISTRATION=false
+TIMEZONE="America/New_York"
+LOG_LEVEL="INFO"
+```
+
+设置 `ALLOW_REGISTRATION=false` 意味着只有管理员可以创建新用户账户,这对于需要控制访问权限的私有安装是推荐的。如果你为团队或家庭运行 Speakr,这可以防止无关人员创建账户。时区设置影响整个界面中日期和时间的显示方式,因此请将其设置为你的本地时区以方便使用。日志级别控制 Speakr 写入日志的信息量。在初始设置和测试期间使用 `INFO` 以了解发生了什么,然后在生产环境中切换到 `ERROR` 以减少日志量并提高性能。
+
+### 步骤 5:配置高级功能
+
+#### 大文件处理
+
+Speakr 最有用的功能之一是自动处理大音频文件。许多转录 API 有文件大小限制,OpenAI 的 25MB 限制就是一个常见的约束。Speakr 通过智能分段自动处理这个问题,而不是强制你手动分割文件:
+
+```bash
+ENABLE_CHUNKING=true
+CHUNK_LIMIT=20MB
+CHUNK_OVERLAP_SECONDS=3
+```
+
+启用分段后,Speakr 会自动检测文件是否超过配置的限制并将其分割为较小的部分。每个部分单独处理,转录结果无缝合并回一起。重叠设置确保在分段边界处不会丢失单词,这对于连续语音尤为重要。分段限制可以指定为文件大小如 `20MB` 或持续时间如 `20m`(20 分钟),具体取决于你的 API 限制。
+
+此功能仅适用于标准 Whisper API 方法。如果你使用 ASR 端点,则不需要分段,因为这些服务通常原生支持大文件。
+
+#### Inquire 模式用于语义搜索
+
+Inquire 模式将 Speakr 从简单的转录工具转变为你所有录音的知识库。启用后,你可以使用自然语言问题在所有转录中进行搜索:
+
+```bash
+ENABLE_INQUIRE_MODE=true
+```
+
+启用 Inquire 模式后,Speakr 会为你的转录创建嵌入向量,从而支持语义搜索。这意味着你可以问"我们什么时候讨论了营销预算?"这样的问题,即使没有使用确切的词语也能找到相关录音。该功能在转录期间需要额外的处理,但随着你的录音库增长,它将提供强大的搜索能力。
+
+#### 自动化文件处理
+
+自动化文件处理功能(有时称为"黑洞"目录)会监控指定文件夹中的新音频文件并自动处理它们,无需手动干预。这非常适合与录音设备、自动化工作流或批处理场景集成:
+
+```bash
+ENABLE_AUTO_PROCESSING=true
+AUTO_PROCESS_MODE=admin_only
+AUTO_PROCESS_WATCH_DIR=/data/auto-process
+AUTO_PROCESS_CHECK_INTERVAL=30
+```
+
+启用后,Speakr 每 30 秒检查一次监控目录是否有新音频文件。发现的任何文件都会自动移动到上传目录并使用你配置的转录设置进行处理。`admin_only` 模式将所有处理后的文件分配给管理员用户,但你也可以为多用户场景配置每个用户的独立目录。
+
+要使用此功能,你需要在 Docker Compose 配置中挂载额外的数据卷,我们将在接下来的步骤中介绍。
+
+### 步骤 6:设置数据目录
+
+Speakr 需要本地目录来持久化存储你的数据。这些目录作为 Docker 数据卷挂载,确保你的录音和数据库在容器更新和重启后仍然存在:
+
+```bash
+mkdir -p uploads instance
+chmod 755 uploads instance
+```
+
+`uploads` 目录存储所有音频文件及其转录内容,按用户组织。`instance` 目录包含跟踪所有录音、用户和设置的 SQLite 数据库。将权限设置为 755 确保 Docker 容器可以读写这些目录,同时保持合理的安全性。
+
+如果你使用自动化文件处理功能,也需要创建该目录:
+
+```bash
+mkdir -p auto-process
+chmod 755 auto-process
+```
+
+### 步骤 7:启动 Speakr
+
+所有配置完成后,你就可以启动 Speakr 了。`-d` 标志以分离模式运行容器,意味着它将在后台持续运行:
+
+```bash
+docker compose up -d
+```
+
+首次运行此命令时,Docker 将从 Docker Hub 下载 Speakr 镜像。该镜像约 3GB,包含运行 Speakr 所需的所有依赖项,包括用于音频处理的 FFmpeg 和各种 Python 库。下载时间取决于你的互联网连接速度。
+
+监控启动过程以确保一切正常运行:
+
+```bash
+docker compose logs -f app
+```
+
+观察日志是否有任何错误消息。你应该会看到关于数据库初始化、管理员用户创建的消息,最后会显示 Flask 应用程序在 8899 端口运行的消息。按 Ctrl+C 停止查看日志(这不会停止容器,只是停止日志查看)。
+
+### 步骤 8:验证安装
+
+打开浏览器并访问 `http://your-server:8899`,将 `your-server` 替换为你的实际服务器地址,如果是本地运行则使用 `localhost`。你应该会看到 Speakr 登录页面,带有其独特的渐变设计。
+
+使用你在环境文件中配置的管理员凭据登录。如果登录失败,请检查 Docker 日志以确保管理员账户创建成功。有时环境文件中的拼写错误会导致问题。
+
+登录成功后,通过创建测试录音或上传示例音频文件来测试安装。录音界面应显示麦克风、系统音频或两者的选项。首先尝试上传一个小型音频文件以验证你的 API 密钥是否正常工作。对于短文件,转录过程应在几秒钟内完成,你应该会看到转录文本出现以及 AI 生成的摘要。
+
+如果转录失败,请检查 Docker 日志中的 API 认证错误或连接问题。常见问题包括 API 密钥不正确、API 额度不足或网络连接问题。
+
+## 高级部署场景
+
+### 运行 ASR 服务用于说话人分离
+
+如果你需要说话人分离功能来识别录音中的不同说话者,你需要与 Speakr 一起运行 ASR 服务。这涉及部署额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)来提供 ASR 端点。推荐的方法是在同一个 Docker Compose 堆栈中运行两个容器,以简化网络和管理。
+
+首先,你需要一个 Hugging Face 令牌来访问说话人分离模型。在 Hugging Face 创建账户,生成访问令牌,并且重要的是,访问 pyannote/segmentation-3.0 和 pyannote/speaker-diarization-3.1 模型页面以接受它们的条款。这些是需要明确授权的受限模型。
+
+以下是包含两个服务的完整 Docker Compose 配置:
+
+```yaml
+services:
+ whisper-asr:
+ image: onerahmet/openai-whisper-asr-webservice:latest-gpu
+ container_name: whisper-asr-webservice
+ ports:
+ - "9000:9000"
+ environment:
+ - ASR_MODEL=distil-large-v3
+ - ASR_COMPUTE_TYPE=int8
+ - ASR_ENGINE=whisperx
+ - HF_TOKEN=your_huggingface_token_here
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ capabilities: [gpu]
+ device_ids: ["0"]
+ restart: unless-stopped
+ networks:
+ - speakr-network
+
+ speakr:
+ image: learnedmachine/speakr:latest
+ container_name: speakr
+ restart: unless-stopped
+ ports:
+ - "8899:8899"
+ env_file:
+ - .env
+ volumes:
+ - ./uploads:/data/uploads
+ - ./instance:/data/instance
+ depends_on:
+ - whisper-asr
+ networks:
+ - speakr-network
+
+networks:
+ speakr-network:
+ driver: bridge
+```
+
+> **Mac 用户注意:** 由于 Docker 的架构,GPU 直通在 macOS 上不起作用。请使用 `onerahmet/openai-whisper-asr-webservice:latest`(CPU 版本)而不是 `:latest-gpu`,并删除 `deploy` 部分。ASR 服务将使用 CPU 处理,速度较慢但功能完整。请参考[常见问题](../faq.md#can-i-use-the-asr-webservice-for-speaker-diarization-on-mac)获取 Mac 特定的配置示例。
+
+当在同一个 Docker Compose 文件中运行两个服务时,容器使用服务名进行通信。在 `.env` 文件中,设置 `ASR_BASE_URL=http://whisper-asr:9000`,使用服务名而不是 localhost 或 IP 地址。这是一个常见的混淆点,但这就是 Docker 网络的工作方式。
+
+#### 在独立的 Docker Compose 文件中运行服务
+
+如果你偏好独立管理服务或将 ASR 服务添加到现有 Speakr 安装中,你可以在单独的 Docker Compose 文件中运行它们。这种方法提供了更大的灵活性,无论服务是在同一台机器还是不同的机器上都可以工作。
+
+##### 选项 1:同一台机器共享网络
+
+如果两个服务运行在同一台机器上,你可以使用 Docker 的内部网络进行通信:
+
+首先,创建共享的 Docker 网络:
+
+```bash
+docker network create speakr-network
+```
+
+为 ASR 服务创建 `docker-compose.asr.yml`:
+
+```yaml
+services:
+ whisper-asr:
+ image: onerahmet/openai-whisper-asr-webservice:latest-gpu
+ container_name: whisper-asr-webservice
+ ports:
+ - "9000:9000"
+ environment:
+ - ASR_MODEL=distil-large-v3
+ - ASR_COMPUTE_TYPE=int8
+ - ASR_ENGINE=whisperx
+ - HF_TOKEN=your_huggingface_token_here
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ capabilities: [gpu]
+ device_ids: ["0"]
+ restart: unless-stopped
+ networks:
+ - speakr-network
+
+networks:
+ speakr-network:
+ external: true
+```
+
+更新你的 Speakr `docker-compose.yml` 以使用共享网络:
+
+```yaml
+services:
+ app:
+ image: learnedmachine/speakr:latest
+ container_name: speakr
+ restart: unless-stopped
+ ports:
+ - "8899:8899"
+ env_file:
+ - .env
+ volumes:
+ - ./uploads:/data/uploads
+ - ./instance:/data/instance
+ networks:
+ - speakr-network
+
+networks:
+ speakr-network:
+ external: true
+```
+
+在你的 `.env` 文件中,使用容器名:
+```bash
+ASR_BASE_URL=http://whisper-asr-webservice:9000
+```
+
+##### 选项 2:不同机器
+
+当在不同机器上运行时,你不需要共享网络。每个服务独立运行,并使用 IP 地址或主机名通过网络进行通信。
+
+在 ASR 服务器上,创建 `docker-compose.asr.yml`:
+
+```yaml
+services:
+ whisper-asr:
+ image: onerahmet/openai-whisper-asr-webservice:latest-gpu
+ container_name: whisper-asr-webservice
+ ports:
+ - "9000:9000" # 暴露到网络
+ environment:
+ - ASR_MODEL=distil-large-v3
+ - ASR_COMPUTE_TYPE=int8
+ - ASR_ENGINE=whisperx
+ - HF_TOKEN=your_huggingface_token_here
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ capabilities: [gpu]
+ device_ids: ["0"]
+ restart: unless-stopped
+```
+
+在 Speakr 服务器上,使用标准的 `docker-compose.yml`:
+
+```yaml
+services:
+ app:
+ image: learnedmachine/speakr:latest
+ container_name: speakr
+ restart: unless-stopped
+ ports:
+ - "8899:8899"
+ env_file:
+ - .env
+ volumes:
+ - ./uploads:/data/uploads
+ - ./instance:/data/instance
+```
+
+在你的 Speakr `.env` 文件中,使用 ASR 服务器的 IP 地址或主机名:
+```bash
+# 使用 IP 地址
+ASR_BASE_URL=http://192.168.1.100:9000
+
+# 或使用主机名
+ASR_BASE_URL=http://asr-server.local:9000
+```
+
+在各自的机器上启动两个服务:
+
+```bash
+# 在 ASR 服务器上
+docker compose -f docker-compose.asr.yml up -d
+
+# 在 Speakr 服务器上
+docker compose up -d
+```
+
+确保机器之间可以访问端口 9000(如有需要请检查防火墙规则)。
+
+## 生产环境注意事项
+
+### 使用反向代理配置 SSL
+
+对于生产环境部署,将 Speakr 运行在反向代理后面对于安全性和启用所有功能至关重要。浏览器录音功能,特别是系统音频捕获,由于浏览器安全限制需要 HTTPS 才能工作。反向代理处理 SSL 终止,意味着它管理 HTTPS 证书,同时在内部通过 HTTP 与 Speakr 通信。
+
+以下是 nginx 的完整配置示例:
+
+```nginx
+server {
+ listen 443 ssl http2;
+ server_name speakr.yourdomain.com;
+
+ ssl_certificate /path/to/certificate.crt;
+ ssl_certificate_key /path/to/private.key;
+
+ # 安全头部
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+
+ location / {
+ proxy_pass http://localhost:8899;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # 实时功能的 WebSocket 支持
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # 实时 ASR WebSocket 代理。如果存在更广泛的 /tool/speakr/ 路由,请将其放在前面。
+ # location /tool/speakr/ws/ {
+ # proxy_pass http://speakr:8899/ws/;
+ # proxy_http_version 1.1;
+ # proxy_set_header Upgrade $http_upgrade;
+ # proxy_set_header Connection "upgrade";
+ # proxy_read_timeout 3600s;
+ # }
+
+ # 大文件上传的超时设置
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+ }
+}
+
+# 将 HTTP 重定向到 HTTPS
+server {
+ listen 80;
+ server_name speakr.yourdomain.com;
+ return 301 https://$server_name$request_uri;
+}
+```
+
+WebSocket 配置对 Speakr 的实时功能很重要。超时设置确保大文件上传不会被中断。你可以使用 Certbot 从 Let's Encrypt 获取免费的 SSL 证书,让每个人都能使用 HTTPS。
+
+### 备份策略
+
+定期备份对于生产环境部署至关重要。你的 Speakr 数据包含三个需要备份的关键组件:`instance` 目录中的 SQLite 数据库、`uploads` 目录中的音频文件和转录内容,以及 `.env` 文件中的配置。
+
+创建一个备份脚本来捕获所有三个组件:
+
+```bash
+#!/bin/bash
+BACKUP_DIR="/backup/speakr"
+DATE=$(date +%Y%m%d_%H%M%S)
+
+# 如果备份目录不存在则创建
+mkdir -p "$BACKUP_DIR"
+
+# 创建备份
+tar czf "$BACKUP_DIR/speakr_backup_$DATE.tar.gz" \
+ /opt/speakr/instance \
+ /opt/speakr/uploads \
+ /opt/speakr/.env
+
+# 可选:仅保留最近 30 天的备份
+find "$BACKUP_DIR" -name "speakr_backup_*.tar.gz" -mtime +30 -delete
+
+echo "备份完成:speakr_backup_$DATE.tar.gz"
+```
+
+使脚本可执行并使用 cron 安排自动每日备份:
+
+```bash
+chmod +x /opt/speakr/backup.sh
+crontab -e
+# 添加此行以在每天凌晨 2 点进行备份:
+0 2 * * * /opt/speakr/backup.sh
+```
+
+对于关键部署,考虑将备份复制到远程存储或云服务以获得额外的冗余。压缩后的备份大小通常远小于原始数据,因为音频文件压缩效果很好。
+
+### 监控和维护
+
+主动监控有助于在问题影响用户之前预防问题。音频文件会随着时间的推移占用大量存储空间,尤其是如果你经常录制较长的会议。设置磁盘空间监控,当使用率超过 80% 时发出警报。一种简单的监控方法是使用 cron 和 df:
+
+```bash
+#!/bin/bash
+USAGE=$(df /opt/speakr | tail -1 | awk '{print $5}' | sed 's/%//')
+if [ $USAGE -gt 80 ]; then
+ echo "警告:Speakr 磁盘使用率已达 ${USAGE}%" | mail -s "Speakr 磁盘警报" admin@example.com
+fi
+```
+
+定期监控 Docker 容器的健康和日志。你可以使用 Docker 内置的健康检查功能或外部监控工具。检查是否存在重复的 API 失败、认证错误或处理超时等模式。同时跟踪你的 API 使用情况和成本,因为大量使用会产生可观的费用。
+
+### 安全加固
+
+生产环境部署需要比默认配置更多的安全措施。首先确保所有账户使用强密码,尤其是管理员账户。切勿在生产环境中使用默认或简单密码。
+
+使用防火墙规则限制网络访问。如果 Speakr 仅在内部使用,请将访问限制为你组织的 IP 范围:
+
+```bash
+# 使用 ufw 的示例
+ufw allow from 192.168.1.0/24 to any port 8899
+ufw deny 8899
+```
+
+在反向代理级别实施速率限制以防止滥用和 API 耗尽。在 nginx 中,你可以添加:
+
+```nginx
+limit_req_zone $binary_remote_addr zone=speakr:10m rate=10r/s;
+limit_req zone=speakr burst=20;
+```
+
+保持 Docker 镜像更新以获得最新的安全补丁。定期检查更新并规划维护窗口进行更新。更新前务必先备份,如果可能的话先在暂存环境中测试更新。
+
+## 更新 Speakr
+
+保持 Speakr 更新可确保你拥有最新的功能和安全补丁。更新过程很简单,但应谨慎操作以避免数据丢失。
+
+首先,更新前务必创建备份:
+
+```bash
+# 创建备份
+tar czf speakr_backup_before_update.tar.gz uploads/ instance/ .env
+
+# 拉取最新镜像
+docker compose pull
+
+# 停止当前容器
+docker compose down
+
+# 使用新镜像启动
+docker compose up -d
+
+# 检查日志以确保成功启动
+docker compose logs -f app
+```
+
+更新过程会保留你所有的数据,因为数据存储在容器外的挂载数据卷中。但是,查看发布说明很重要,因为某些更新可能需要配置更改或有需要注意的破坏性变更。
+
+如果更新导致问题,你可以通过在 docker-compose.yml 中指定先前版本来回滚:
+
+```yaml
+image: learnedmachine/speakr:v1.2.3 # 替换为你之前的版本
+```
+
+## 常见问题排查
+
+### 容器无法启动
+
+当容器无法启动时,日志通常会准确告诉你问题所在。首先检查日志:
+
+```bash
+docker compose logs app
+```
+
+常见的启动问题包括缺失或格式不正确的 `.env` 文件。确保你的 `.env` 文件存在且具有正确的语法。每一行应该是 `KEY=value` 格式,等号周围不要有空格。注释以 `#` 开头。
+
+端口冲突是另一个常见问题。检查端口 8899 是否已被占用:
+
+```bash
+netstat -tulpn | grep 8899
+# 或在 macOS 上:
+lsof -i :8899
+```
+
+如果端口被占用,请停止冲突的服务或更改 docker-compose.yml 中 Speakr 的端口。
+
+### 转录失败
+
+转录失败通常源于 API 配置问题。检查 Docker 日志中的具体错误消息:
+
+```bash
+docker compose logs app | grep -i error
+```
+
+常见的转录问题包括不正确的 API 密钥,这在日志中会显示为认证错误。仔细检查 `.env` 文件中的密钥并确保它们是正确的服务。API 额度不足会显示为配额或支付错误。检查你的 API 提供商账户余额。网络连接问题会显示为连接超时或 DNS 解析失败。
+
+对于 ASR 端点,验证服务是否正在运行并可访问:
+
+```bash
+# 测试 ASR 端点连接
+curl http://your-asr-service:9000/docs
+```
+
+如果使用 Docker 网络和服务名,请记住容器必须在同一网络上才能通信。
+
+### 性能问题
+
+性能缓慢可能有多种原因。首先检查系统资源:
+
+```bash
+# 检查内存使用情况
+free -h
+
+# 检查磁盘 I/O
+iotop
+
+# 检查 Docker 资源使用情况
+docker stats speakr
+```
+
+如果内存紧张,考虑添加 swap 空间或升级你的服务器。对于磁盘 I/O 问题,确保 uploads 和 instance 目录使用 SSD 存储。传统硬盘会显著降低操作速度,尤其是多用户并发使用时。
+
+对于大文件处理,确保正确配置了分段。没有分段的话,大文件可能会超时或完全失败。分段大小应略低于你的 API 限制,以考虑编码开销。
+
+如果你在许多并发用户情况下看到转录缓慢,你可能触及了 API 速率限制。查看你的 API 提供商文档中的速率限制,如果需要请考虑升级你的计划。
+
+### 浏览器录音问题
+
+如果浏览器录音不起作用,尤其是系统音频,最常见的原因是你使用的是 HTTP 而不是 HTTPS。由于隐私问题,浏览器需要安全连接才能进行音频捕获。要么使用反向代理设置 SSL,要么(仅用于本地开发)修改浏览器的安全设置,将你的本地 URL 视为安全。
+
+在 Chrome 中,导航到 `chrome://flags`,搜索"insecure origins",并将你的 URL 添加到列表中。请记住这会降低安全性,仅应用于开发环境。
+
+## 从源码构建
+
+如果你需要修改 Speakr 的代码或偏好自行构建镜像,你可以从源码构建。这需要克隆仓库并使用 Docker 的构建功能。
+
+首先,克隆仓库并进入目录:
+
+```bash
+git clone https://github.com/murtaza-nasir/speakr.git
+cd speakr
+```
+
+修改 docker-compose.yml 以本地构建而不是使用预构建镜像:
+
+```yaml
+services:
+ app:
+ build: . # 从当前目录构建
+ image: speakr:custom # 自定义构建的标签
+ container_name: speakr
+ restart: unless-stopped
+ ports:
+ - "8899:8899"
+ env_file:
+ - .env
+ volumes:
+ - ./uploads:/data/uploads
+ - ./instance:/data/instance
+```
+
+构建并启动自定义版本:
+
+```bash
+docker compose up -d --build
+```
+
+`--build` 标志强制 Docker 即使已有镜像也重新构建。当你进行了代码更改并想测试时很有用。
+
+## 性能优化
+
+对于高负载部署或处理大量大文件时,优化变得很重要。如果使用 ASR,首先从模型选择开始。distil-large-v3 模型在速度和准确性之间提供了极佳的平衡。对于纯英语内容,使用 `.en` 变体,它们在英语上更快更准确。
+
+为你的工作负载优化 Docker 资源分配:
+
+```yaml
+services:
+ app:
+ image: learnedmachine/speakr:latest
+ deploy:
+ resources:
+ limits:
+ memory: 8G
+ cpus: '4'
+ reservations:
+ memory: 4G
+ cpus: '2'
+```
+
+这确保 Speakr 有足够的资源,同时防止它在共享服务器上消耗所有资源。
+
+对于存储性能,为 Docker 数据卷使用 SSD 驱动器。数据库从快速随机 I/O 中获益良多,大音频文件处理在 SSD 上也会快得多。如果使用网络存储,请确保低延迟连接。
+
+---
+
+下一步:[用户指南](../user-guide/index.md) 了解如何使用 Speakr 的所有功能
diff --git a/speakr/docs/index.md b/speakr/docs/index.md
new file mode 100644
index 0000000..e342e11
--- /dev/null
+++ b/speakr/docs/index.md
@@ -0,0 +1,156 @@
+# 欢迎使用 Speakr
+
+Speakr 是一款功能强大的自托管转录平台,帮助您捕获、转录和理解音频内容。无论您是录制会议、采访、讲座还是个人笔记,Speakr 都能将语音转化为有价值的、可搜索的知识。
+
+
+
+
+
+## 快速导航
+
+
+
+
📚
+
快速开始
+
初次使用 Speakr?从这里开始快速了解并查看设置指南。
+
开始使用 →
+
+
+
+
🚀
+
安装指南
+
Docker 和手动安装的逐步说明。
+
立即安装 →
+
+
+
+
+
+
+
+
❓
+
常见问题
+
查找关于 Speakr 的常见问题解答。
+
查看常见问题 →
+
+
+
+
+
+## 核心功能
+
+
+
+
🎙️ 智能录制
+
+ 从麦克风或系统捕获音频
+ 录制时做笔记
+ 生成智能摘要
+
+
+
+
+
+
+
🔍 智能搜索
+
+ 语义搜索
+ 自然语言查询
+ 跨录音搜索
+
+
+
+
+
+
+
🌍 国际化
+
+ 支持 5+ 种语言
+ 自动 UI 翻译
+ 本地化摘要
+
+
+
+
+
+
+## 最新更新
+
+!!! info "Version 0.5.6 - 最新版本"
+ - **事件提取** - 自动识别并从录音中导出日历事件
+ - **转录模板** - 使用灵活的模板系统自定义下载格式
+ - **增强的导出选项** - 以多种格式下载转录内容,适应不同使用场景
+ - **改进的用户界面** - 所有页面提供更好的移动端布局和加载遮罩
+
+ 上一个版本 (v0.5.5):
+
+ - 完整的国际化与多语言支持
+ - 增强的 128kbps 音频处理以获得更高准确率
+ - 支持日期预设和多标签选择的高级过滤功能
+
+## 获取帮助
+
+需要协助?我们随时为您提供帮助:
+
+
+
+
📖 文档
+
您已经在这里!浏览我们的综合指南:
+
+
+
+
+
💬 社区
+
与其他用户交流并获取支持:
+
+
+
+
+---
+
+准备好将您的音频转化为可操作的洞察了吗?[立即开始](getting-started.md) →
diff --git a/speakr/docs/javascripts/extra.js b/speakr/docs/javascripts/extra.js
new file mode 100644
index 0000000..d4344b4
--- /dev/null
+++ b/speakr/docs/javascripts/extra.js
@@ -0,0 +1,125 @@
+// Custom JavaScript for Speakr documentation
+
+document.addEventListener('DOMContentLoaded', function() {
+ // Add smooth scrolling for anchor links
+ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
+ anchor.addEventListener('click', function (e) {
+ const href = this.getAttribute('href');
+ if (href === '#') return;
+
+ e.preventDefault();
+ const target = document.querySelector(href);
+ if (target) {
+ target.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start'
+ });
+
+ // Update URL without jumping
+ history.pushState(null, null, href);
+ }
+ });
+ });
+
+ // Add external link indicators
+ document.querySelectorAll('a[href^="http"]').forEach(link => {
+ if (!link.hostname.includes(window.location.hostname)) {
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener noreferrer');
+
+ // Add external icon if not already present
+ if (!link.querySelector('.external-icon')) {
+ const icon = document.createElement('span');
+ icon.className = 'external-icon';
+ icon.innerHTML = ' ↗';
+ icon.style.fontSize = '0.75em';
+ icon.style.verticalAlign = 'super';
+ link.appendChild(icon);
+ }
+ }
+ });
+
+ // Enhance search functionality
+ const searchInput = document.querySelector('.md-search__input');
+ if (searchInput) {
+ // Add keyboard shortcut hint
+ searchInput.setAttribute('placeholder', 'Search (Press "/" to focus)');
+
+ // Add "/" keyboard shortcut
+ document.addEventListener('keydown', function(e) {
+ if (e.key === '/' && !searchInput.matches(':focus')) {
+ e.preventDefault();
+ searchInput.focus();
+ }
+ });
+ }
+
+ // Add responsive tables wrapper
+ document.querySelectorAll('table').forEach(table => {
+ if (!table.parentElement.classList.contains('table-wrapper')) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'table-wrapper';
+ wrapper.style.overflowX = 'auto';
+ table.parentElement.insertBefore(wrapper, table);
+ wrapper.appendChild(table);
+ }
+ });
+
+ // Add image zoom functionality
+ document.querySelectorAll('.md-content img').forEach(img => {
+ img.style.cursor = 'zoom-in';
+ img.addEventListener('click', function() {
+ const overlay = document.createElement('div');
+ overlay.style.position = 'fixed';
+ overlay.style.top = '0';
+ overlay.style.left = '0';
+ overlay.style.width = '100%';
+ overlay.style.height = '100%';
+ overlay.style.background = 'rgba(0, 0, 0, 0.9)';
+ overlay.style.zIndex = '9999';
+ overlay.style.display = 'flex';
+ overlay.style.alignItems = 'center';
+ overlay.style.justifyContent = 'center';
+ overlay.style.cursor = 'zoom-out';
+
+ const zoomedImg = document.createElement('img');
+ zoomedImg.src = img.src;
+ zoomedImg.style.maxWidth = '90%';
+ zoomedImg.style.maxHeight = '90%';
+ zoomedImg.style.objectFit = 'contain';
+
+ overlay.appendChild(zoomedImg);
+ document.body.appendChild(overlay);
+
+ overlay.addEventListener('click', function() {
+ document.body.removeChild(overlay);
+ });
+
+ // Close on Escape key
+ document.addEventListener('keydown', function closeOnEscape(e) {
+ if (e.key === 'Escape' && document.body.contains(overlay)) {
+ document.body.removeChild(overlay);
+ document.removeEventListener('keydown', closeOnEscape);
+ }
+ });
+ });
+ });
+});
+
+// Add loading indicator for slow operations
+window.addEventListener('load', function() {
+ // Remove any loading indicators
+ const loader = document.querySelector('.page-loader');
+ if (loader) {
+ loader.style.display = 'none';
+ }
+});
+
+// Print-friendly styling
+window.addEventListener('beforeprint', function() {
+ document.body.classList.add('print-mode');
+});
+
+window.addEventListener('afterprint', function() {
+ document.body.classList.remove('print-mode');
+});
\ No newline at end of file
diff --git a/speakr/docs/requirements-docs.txt b/speakr/docs/requirements-docs.txt
new file mode 100644
index 0000000..b3aa268
--- /dev/null
+++ b/speakr/docs/requirements-docs.txt
@@ -0,0 +1,16 @@
+# MkDocs and Material theme
+mkdocs>=1.5.3
+mkdocs-material>=9.5.0
+mkdocs-material-extensions>=1.3
+
+# MkDocs plugins
+mkdocs-minify-plugin>=0.7.2
+mkdocs-git-revision-date-localized-plugin>=1.2.2
+
+# Markdown extensions
+pymdown-extensions>=10.5
+pygments>=2.17.0
+
+# Additional utilities
+Pillow>=10.1.0 # For image optimization
+cairosvg>=2.7.1 # For SVG support
\ No newline at end of file
diff --git a/speakr/docs/rework-notes/backend-improvement-log.md b/speakr/docs/rework-notes/backend-improvement-log.md
new file mode 100644
index 0000000..472eb5a
--- /dev/null
+++ b/speakr/docs/rework-notes/backend-improvement-log.md
@@ -0,0 +1,156 @@
+# 后端代码改进工作记录
+
+> 基于 2026-06-23 的代码审查,记录所有发现的问题及处理状态。
+> 注:问题编号与分析报告一致。
+
+---
+
+## 一、暂不处理(鉴权相关,后续配合主系统调整)
+
+| 编号 | 问题 | 原因 |
+|------|------|------|
+| #1 | `auto_login()` 完全绕过认证 | 鉴权在主系统完成,我们是补充子系统 |
+| #2 | 未认证接口泄露敏感数据 | 同上 |
+| #4 | CSRF 防护全局失效 | 同上 |
+
+---
+
+## 二、已处理
+
+### #8 recording.py 下载接口重复代码抽取
+- **状态**: ✅ 已完成
+- **文件**: `src/services/document_service.py`、`src/api/recording.py`
+- **修复**: 抽取 `add_unicode_paragraph()` / `create_docx_with_unicode_title()` / `save_and_send_docx()` 到 document_service.py,4 个下载接口共减少约 280 行重复代码
+
+### #9 recording.py 大文件拆分
+- **状态**: ✅ 已完成
+- **文件**: `src/api/recording_download.py`(新建)
+- **修复**: 下载路由拆分到独立蓝图 `recording_download_bp`,recording.py 从 1610 行降至约 1330 行;在 `api/__init__.py` 统一注册
+
+### #3 字段名不匹配 Bug:`error_msg` vs `error_message`
+- **状态**: ✅ 已完成
+- **文件**: `src/services/transcription_service.py:202, :581`
+- **问题**: 模型字段是 `error_message`,service 写入 `recording.error_msg`(不存在的属性),错误信息丢失
+- **修复**: 两处 `error_msg` 改为 `error_message`
+
+### #10 错误处理不一致且泄露内部异常
+- **状态**: 🔴 已还原(撤销全局错误处理器和 `api_response.py`)
+- **文件**: `src/api/` 下所有蓝图文件
+- **问题**: 51 处 `return jsonify({'error': str(e)}), 500` 把内部异常暴露给客户端
+- **处理**: 曾创建 `api_response.py` 和全局错误处理器替换,但用户认为全局错误处理器不需要,已还原
+- **后续**: 等 #11 统一返回和日志方案重新设计后再处理
+
+### #5 硬编码默认密钥(仅警告)
+- **状态**: ✅ 已完成(报警告,不阻止启动)
+- **文件**: `src/config.py:17, :73`
+- **处理**: `validate_config()` 已有警告逻辑,`get_app_config()` 中保留默认值但启动时打印警告
+
+### #15 LLM/Voiceprint 客户端无超时和重试
+- **状态**: ✅ 已完成
+- **文件**: `src/clients/llm_client.py`、`src/clients/voiceprint_client.py`
+- **修复**:
+ - llm_client: 添加 `httpx.Timeout(connect=10, read=300, write=60, pool=30)`,`print()` 改为 `logging`
+ - voiceprint_client: 改用持久化 `httpx.Client` 复用连接池,添加指数退避重试(3次),细化异常类型
+
+### #16 双入口文件端口不一致
+- **状态**: ✅ 已完成
+- **文件**: `src/app.py`
+- **修复**: 端口和 debug 模式从环境变量 `FLASK_PORT` / `FLASK_DEBUG` 读取,默认 5000 / false
+
+### #20 config.py 循环依赖风险
+- **状态**: ✅ 已完成
+- **文件**: `src/config.py`、`src/app.py`、`src/services/register.py`
+- **修复**: `get_app_config()` 移除对 `src.clients` 的导入;新增 `init_early_services()` 在蓝图注册前初始化 `chunking_service` 和 `EMBEDDINGS_AVAILABLE`
+
+### #21 admin.py 绕过 config 直接读 os.environ
+- **状态**: ✅ 已完成
+- **文件**: `src/api/admin.py:466-470`、`src/config.py`
+- **修复**: 自动处理配置项(`ENABLE_AUTO_PROCESSING` 等 5 项)移入 config.py;admin.py 从 `current_app.config` 读取
+
+---
+
+## 三、标记待做(高优先级)
+
+### #11 统一错误返回格式
+- **状态**: 🔴 已还原(撤销全局错误处理器和 `api_response.py`)
+- **处理**: 曾创建 `api_response.py` 和全局错误处理器,但已还原;后续需重新设计统一返回和日志方案
+- **优先级**: 高(在 #6/#7/#12 之前)
+
+### #19 统一日志格式
+- **状态**: ✅ 已完成
+- **修复**:
+ - `src/config.py`: `print()` 改为 `logging.warning()`
+ - `src/clients/llm_client.py`: `print()` 改为 `logging.info()` / `logging.error()`
+ - `src/api/draft_api.py`: 约 20 条英文日志全部改为中文
+ - `src/services/llm_service.py`: 约 15 条英文/混杂日志全部改为中文,语义更清晰
+ - `src/services/audio_chunking.py`: 约 40 条英文日志全部改为中文,统计和建议部分语义优化
+ - 所有日志语言统一为中文,语义表达便于调试
+
+---
+
+## 四、标记待做(性能优化,有空再处理)
+
+### #6 N+1 查询优化
+- **文件**: `src/api/admin.py:81-93, :645-652`、`src/models/recording.py:38-64`
+- **问题**: admin 接口和 to_dict 列表场景存在 N+1 查询
+- **处理**: 标记,当前系统规模无性能问题,有空再优化
+- **优先级**: 中
+
+### #7 后台线程管理优化
+- **文件**: `src/api/recording.py` 多处 `threading.Thread`
+- **问题**: 无线程池、无任务队列、无重试
+- **处理**: 标记,当前无并发压力,后续引入 ThreadPoolExecutor 或 Celery
+- **优先级**: 中(与 #6 一致)
+
+### #12 缺少输入校验
+- **问题**: POST/PUT 接口缺少 email 格式、密码强度、字段长度等校验
+- **处理**: 标记,后续引入 marshmallow/pydantic
+- **优先级**: 中(与 #6/#7 一致)
+
+### #13 sync_service.py 每轮循环重建 engine(PostgreSQL)
+- **文件**: `src/services/sync_service.py:78`
+- **问题**: `create_engine` 在循环内每次重建,连接泄漏
+- **优先级**: 高(与 #11 一致,数据库返工)
+
+### #14 数据库迁移仅支持 SQLite(PostgreSQL)
+- **文件**: `src/services/register.py:130-161`
+- **问题**: `PRAGMA table_info` 是 SQLite 专有,PostgreSQL 上不执行
+- **优先级**: 高(与 #11 一致,数据库返工)
+
+### #17 缺失数据库索引(PostgreSQL)
+- **文件**: `src/models/recording.py` 等
+- **问题**: 关键列无 `index=True`,PostgreSQL 不会自动建索引
+- **优先级**: 高(与 #11 一致,数据库返工)
+
+### #18 datetime 时区不一致(PostgreSQL)
+- **文件**: 多模型和 API 混用 `datetime.now` / `datetime.utcnow`
+- **问题**: 跨时区部署时时间错乱
+- **优先级**: 高(与 #11 一致,数据库返工)
+
+---
+
+## 进度追踪
+
+| 编号 | 问题 | 状态 | 备注 |
+|------|------|------|------|
+| #8 | 下载接口重复代码抽取 | ✅ 已完成 | 优先级最高,已处理 |
+| #9 | 大文件拆分 | ✅ 已完成 | 优先级最高,已处理 |
+| #3 | error_msg 字段名修复 | ✅ 已完成 | 可改,已处理 |
+| #10 | 错误处理统一 | 🔴 已还原 | 全局错误处理器不需要,已撤销 |
+| #5 | 硬编码密钥警告 | ✅ 已完成 | 仅警告,已处理 |
+| #15 | 客户端超时和重试 | ✅ 已完成 | 同 #3/#10 优先级 |
+| #16 | 双入口文件端口 | ✅ 已完成 | 同 #3/#10 优先级 |
+| #20 | config.py 循环依赖 | ✅ 已完成 | 同 #3/#10 优先级 |
+| #21 | admin.py 绕过 config | ✅ 已完成 | 同 #3/#10 优先级 |
+| #11 | 统一错误返回格式 | 🟡 部分完成 | 高优先级,需完善日志 |
+| #19 | 统一日志格式 | ✅ 已完成 | 高优先级,已处理 |
+| #13 | sync_service engine 复用 | 📋 待做 | 中优先级,数据库返工 |
+| #14 | 数据库迁移跨库兼容 | 📋 待做 | 中优先级,数据库返工 |
+| #17 | 数据库索引 | 📋 待做 | 中优先级,数据库返工 |
+| #18 | 时区统一 | 📋 待做 | 中优先级,数据库返工 |
+| #6 | N+1 查询优化 | 📋 待做 | 中优先级,优化 |
+| #7 | 后台线程管理 | 📋 待做 | 中优先级,优化 |
+| #12 | 输入校验 | 📋 待做 | 中优先级,优化 |
+| #1 | 认证绕过 | ⏸️ 搁置 | 鉴权相关,暂不处理 |
+| #2 | 未认证接口泄露数据 | ⏸️ 搁置 | 鉴权相关,暂不处理 |
+| #4 | CSRF 防护失效 | ⏸️ 搁置 | 鉴权相关,暂不处理 |
diff --git a/speakr/docs/rework-notes/前后端边界梳理修改.md b/speakr/docs/rework-notes/前后端边界梳理修改.md
new file mode 100644
index 0000000..ba30cf5
--- /dev/null
+++ b/speakr/docs/rework-notes/前后端边界梳理修改.md
@@ -0,0 +1,323 @@
+# 前后端边界梳理修改记录
+
+更新时间:2026-06-23
+
+## 当前目标
+
+本轮重构的目标是把浏览器、Speakr 后端、算法服务之间的处理边界理顺:
+
+- 浏览器只负责音频采集、状态展示、用户操作。
+- Speakr 后端统一负责和 FunASR、ASR HTTP、LLM、投屏/外部服务通信。
+- 算法服务地址不再通过 `getUserConfig` 暴露给前端。
+- 草稿完成后由后端直接进入正式 Recording 处理流,不再由前端下载 blob 后二次上传。
+
+## 已完成修改
+
+### 后端实时 ASR WebSocket 代理
+
+新增 `src/api/asr_ws.py`,提供后端 WebSocket 入口:
+
+```text
+/ws/asr/live
+```
+
+经 nginx 暴露为:
+
+```text
+/tool/speakr/ws/asr/live
+```
+
+当前流程:
+
+- 前端只连接 Speakr 后端 WebSocket。
+- 后端读取 `FUNASR_WEBSOCKET_IP`,连接 FunASR `/websocket_offline`。
+- 前端发送 PCM binary chunk 给后端,后端转发给 FunASR。
+- FunASR 返回消息后,后端推回前端,尽量保持原 FunASR 返回格式。
+- FunASR 初始化参数由后端生成,包括 `mode`、`chunk_size`、`hotwords` 等。
+
+相关文件:
+
+- `src/api/asr_ws.py`
+- `src/flask_ext.py`
+- `src/api/__init__.py`
+- `requirements.txt`
+
+新增依赖:
+
+```text
+flask-sock
+websocket-client
+```
+
+### 前端实时 ASR 连接收口
+
+修改 `static/js/wsconnecter.js` 和 `static/js/app.js`。
+
+旧模式:
+
+```js
+wss://${wssBaseUrl.FUNASR_WEBSOCKET_IP}/websocket_offline
+```
+
+新模式:
+
+```text
+/tool/speakr/ws/asr/live?mode=online
+/tool/speakr/ws/asr/live?mode=offline
+```
+
+前端不再读取或使用 `FUNASR_WEBSOCKET_IP` 创建 WebSocket。
+
+保留了现有录音采集、16k PCM 分片、实时展示、`segmentList` 渲染逻辑。
+
+### 草稿 finalize 后端化
+
+修改 `src/api/draft_api.py`、`src/api/recording.py`、`src/api/__init__.py`、`static/js/app.js`。
+
+已完成:
+
+- 注册 `draft_bp` 到 `/api/draft`。
+- `POST /tool/speakr/api/draft//finalize` 不再返回音频 blob。
+- 后端合并草稿音频后直接创建正式 `Recording`。
+- 后端绑定 notes、tags、ASR 参数。
+- 后端启动现有 `transcribe_audio_task`。
+- 前端拿到 `{ success: true, recording }` 后进入现有状态轮询。
+
+为复用普通上传链路,`src/api/recording.py` 新增:
+
+```python
+create_recording_from_existing_file(...)
+```
+
+普通 `/upload` 和草稿 finalize 都走这个函数创建 Recording、绑定标签、启动转写线程。
+
+### 外部算法/服务配置收口
+
+修改 `src/api/main.py`、`static/js/app.js`、`templates/account.html`。
+
+已完成:
+
+- `getUserConfig` 不再返回 `FUNASR_WEBSOCKET_IP`。
+- `getUserConfig` 不再返回 `SCREEN_PUSH_IP`。
+- 原本前端直连 `SCREEN_PUSH_IP` 的请求改为后端代理。
+
+新增后端代理路径:
+
+```text
+/tool/speakr/api/external/summarize_text
+/tool/speakr/api/external/submit
+/tool/speakr/api/external/prompts/list
+/tool/speakr/api/external/recommend_prompts
+/tool/speakr/api/external/summarize_text_stream
+```
+
+新增依赖:
+
+```text
+requests
+```
+
+### 部署配置调整
+
+修改:
+
+- `Dockerfile`
+- `deployment/setup.sh`
+- `docs/getting-started/installation.md`
+
+Gunicorn 增加:
+
+```text
+--threads 8
+```
+
+用于支持后端 WebSocket 长连接。
+
+## 当前运行状态
+
+### 主流程
+
+实时转写主流程当前可用,后端无明显异常,前端 Network 中主要连接 `/tool/speakr/ws/asr/live`。
+
+### 已知遗留问题:停止阶段 Invalid frame header
+
+停止录音时前端仍可能打印:
+
+```text
+WebSocket connection to 'wss://localhost:8083/tool/speakr/ws/asr/live?mode=online' failed: Invalid frame header
+WebSocket connection to 'wss://localhost:8083/tool/speakr/ws/asr/live?mode=offline' failed: Invalid frame header
+```
+
+当前处理情况:
+
+- 已移除前端消息回调里基于 `is_final.value == true` 的硬关闭。
+- 已增加 `wsFinish()`,结束录音时优先发送 stop 并等待后端 closed 状态或短超时。
+- 已调整后端 stop 后等待 FunASR final 消息再退出。
+- 已撤掉后端显式 `client_ws.close()`,让 Flask-Sock handler return 后自然关闭。
+
+目前判断:
+
+- 问题更像 WebSocket 关闭阶段的代理/框架兼容性问题,而不是主流程架构问题。
+- 现阶段不影响正常使用,先记录,后续结合 nginx access/error log、浏览器 Network close code、后端日志继续定位。
+
+后续排查方向:
+
+- 抓 nginx 对应时刻 `/tool/speakr/ws/asr/live` 的 access log 和 error log。
+- 确认 `/tool/speakr/ws/` location 一定优先于普通 `/tool/speakr/` location。
+- 确认 nginx 没有在 WebSocket 关闭阶段返回普通 HTTP 错误页。
+- 检查当前 Flask-Sock/simple-websocket 与实际运行服务器的关闭帧兼容性。
+- 如仍无法消除,可考虑把实时 ASR 代理独立为 ASGI/WebSocket 服务。
+
+### 已知遗留问题:停止阶段 LLM 矫正请求
+
+停止录音时可能伴随:
+
+```text
+LLM矫正出错: TypeError: Failed to fetch
+```
+
+来源:
+
+```text
+/tool/speakr/api/asr/correct
+```
+
+可能原因:
+
+- offline final 到达后触发 LLM 矫正,但录音已经进入停止/收尾阶段。
+- 请求被页面状态、CSRF refresh、网络或后端瞬时状态影响。
+
+后续建议:
+
+- 结束录音后禁止新发起 LLM 矫正,只等待已发出的请求。
+- 给 `getJsonMessage2` 增加停止态判断。
+- 后端为 `/api/asr/correct` 增加更明确日志。
+
+## 需要手动同步的非 git 配置
+
+下面两类改动非常重要,但通常不会随 git diff 自动同步给其他成员。
+
+### 1. `.env` 必须同步
+
+后端代理模式下,`FUNASR_WEBSOCKET_IP` 不能再指向 nginx 外部入口,例如:
+
+```env
+FUNASR_WEBSOCKET_IP=localhost:8083
+```
+
+这个旧值在新架构下会让 Speakr 后端连回 nginx,而不是直接连 FunASR。
+
+应改为真实 FunASR WebSocket 服务地址。按当前环境,建议:
+
+```env
+FUNASR_WEBSOCKET_IP=ws://10.100.3.22:10095/websocket_offline
+```
+
+或:
+
+```env
+FUNASR_WEBSOCKET_IP=ws://10.100.3.22:10095
+```
+
+代码会在没有 path 时自动补 `/websocket_offline`,但推荐写完整路径。
+
+`SCREEN_PUSH_IP` 仍由后端读取,用于 `/api/external/...` 代理。其他成员需要确认自己的 `.env` 中该值指向对应环境的真实服务地址。
+
+注意:修改 `.env` 后必须重启 Speakr 后端。
+
+### 2. nginx 配置必须同步
+
+需要新增 WebSocket 专用 location,并放在普通 `/tool/speakr/` location 前面:
+
+```nginx
+location /tool/speakr/ws/ {
+ proxy_pass http://localhost:5000/ws/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Script-Name /tool/speakr;
+ proxy_redirect off;
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 3600s;
+ proxy_read_timeout 3600s;
+ proxy_buffering off;
+}
+```
+
+如果后端不是 `localhost:5000`,需要替换为实际 Speakr 后端地址。
+
+当前旧 FunASR 直连代理:
+
+```nginx
+location /websocket { ... }
+location /websocket_offline { ... }
+```
+
+新前端理论上不再需要。可以先保留,待新链路稳定后清理,避免其他页面或缓存仍依赖旧路径。
+
+修改 nginx 后需要:
+
+```bash
+nginx -t
+nginx -s reload
+```
+
+## 后续收尾清单
+
+### 清理旧 FunASR 前端遗留
+
+建议后续删除或整理:
+
+- `static/js/wsconnecter.js` 中的 `wssBaseUrl`、`getBaseUrlFromApi()`、`configwords`。
+- `static/js/wsconnecter.js` 中的 `getBaseHot()`。
+- FunASR demo 遗留变量:`isfilemode`、`file_ext`、`file_sample_rate` 等。
+- `getJsonMessageLegacy()`。
+
+### 整理实时 ASR 生命周期
+
+建议抽成明确 API:
+
+```text
+startRealtimeAsr()
+pauseRealtimeAsr()
+finishRealtimeAsr()
+disposeRealtimeAsr()
+```
+
+减少 `app.js` 中多处直接调用 `wsStop()`、`wsFinish()` 的分散逻辑。
+
+### 草稿链路收尾
+
+- 确认 `/api/draft//download` 是否仍需要保留用于预览。
+- 如果后续不需要草稿预览 blob,可删除该接口。
+- 为草稿 finalize 增加自动化测试,覆盖 segment 文件清理和 Recording 创建。
+
+### 测试补充
+
+建议补:
+
+- draft create -> segment -> finalize 返回 `202 + recording`。
+- finalize 后数据库生成正式 Recording。
+- finalize 后草稿记录和 segment 文件被清理。
+- `/upload` 原上传链路不回归。
+- `/ws/asr/live` 未登录、FunASR 不可达、FunASR 中途断开场景。
+- 前端停止/暂停/恢复不会向旧 WebSocket 继续发 chunk。
+
+### 配置拆分
+
+`src/config.py` 的 `as_dict()` 仍包含内部地址。只要该 dict 不返回前端,目前可接受;后续建议拆成:
+
+- 后端内部配置。
+- 前端可见 UI 配置。
+
+避免以后误把内部服务地址再次返回到页面。
+
+## 当前结论
+
+主要边界调整已经完成:前端不再直连 FunASR,不再读取算法服务地址;草稿 finalize 已后端化;外部服务地址也改为后端代理。
+
+当前剩余工作主要是运行收尾和清理:WebSocket 关闭阶段 `Invalid frame header`、停止阶段 LLM 矫正请求时机、旧 demo/旧直连逻辑删除、自动化测试、`.env` 和 nginx 配置同步。
diff --git a/speakr/docs/screenshots.md b/speakr/docs/screenshots.md
new file mode 100644
index 0000000..59e062a
--- /dev/null
+++ b/speakr/docs/screenshots.md
@@ -0,0 +1,303 @@
+# 截图展示
+
+通过我们的可视化画廊探索 Speakr 的强大功能。点击任意图片可查看完整尺寸。
+
+## 主界面
+
+### 仪表盘视图
+
+
+
+
+
带有高级筛选选项的主仪表盘
+
+
+
+
+
简洁的简单转录界面
+
+
+
+### 多语言支持
+
+
+
+
完整国际化支持,涵盖5种语言
+
+
+## 录音与上传
+
+### 新录音界面
+
+
+
+
+
直接在浏览器中上传或录制音频
+
+
+
+
+
录音时实时记录笔记
+
+
+
+### 录音选项
+
+
+
+
+
从指定的浏览器标签页录制音频
+
+
+
+
+
屏幕共享时捕获系统音频
+
+
+
+### 录音后处理
+
+
+
+
在处理前添加标签、备注和配置设置
+
+
+## 转录功能
+
+### 转录视图
+
+
+
+
+
简单转录视图搭配 AI 聊天面板
+
+
+
+
+
AI 生成的摘要及要点
+
+
+
+### 说话人识别
+
+
+
+
+
识别并标注录音中的说话人
+
+
+
+
+
基于片段的高级 ASR 转录编辑器
+
+
+
+## AI 驱动功能
+
+### 交互式聊天
+
+
+
+
+
针对录音内容提问并获取智能回答
+
+
+
+
+
使用 Markdown 笔记整理思路
+
+
+
+### 问询模式
+
+
+
+
+
在所有录音中进行语义搜索
+
+
+
+
+
自然语言搜索及上下文相关结果
+
+
+
+
+
在多个录音中查找相关内容
+
+
+
+## 组织与管理
+
+### 标签与筛选
+
+
+
+
+
多标签选择与智能提示叠加
+
+
+
+
+
创建带有自定义 AI 提示词和 ASR 设置的标签
+
+
+
+### 录音管理
+
+
+
+
+
编辑录音元数据和设置
+
+
+
+
+
管理标签以实现更好的组织
+
+
+
+## 分享与协作
+
+### 分享录音
+
+
+
+
+
生成带有可自定义权限的安全分享链接
+
+
+
+
+
简洁的公开分享录音视图
+
+
+
+### 管理分享
+
+
+
+
+
控制和撤销分享链接
+
+
+
+
+
所有已分享录音的总览
+
+
+
+## 用户设置
+
+### 账户配置
+
+
+
+
+
个人信息和语言偏好
+
+
+
+
+
配置个人 AI 摘要提示词
+
+
+
+### 说话人管理
+
+
+
+
管理说话人库及使用统计
+
+
+## 管理员功能
+
+### 用户管理
+
+
+
+
管理用户并监控系统使用情况
+
+
+### 系统配置
+
+
+
+
+
配置全局系统参数
+
+
+
+
+
为所有用户设置默认 AI 提示词
+
+
+
+### 监控与分析
+
+
+
+
+
监控系统健康状态和使用模式
+
+
+
+
+
管理语义搜索基础设施
+
+
+
+## 处理与重新处理
+
+
+
+
+
使用更新的设置重新处理录音
+
+
+
+
+
使用新提示词重新生成摘要
+
+
+
+---
+
+
diff --git a/speakr/docs/stylesheets/extra.css b/speakr/docs/stylesheets/extra.css
new file mode 100644
index 0000000..3db0711
--- /dev/null
+++ b/speakr/docs/stylesheets/extra.css
@@ -0,0 +1,572 @@
+/* Custom styles for Speakr documentation */
+
+/* Logo styling */
+.md-header__button.md-logo img,
+.md-header__button.md-logo svg {
+ height: 1.6rem;
+ width: auto;
+}
+
+/* Better code blocks */
+.highlight {
+ margin: 1em 0;
+}
+
+/* Custom admonition colors */
+.md-typeset .admonition.info,
+.md-typeset details.info {
+ border-color: rgb(0, 123, 255);
+}
+
+.md-typeset .admonition.info > .admonition-title,
+.md-typeset details.info > summary {
+ background-color: rgba(0, 123, 255, 0.1);
+ border-color: rgb(0, 123, 255);
+}
+
+/* Better image display */
+.md-typeset img {
+ border-radius: 8px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ margin: 1.5em 0;
+}
+
+/* Improve table styling */
+.md-typeset table:not([class]) {
+ margin: 1.5em 0;
+}
+
+.md-typeset table:not([class]) th {
+ background-color: var(--md-default-bg-color--light);
+ font-weight: 600;
+}
+
+/* Custom navigation styling */
+.md-nav__item--active > .md-nav__link {
+ font-weight: 600;
+}
+
+/* Better search results */
+.md-search-result__title {
+ font-weight: 600;
+}
+
+/* Responsive video embeds */
+.video-wrapper {
+ position: relative;
+ padding-bottom: 56.25%;
+ height: 0;
+ overflow: hidden;
+ max-width: 100%;
+ margin: 1.5em 0;
+}
+
+.video-wrapper iframe {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 8px;
+}
+
+/* Custom badges */
+.badge {
+ display: inline-block;
+ padding: 0.25em 0.6em;
+ font-size: 0.75em;
+ font-weight: 600;
+ line-height: 1;
+ color: #fff;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ border-radius: 0.25rem;
+ margin: 0 0.25em;
+}
+
+.badge-primary {
+ background-color: #007bff;
+}
+
+.badge-success {
+ background-color: #28a745;
+}
+
+.badge-warning {
+ background-color: #ffc107;
+ color: #212529;
+}
+
+.badge-danger {
+ background-color: #dc3545;
+}
+
+/* Screenshot styling */
+.screenshot-container {
+ max-width: 80%;
+ margin: 2em auto;
+}
+
+.screenshot-container img {
+ width: 100%;
+ height: auto;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+}
+
+/* Navigation cards grid */
+.grid.cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1.5em;
+ margin: 2em 0 3em 0;
+}
+
+.grid.cards .card {
+ background: var(--md-code-bg-color);
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: 12px;
+ padding: 1.75em;
+ text-align: center;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
+ overflow: hidden;
+}
+
+.grid.cards .card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ border-color: var(--md-primary-fg-color);
+}
+
+.grid.cards .card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: linear-gradient(90deg, var(--md-primary-fg-color), var(--md-accent-fg-color));
+ transform: scaleX(0);
+ transition: transform 0.3s ease;
+}
+
+.grid.cards .card:hover::before {
+ transform: scaleX(1);
+}
+
+.card-icon {
+ font-size: 2.5em;
+ margin-bottom: 0.5em;
+ opacity: 0.9;
+}
+
+.grid.cards .card h3 {
+ margin: 0.5em 0;
+ color: var(--md-default-fg-color);
+ font-size: 1.25em;
+}
+
+.grid.cards .card p {
+ color: var(--md-default-fg-color--light);
+ margin: 0.75em 0 1.25em 0;
+ line-height: 1.6;
+}
+
+.card-link {
+ display: inline-block;
+ color: var(--md-primary-fg-color);
+ text-decoration: none;
+ font-weight: 600;
+ transition: all 0.2s ease;
+}
+
+.card-link:hover {
+ color: var(--md-accent-fg-color);
+ transform: translateX(4px);
+}
+
+/* Feature cards grid */
+.feature-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 1.25em;
+ margin: 2em 0;
+}
+
+.feature-card {
+ background: var(--md-code-bg-color);
+ padding: 1.5em;
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: 8px;
+ transition: all 0.3s ease;
+}
+
+.feature-card:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ transform: translateY(-2px);
+ border-color: var(--md-primary-fg-color--light);
+}
+
+.feature-card h4 {
+ margin: 0 0 0.75em 0;
+ color: var(--md-default-fg-color);
+ font-size: 1.1em;
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+}
+
+.feature-card ul {
+ margin: 0;
+ padding-left: 1.25em;
+ color: var(--md-default-fg-color--light);
+}
+
+.feature-card li {
+ margin: 0.4em 0;
+ font-size: 0.95em;
+}
+
+/* Help section grid */
+.help-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 2em;
+ margin: 2em 0;
+}
+
+.help-card {
+ background: var(--md-code-bg-color);
+ padding: 1.75em;
+ border-radius: 8px;
+ border: 1px solid var(--md-default-fg-color--lightest);
+}
+
+.help-card h4 {
+ margin-top: 0;
+ color: var(--md-default-fg-color);
+ font-size: 1.2em;
+}
+
+.help-card p {
+ color: var(--md-default-fg-color--light);
+ margin: 0.75em 0;
+}
+
+.help-card ul {
+ margin: 0.5em 0;
+ padding-left: 1.5em;
+}
+
+.help-card li {
+ margin: 0.5em 0;
+}
+
+.help-card a {
+ color: var(--md-primary-fg-color);
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.help-card a:hover {
+ color: var(--md-accent-fg-color);
+ text-decoration: underline;
+}
+
+/* Custom tabs styling */
+.md-typeset .tabbed-set > input:checked + label {
+ font-weight: 600;
+}
+
+/* Improve inline code */
+.md-typeset code {
+ padding: 0.15em 0.3em;
+ border-radius: 0.25em;
+}
+
+/* Version selector styling */
+.md-version__current {
+ font-weight: 600;
+}
+
+/* Footer improvements */
+.md-footer-meta {
+ background-color: var(--md-footer-bg-color);
+}
+
+/* Dark mode adjustments */
+[data-md-color-scheme="slate"] .grid.cards .card {
+ background: var(--md-code-bg-color);
+ border-color: var(--md-default-fg-color--lighter);
+}
+
+[data-md-color-scheme="slate"] .feature-card {
+ background: var(--md-code-bg-color);
+}
+
+[data-md-color-scheme="slate"] .help-card {
+ background: var(--md-code-bg-color);
+}
+
+/* Guide cards for User/Admin guides */
+.guide-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1.5em;
+ margin: 2em 0 3em 0;
+}
+
+.guide-card {
+ background: var(--md-code-bg-color);
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: 12px;
+ padding: 1.75em;
+ text-align: center;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
+ overflow: hidden;
+}
+
+.guide-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ border-color: var(--md-primary-fg-color);
+}
+
+.guide-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: linear-gradient(90deg, var(--md-primary-fg-color), var(--md-accent-fg-color));
+ transform: scaleX(0);
+ transition: transform 0.3s ease;
+}
+
+.guide-card:hover::before {
+ transform: scaleX(1);
+}
+
+.guide-card .card-icon {
+ font-size: 2.5em;
+ margin-bottom: 0.5em;
+ opacity: 0.9;
+}
+
+.guide-card h3 {
+ margin: 0.5em 0;
+ color: var(--md-default-fg-color);
+ font-size: 1.25em;
+}
+
+.guide-card p {
+ color: var(--md-default-fg-color--light);
+ margin: 0.75em 0 1.25em 0;
+ line-height: 1.6;
+ font-size: 0.95em;
+}
+
+/* Tips grid */
+.tips-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 1.25em;
+ margin: 2em 0;
+}
+
+.tip-card {
+ background: var(--md-code-bg-color);
+ padding: 1.25em;
+ border-radius: 8px;
+ border: 1px solid var(--md-default-fg-color--lightest);
+ transition: all 0.2s ease;
+}
+
+.tip-card:hover {
+ border-color: var(--md-primary-fg-color--light);
+ transform: translateY(-2px);
+}
+
+.tip-card h4 {
+ margin: 0 0 0.5em 0;
+ color: var(--md-default-fg-color);
+ font-size: 1em;
+}
+
+.tip-card p {
+ margin: 0;
+ color: var(--md-default-fg-color--light);
+ font-size: 0.9em;
+ line-height: 1.5;
+}
+
+/* Responsibility grid */
+.responsibility-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 1.25em;
+ margin: 2em 0;
+}
+
+.resp-card {
+ background: linear-gradient(135deg, var(--md-code-bg-color), var(--md-default-bg-color--light));
+ padding: 1.5em;
+ border-radius: 10px;
+ border: 1px solid var(--md-default-fg-color--lightest);
+}
+
+.resp-card h4 {
+ margin: 0 0 0.75em 0;
+ color: var(--md-primary-fg-color);
+ font-size: 1.1em;
+}
+
+.resp-card p {
+ margin: 0;
+ color: var(--md-default-fg-color--light);
+ font-size: 0.9em;
+ line-height: 1.5;
+}
+
+/* Action cards */
+.action-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1.25em;
+ margin: 2em 0;
+}
+
+.action-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 1em;
+ background: var(--md-code-bg-color);
+ padding: 1.25em;
+ border-radius: 8px;
+ border: 1px solid var(--md-default-fg-color--lightest);
+ transition: all 0.2s ease;
+}
+
+.action-card:hover {
+ border-color: var(--md-primary-fg-color);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.action-icon {
+ font-size: 1.5em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2em;
+ height: 2em;
+ background: var(--md-primary-fg-color--light);
+ color: white;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.action-card strong {
+ display: block;
+ margin-bottom: 0.25em;
+ color: var(--md-default-fg-color);
+}
+
+.action-card p {
+ margin: 0;
+ color: var(--md-default-fg-color--light);
+ font-size: 0.85em;
+}
+
+/* Best practices */
+.best-practices {
+ display: grid;
+ gap: 1em;
+ margin: 2em 0;
+}
+
+.practice-item {
+ background: var(--md-code-bg-color);
+ padding: 1em 1.25em;
+ border-left: 4px solid var(--md-primary-fg-color);
+ border-radius: 4px;
+}
+
+.practice-item strong {
+ color: var(--md-default-fg-color);
+ font-size: 1em;
+}
+
+.practice-item p {
+ margin: 0.5em 0 0 0;
+ color: var(--md-default-fg-color--light);
+ font-size: 0.9em;
+}
+
+/* Help section styling */
+.help-section {
+ background: var(--md-code-bg-color);
+ padding: 1.5em;
+ border-radius: 8px;
+ margin: 2em 0;
+}
+
+.help-item {
+ display: flex;
+ align-items: center;
+ gap: 1em;
+ margin: 0.75em 0;
+}
+
+.help-icon {
+ font-size: 1.25em;
+ display: inline-block;
+ width: 1.5em;
+}
+
+.help-item span:last-child {
+ color: var(--md-default-fg-color--light);
+}
+
+.help-item a {
+ color: var(--md-primary-fg-color);
+ text-decoration: none;
+}
+
+.help-item a:hover {
+ text-decoration: underline;
+}
+
+.help-item code {
+ background: var(--md-code-bg-color);
+ padding: 0.2em 0.4em;
+ border-radius: 3px;
+ font-size: 0.9em;
+}
+
+/* Mobile optimization */
+@media screen and (max-width: 768px) {
+ .grid.cards {
+ grid-template-columns: 1fr;
+ }
+
+ .feature-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .help-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .md-typeset img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ .card-icon {
+ font-size: 2em;
+ }
+}
\ No newline at end of file
diff --git a/speakr/docs/troubleshooting.md b/speakr/docs/troubleshooting.md
new file mode 100644
index 0000000..7568c46
--- /dev/null
+++ b/speakr/docs/troubleshooting.md
@@ -0,0 +1,174 @@
+# 故障排除
+
+当 Speakr 出现问题时,本指南可帮助您快速识别和解决常见问题。大多数问题归为以下几类 — [安装问题](getting-started/installation.md)、转录失败、性能问题或特定功能异常。同时请参阅 [常见问题](faq.md) 了解常见疑问。了解从哪里着手以及需要检查什么可以节省大量排错时间。
+
+## 安装与设置问题
+
+### 容器无法启动
+
+当 Docker 容器拒绝启动或立即退出时,问题通常出在配置上。首先检查您的 [环境配置文件](getting-started.md#step-3-configure-your-transcription-service) — API 密钥中的任何拼写错误或不匹配的引号都可能导致启动失败。查看 [安装指南](getting-started/installation.md) 了解正确的设置方式。运行 `docker-compose logs app` 查看实际的错误信息。常见原因包括端口冲突(其他服务占用了 8899 端口)、缺少卷挂载或数据目录文件权限不正确。
+
+如果遇到数据库连接错误,请确保数据库文件具有正确的权限。容器以特定用户身份运行,需要对数据目录具有读写权限。在 Linux 系统上,您可能需要使用 `chown -R 1000:1000 ./uploads ./instance` 调整文件所有者。
+
+### 无法访问 Web 界面
+
+当 Speakr 成功启动但您无法访问 Web 界面时,通常是网络配置问题。首先使用 `docker ps` 验证容器是否实际在运行。检查端口 8899 是否正确映射 — docker-compose 文件的 ports 部分应显示 `"8899:8899"`。
+
+防火墙规则经常阻止访问,尤其是在云服务器上。确保防火墙、安全组(AWS)或网络策略中已开放 8899 端口。如果从其他机器访问,请注意 `localhost` 无效 — 需要使用服务器的实际 IP 地址或主机名。
+
+### 管理员登录失败
+
+如果无法使用 [管理员凭据](getting-started.md#step-4-configure-admin-account) 登录,首先验证您使用的是环境配置文件中完全相同的用户名和密码。有关用户管理问题,请参阅 [管理员指南](admin-guide/user-management.md)。这些值区分大小写,必须完全匹配。检查 Docker 日志中的管理员用户创建消息 — 首次启动时应看到 "Admin user created successfully"。
+
+有时如果密码不符合要求,管理员用户创建会静默失败。确保管理员密码至少为 8 个字符。如果管理员用户未创建,您可能需要删除数据库文件并重启容器以重新触发初始化。
+
+## 转录问题
+
+### 转录从未开始
+
+当录音一直处于 "pending" 状态时,后台处理器可能已停止。检查日志中是否有关于 [转录服务](features.md#multi-engine-support) 的错误消息。在 [向量存储](admin-guide/vector-store.md) 管理面板中监控处理状态。API 密钥问题是最常见的原因 — 验证您的 OpenAI 或 OpenRouter API 密钥是否有效且具有可用额度。
+
+网络连接问题也可能阻止转录。容器需要能够访问外部 API 端点。如果您在公司代理后面,需要在 Docker 环境中配置代理设置。
+
+### 转录立即失败
+
+快速失败通常表明 API 身份验证问题。仔细检查环境配置文件中的 API 密钥。请注意 OpenAI 和 OpenRouter 使用不同的密钥格式。OpenAI 密钥以 "sk-" 开头,而 OpenRouter 密钥格式不同。确保您为已配置的服务使用了正确的密钥。
+
+API 速率限制或额度不足也会导致立即失败。登录您的 API 提供商仪表板检查使用情况和限制。某些 API 计划的速率限制较为严格,Speakr 处理大文件时可能超出限制。
+
+### ASR 端点返回 405 或 404 错误
+
+如果使用 Whisper ASR webservice 时遇到 "405 Method Not Allowed" 或 "404 Not Found" 错误,请检查您的 ASR_BASE_URL 配置。URL 不应包含尾部注释或描述 — 删除环境配置文件中 # 符号之后的任何内容。对于 Whisper ASR webservice,仅使用基础 URL,如 `http://whisper-asr:9000`,不要在末尾添加 `/asr`。
+
+使用 Docker Compose 时,始终使用容器名称而非 IP 地址进行服务通信。不要使用 `http://192.168.1.132:9000`,而应使用 `http://whisper-asr-webservice:9000`,其中 `whisper-asr-webservice` 是您的容器名称。
+
+### 转录质量不佳
+
+转录准确率高度依赖于音频质量。背景噪音、多个重叠说话人或麦克风摆放位置不当都会降低结果质量。AI 模型在清晰的单人音频或分离良好的多人音频上效果最佳。
+
+语言不匹配也会导致结果不佳。如果您在设置中指定了特定的转录语言,但上传了不同语言的音频,准确率会受到影响。请设置正确的语言或留空以启用自动检测。
+
+对于多人录音,使用 [带说话人分离功能的 ASR 端点](features.md#speaker-diarization) 可显著提高可用性。了解如何在转录后 [识别说话人](user-guide/transcripts.md#speaker-identification),即使原始转录准确率相似。
+
+### 中文转录问题
+
+对于 [中文语言转录](features.md#language-support),模型选择至关重要。有关更多详细信息,请参阅 [关于语言支持的常见问题](faq.md#can-speakr-transcribe-languages-other-than-english)。
+
+**重要提示**:Distil 模型(如 distil-large-v3)不支持正确的中文转录。即使您将语言设置为 "zh",这些模型也可能将中文音频识别为英文并产生错误输出。对于中文内容,请始终使用完整版的 large-v3 模型或类似的非蒸馏模型。如果您的中文音频被转录为英文,或输出为拼音而非中文字符,请从 distil 模型切换到 large-v3。
+
+### 摘要语言与偏好不匹配
+
+如果点击 "Reprocess Summary" 后 [摘要](features.md#automatic-summarization) 回退到英文,尽管已设置了 [语言偏好](user-guide/settings.md#language-preferences),这可能是模型限制。配置 [自定义提示词](admin-guide/prompts.md) 以强制执行语言要求。某些模型(如 Qwen3-30B)不一定能正确遵循语言指令。尝试使用能更好地遵循语言指令的不同模型,或确保您的自定义提示词明确指定了输出语言。
+
+## 性能问题
+
+### 转录处理缓慢
+
+大音频文件自然需要更长时间处理,但过度延迟表明存在问题。检查您的 [服务器资源](getting-started.md#prerequisites) 并查看 [系统统计信息](admin-guide/statistics.md) 获取性能指标 — Speakr 需要足够的 CPU 和 RAM,尤其是在同时处理多个录音时。`docker stats` 命令可显示当前资源使用情况。
+
+网络速度影响转录时间,因为音频必须上传到 API 服务。慢速互联网连接会造成瓶颈,尤其是对于大文件。如果您经常处理长录音,请考虑分块设置。
+
+转录模型的选择影响速度。Whisper Large 更准确但比 Whisper Base 慢。如果速度比完美准确率更重要,请考虑通过 API 设置使用较小的模型。
+
+### 超过 25MB 的文件在 OpenAI 上失败
+
+OpenAI 的 Whisper API 有 25MB 文件大小限制。对于更大的文件,请在环境配置中启用 [分块](features.md#audio-chunking)。了解 [分块策略](faq.md#whats-the-difference-between-chunking-by-size-vs-duration):
+```
+ENABLE_CHUNKING=true
+CHUNK_LIMIT=20MB # or use duration: CHUNK_LIMIT=1400s
+CHUNK_OVERLAP_SECONDS=3
+```
+
+您可以按文件大小(MB)或时长(秒)指定分块限制。对于有特定时长限制的模型(如 Azure 的 1500 秒上限),请使用基于时长的分块。系统会自动拆分录音并重新组装转录。
+
+### 长录音 ASR 超时
+
+长录音(超过 30 分钟)在 ASR 处理期间可能超时。在管理设置 > 系统设置 > "ASR Timeout Seconds" 中增加超时时间。对于 2 小时的录音,请至少设置为 7200 秒(2 小时)。非常长的录音(如 3 小时以上)可能需要更长的超时时间,具体取决于您用于转录的 GPU(如果是本地转录)。
+
+### Web 界面响应迟缓
+
+浏览器在处理非常大的转录时性能会下降。超过 2 小时的录音可能生成大量文本,某些浏览器可能难以流畅显示。说话人标记转录的气泡视图尤其消耗资源。
+
+如果界面随时间推移逐渐变慢,请清除浏览器缓存。Speakr 会在本地缓存数据以提高性能,但此缓存可能会损坏。在 Chrome 或 Firefox 中,使用 Ctrl+Shift+R 硬刷新以重新加载最新资源。
+
+## 特定功能问题
+
+### 说话人识别不起作用
+
+[说话人分离](features.md#speaker-diarization) 需要 [ASR 端点](getting-started.md#option-b-custom-asr-endpoint-configuration),而非标准 Whisper API。在 [系统设置](admin-guide/system-settings.md) 中配置说话人设置。验证您已在环境配置文件中正确配置 ASR 设置。ASR_BASE_URL 应指向支持说话人分离的有效 ASR 服务。
+
+即使启用了 ASR,您也必须在上传或重新处理录音时明确请求说话人分离。Speakr 默认应执行此操作,但用户设置可能会覆盖此行为。检查说话人数量设置 — 如果将最小和最大说话人数都设置为 1,说话人分离实际上已禁用。对于大多数录音,使用 2-6 个说话人的合理范围。
+
+转录后,说话人显示为通用标签(SPEAKER_01 等)。您必须手动 [识别说话人](user-guide/transcripts.md#speaker-identification),方法是点击标签并分配名称。在账户设置中管理您的 [说话人库](user-guide/settings.md#speakers-management-tab)。
+
+### WhisperX 显示 UNKNOWN_SPEAKER
+
+如果 WhisperX 仅显示 "UNKNOWN_SPEAKER" 而不是编号说话人(SPEAKER_00、SPEAKER_01 等),请检查以下常见问题:
+
+1. **错误的 ASR_ENGINE**:您必须在 ASR 容器的 Docker 环境中使用 `ASR_ENGINE=whisperx`。`faster_whisper` 引擎不支持说话人分离,尽管它可以转录音频。
+
+2. **缺少或无效的 HF_TOKEN**:ASR 容器需要有效的 HuggingFace 令牌来下载说话人分离模型。确保您的 ASR 容器配置中设置了 `HF_TOKEN` 环境变量。
+
+3. **未启用 ASR_DIARIZE**:虽然当 `USE_ASR_ENDPOINT=true` 时应自动启用,但如果未检测到说话人,请在 Speakr .env 文件中显式设置 `ASR_DIARIZE=true`。
+
+4. **Docker 网络问题**:如果在同一 docker-compose 中使用 Speakr 和 ASR webservice 容器,容器必须通过服务名称通信(如 `http://whisper-asr:9000`),而非 localhost 或外部 IP。
+
+检查 ASR 容器日志中的 pyannote/VAD 消息,以确认说话人分离模型已正确加载。
+
+### Mac 上的 ASR 服务显示 GPU 错误
+
+如果在 macOS 上运行 ASR webservice 并遇到 GPU 相关错误或 "no matching manifest" 错误:
+
+- **使用 CPU 镜像**:将 `onerahmet/openai-whisper-asr-webservice:latest-gpu` 替换为 `onerahmet/openai-whisper-asr-webservice:latest`
+- **移除 GPU 配置**:从 docker-compose.yml 中删除包含 GPU 设备预留的整个 `deploy` 部分
+- **预期处理速度较慢**:基于 CPU 的转录可以工作,但比 GPU 加速慢得多
+
+这是 macOS 上 Docker 的限制 — GPU 直通不受支持,因为 Docker 运行在 Linux VM 中。请参阅 [常见问题](faq.md#can-i-use-the-asr-webservice-for-speaker-diarization-on-mac) 了解完整的 Mac 配置。
+
+### 分享链接不起作用
+
+[分享](user-guide/sharing.md) 功能要求您的 Speakr 实例可通过互联网使用 HTTPS 访问。请参阅 [分享要求](user-guide/sharing.md#requirements-for-sharing) 和 [安全注意事项](user-guide/sharing.md#security-and-privacy-considerations)。本地安装或非 SSL 设置无法生成有效的分享链接。分享按钮将禁用或显示错误,说明相关要求。
+
+如果您的实例满足要求但分享仍然失败,请检查环境配置中配置的 URL 是否与实际情况匹配。URL 不匹配会导致分享链接指向错误位置。URL 必须与外部用户访问您实例时使用的地址完全一致。
+
+### 询问模式无结果
+
+[语义搜索](user-guide/inquire-mode.md) 需要正确安装和初始化嵌入模型。在管理设置中查看 [向量存储选项卡](admin-guide/vector-store.md),并查看 [向量存储故障排除](admin-guide/vector-store.md#troubleshooting-common-issues) — 应显示 "Available" 状态。如果不是,sentence-transformers 库可能缺失或加载失败。
+
+所有录音在可搜索之前都需要进行处理。向量存储选项卡显示已处理与待处理的录音数量。如果自动处理停滞,请使用处理按钮手动触发嵌入生成。
+
+查询表述方式非常重要。[询问模式](user-guide/inquire-mode.md) 理解上下文和含义,而不仅仅是关键词。在用户指南中了解 [有效的搜索策略](user-guide/inquire-mode.md#asking-effective-questions)。提出完整的问题,而不是输入孤立的单词。"我们关于预算做了什么决定?" 比仅输入 "预算 决定" 效果更好。
+
+## 其他注意事项
+
+### 录音免责声明(法律合规)
+
+在许多司法管辖区,您必须告知参与者他们正在被录音。在 [系统设置](admin-guide/system-settings.md#recording-disclaimer) 中启用录音免责声明。查看 [关于录音合规的常见问题](faq.md#do-i-need-to-inform-people-theyre-being-recorded)。设置在任何录音开始前显示的自定义文本,例如关于同意要求的法律通知。此功能在具有严格录音法律的地区(如澳大利亚或加利福尼亚州)尤为重要。
+
+### 离线部署
+
+Speakr 可以完全离线运行,因为所有依赖项都内置在 Docker 镜像中。对于离线部署,使用 Ollama 的本地模型进行 [文本生成](features.md#automatic-summarization),并确保您的 ASR 端点在本地托管。正确配置后,系统无需互联网访问即可工作。
+
+### 非 Docker 安装
+
+虽然 Docker 是唯一官方支持的安装方式,但您可以尝试使用 npm 和 Python 进行手动安装。您需要自行处理依赖项、环境设置和配置。此方式不推荐用于常规使用,您需要独立排查问题。
+
+## 获取帮助
+
+### 检查日志
+
+Docker 日志包含宝贵的调试信息。使用 `docker-compose logs -f app` 查看实时日志。查找与问题发生时间对应的 ERROR 或 WARNING 消息。Python 追溯信息表明可能是代码级问题,可能需要寻求支持。
+
+对于 ASR 问题,同时检查 ASR 容器日志:`docker-compose logs -f whisper-asr-webservice`
+
+### 系统信息
+
+请求帮助时,请提供账户设置中 About 选项卡的系统配置信息。包括 Speakr 版本、配置的 AI 模型、转录服务类型以及任何错误消息。这些上下文信息有助于他人了解您的具体设置。
+
+### 社区支持
+
+GitHub 仓库的 issue 追踪器是报告错误或请求功能的最佳资源。请先搜索现有 issue — 可能有人已经遇到并解决了您的问题。创建新 issue 时,请包含重现问题的具体步骤。
+
+---
+
+下一步:[常见问题](faq.md) →
diff --git a/speakr/docs/user-guide/first-steps.md b/speakr/docs/user-guide/first-steps.md
new file mode 100644
index 0000000..e298679
--- /dev/null
+++ b/speakr/docs/user-guide/first-steps.md
@@ -0,0 +1,118 @@
+# 主界面
+
+Speakr 主界面是你花费大部分时间的地方。它被设计为一个三面板界面,让你可以轻松浏览录音、审阅转录文本并与内容交互。了解每个组件将帮助你更高效地使用音频录音。
+
+
+
+## 了解界面布局
+
+主界面分为三个独立但协同工作的部分。在左侧,你有录音侧边栏,其中列出了所有音频文件并可以进行筛选。中间面板显示所选录音的完整转录文本。如果你的录音经过 ASR 和说话人分离处理,你会看到说话人标签,并且可以点击任何句子跳转到音频中的对应位置。右侧面板包含 AI 生成的摘要、你的个人笔记以及交互式聊天界面。在中间和右侧面板的上方,横跨顶部的是包含录音信息和操作按钮的元数据栏。
+
+## 顶部导航栏
+
+屏幕顶部的导航栏提供对核心功能的快速访问:
+
+
+
+从左侧开始,你会看到 Speakr 标志,无论在应用程序的哪个位置点击它都会返回主界面。"询问"按钮打开语义搜索界面,允许你使用自然语言问题在所有录音中进行搜索。"新建录音"按钮是你创建新内容的入口,可以通过上传现有音频文件或直接在浏览器中录音。在右侧,你的用户名旁边有一个下拉菜单,其中包含设置、语言偏好和退出登录的访问选项。
+
+## 左侧边栏 - 录音列表和筛选器
+
+左侧边栏是你组织和管理录音的指挥中心。在顶部,你会看到一个搜索栏,可以快速按标题或内容查找录音。在下方,筛选系统帮助你缩小录音列表范围。
+
+### 使用筛选器
+
+
+
+Speakr 中的筛选系统既强大又直观。点击"活跃筛选器"展开筛选面板。你可以按标签筛选,标签显示为可点击选择或取消选择的彩色药丸形状。日期范围筛选器提供便捷的预设选项,如"今天"、"昨天"、"本周"和"上周",或者你可以使用日期选择器设置自定义日期范围。当筛选器处于活跃状态时,其数量会显示在筛选标签旁边,并且有一个清除按钮可以让你一次性重置所有筛选条件。
+
+### 录音列表
+
+在筛选器下方,你的录音以卡片形式显示,一目了然地展示关键信息。每张录音卡片醒目地显示标题,后面跟着元数据,包括参与者(如果使用了说话人分离)、录音日期和时长。彩色标签药丸显示分配给每条录音的类别。右侧的小图标指示录音的处理状态,已完成、处理中或失败的转录分别用不同颜色表示。
+
+## 中间面板 - 转录视图
+
+中间面板是你阅读和交互转录文本的地方。这是 Speakr 的核心,显示录音的完整文本。
+
+### 转录显示
+
+
+
+
+转录显示会根据音频的处理方式有所不同。如果你使用标准 Whisper API 端点,你会看到简单的连续文本转录,没有说话人识别。文本以单一叙述形式呈现,这非常适合单人录音或不需要说话人识别的场景。
+
+如果你启用了说话人分离的 ASR 端点,每位说话人的发言都会用彩色标签清晰标注,如"SPEAKER_01"、"SPEAKER_02"等。这些标签采用颜色编码,帮助你直观地跟踪多人对话。虽然时间戳不会在文本中显示,但点击任何句子都会自动将音频播放器移动到该精确位置,从而轻松导航录音。
+
+### 视图选项
+
+在转录文本上方,有几个按钮控制内容的显示方式。"复制"按钮允许你将整个转录文本复制到剪贴板。"编辑"按钮启用转录文本的内联编辑,如果你需要更正内容。
+
+如果你的录音经过说话人分离处理,你还会看到一个"简单/气泡"切换按钮,可以在流动文本视图和对话气泡格式之间切换。此切换仅在转录中存在说话人识别时可用。
+
+## 录音元数据栏
+
+元数据栏横跨中间和右侧面板的顶部,显示重要的录音信息并提供对操作的快速访问。从左到右,你会看到录音标题、参与者名称(如果已识别)、录音日期和时间、文件大小以及持续时间。此栏右侧的操作按钮允许你为录音添加书签(星形图标)、编辑录音详情(铅笔图标)、管理标签(标签图标)、重新处理转录(刷新图标)、分享录音(分享图标)以及删除录音(垃圾桶图标)。
+
+## 右侧面板 - 播放、摘要、笔记和聊天
+
+右侧面板包含三个标签页部分,通过 AI 驱动功能和个人注释来增强你的转录体验。
+
+### 播放控制
+
+在右侧面板的顶部,你会找到音频播放器控制。播放按钮用于开始和暂停播放,时间线显示你在录音中的当前位置。你可以点击时间线上的任何位置跳转到该点。速度控制允许你将播放速度从 0.5x 调整到 2x,这在快速浏览内容或仔细分析特定部分时特别有用。
+
+如果你的录音有说话人分离功能,点击转录文本中的任何句子都会自动将音频播放器定位到该确切时刻,提供录音的无缝导航体验。
+
+### 摘要标签页
+
+摘要标签页显示 AI 生成的录音概述。此摘要捕捉录音中的关键点、决策和待办事项,在审阅内容时为你节省时间。摘要在转录完成后自动生成,但如果需要,可以使用不同的设置重新生成。
+
+### 笔记标签页
+
+笔记标签页是你添加与录音相关的上下文、想法或后续事项的个人工作区。这些笔记是私密的且可搜索,非常适合添加会议结果、个人提醒或录音中未捕获的额外上下文。笔记支持 Markdown 格式,允许你创建列表、标题和格式化文本。
+
+### 聊天标签页
+
+聊天标签页提供一个交互式 AI 助手,可以回答关于你录音的问题。只需输入一个问题,如"做出了哪些主要决策?"或"John 对预算说了什么?",AI 将分析转录文本并提供相关答案。这在需要快速查找特定信息的长录音中特别有用。
+
+## 重新处理选项
+
+Speakr 提供两种类型的重新处理来使用改进的设置或不同的参数更新你的录音。
+
+### 完整重新处理
+
+
+
+完整重新处理使用 ASR 端点完全重新转录你的音频文件。这在你想要更改说话人分离设置或使用不同的转录模型时非常有用。通过点击元数据栏中的刷新图标并选择"转录重新处理"来访问此功能。你可以调整语言设置、说话人分离的最小和最大说话人数以及其他 ASR 特定选项。请注意,完整重新处理将覆盖你对转录文本所做的任何手动编辑。
+
+### 摘要重新处理
+
+
+
+摘要重新处理基于现有转录文本生成新的标题和摘要。这比完整重新处理更快,并保留你对转录文本所做的任何编辑。当你想要使用不同的 AI 设置重新生成摘要或对转录文本进行重大编辑后,请使用此选项。通过在同一刷新菜单中选择"摘要重新处理"来访问此选项。
+
+## 处理多个录音
+
+主界面设计用于高效处理大量录音库。你可以组合多个筛选器来精确查找所需内容。例如,你可以筛选上周所有带有"会议"标签且在转录中提到"预算"的录音。筛选器协同工作,每增加一个条件就会进一步缩小结果范围。
+
+当处理大量录音时,考虑使用排序选项按创建日期(上传或录音的时间)或会议日期(录音实际发生的时间)进行组织。当你上传较旧的录音或处理积压的音频文件时,这种区分特别有用。
+
+## 高效使用技巧
+
+要充分利用主界面,请尽早制定一致的标签策略。为不同的项目、客户或会议类型创建标签,并一致地应用它们。这一小段时间投入在你以后需要查找特定录音时会带来丰厚回报。
+
+搜索功能很强大,但使用特定术语时效果最佳。不要搜索"the"或"meeting"等常见词,而是搜索唯一术语、项目名称或录音中讨论的具体主题。
+
+会议结束后立即使用笔记部分,此时上下文在你的记忆中仍然清晰。当你几周或几个月后回顾录音时,这些个人注释将变得非常有价值。
+
+对于长录音,聊天界面可以节省大量时间。与其阅读整个一小时的转录文本,不如向 AI 提出关于所做决策、分配的待办事项或讨论主题的具体问题。
+
+请记住,界面会实时更新。当你上传新录音或转录完成时,界面将自动刷新以显示最新状态。你无需手动重新加载页面即可查看更新。
+
+## 创建新内容
+
+现在你已经了解如何在主界面中导航和交互现有录音,让我们探讨如何创建新内容。Speakr 提供两种方式将录音添加到你的库中:上传现有音频文件或直接在浏览器中录音。
+
+---
+
+下一篇:[录音和上传音频](recording.md) →
diff --git a/speakr/docs/user-guide/index.md b/speakr/docs/user-guide/index.md
new file mode 100644
index 0000000..68b3ef6
--- /dev/null
+++ b/speakr/docs/user-guide/index.md
@@ -0,0 +1,116 @@
+# 用户指南
+
+欢迎使用 Speakr 用户指南!本文档将帮助你掌握每个功能,从基本录音到高级 AI 驱动的搜索功能。
+
+## 快速导航
+
+
+
+
🖥️
+
主视图
+
导航三面板界面、使用智能过滤器并高效管理你的录音。
+
了解界面 →
+
+
+
+
🎙️
+
录音与上传
+
上传音频文件或直接在浏览器中录音,支持实时笔记功能。
+
开始录音 →
+
+
+
+
📝
+
处理转录文本
+
编辑转录文本、识别说话人并使用 AI 聊天探索你的内容。
+
掌握转录 →
+
+
+
+
+
+
🔗
+
分享录音
+
为录音创建安全分享链接,控制可见内容。
+
分享内容 →
+
+
+
+
⚙️
+
账户设置
+
配置偏好、管理说话人、创建标签和自定义 AI 提示词。
+
自定义 →
+
+
+
+## 成功小贴士
+
+
+
+
🏷️ 使用标签组织
+
尽早创建一致的标签系统。带有自定义 AI 提示词的标签可以为不同内容类型自动生成专业摘要。
+
+
+
+
🎤 录音最佳实践
+
会议时使用麦克风与系统音频组合录音。做实时笔记来标记重要时刻而不中断录音。
+
+
+
+
👥 识别说话人
+
转录后识别说话人以建立你的说话人库。未来录音将自动建议已知的说话人。
+
+
+
+
💬 高效聊天
+
对于长录音,问具体问题如"待办事项是什么?"而不是阅读整个转录文本。
+
+
+
+
🔎 智能搜索
+
Inquire 模式理解上下文,不仅仅是关键字。问自然问题来在所有录音中找到信息。
+
+
+
+
📊 自定义摘要
+
在设置中设置个人摘要提示词以获得适合你特定需求和工作流程的摘要。
+
+
+
+## 入门
+
+学习 Speakr 的最佳方式是实践。从这些简单步骤开始:
+
+1. **上传测试文件** - 尝试短音频录音以了解转录过程
+2. **探索界面** - 点击浏览不同的视图和面板
+3. **尝试 AI 聊天** - 询问关于你的录音的问题以查看 AI 的实际效果
+4. **创建你的第一个标签** - 从一开始就建立组织
+5. **测试 Inquire 模式** - 在你的录音中搜索特定内容
+
+当你对基础感到舒适时,探索高级功能,如说话人分离、自定义 AI 提示词和批量操作。
+
+## 需要帮助?
+
+
+
+---
+
+准备好成为 Speakr 高级用户了吗?从[第一步](first-steps.md)开始 →
diff --git a/speakr/docs/user-guide/inquire-mode.md b/speakr/docs/user-guide/inquire-mode.md
new file mode 100644
index 0000000..6dc2101
--- /dev/null
+++ b/speakr/docs/user-guide/inquire-mode.md
@@ -0,0 +1,100 @@
+---
+layout: default
+title: Inquire Mode
+parent: User Guide
+nav_order: 4
+---
+
+# Inquire 模式 - 语义搜索
+
+Inquire 模式将整个录音库转化为一个智能知识库,你可以使用自然语言问题进行搜索。无需逐个查找录音或努力回忆哪次会议包含了特定讨论,你只需提出问题,即可获得来自所有相关录音的全面答案。
+
+
+
+## 理解 Inquire 模式
+
+把 Inquire 模式想象成一位知识渊博的助手,他听过了你库中的每一条录音,并能立即回忆和综合来自任何录音的信息。当你提出问题时,系统会搜索所有转录内容,理解你查询背后的上下文和含义,并提供带有来源录音引用的连贯答案。
+
+这种语义搜索能力远超简单的关键词匹配。系统能够理解概念、关系和上下文。如果你询问"预算担忧",它会找到关于财务限制、成本超支、资金问题和资源限制的讨论——即使从未有人说过"budget concerns"这个确切短语。
+
+## 开始使用 Inquire 模式
+
+通过点击顶部导航栏中的 Inquire 按钮访问 Inquire 模式。界面打开后,顶部有一个干净的搜索区域,左侧有强大的筛选选项。搜索结果将显示在主内容区域,以易于阅读的格式组织呈现。
+
+Inquire 模式的魅力在于其简洁性。你不需要学习特殊的搜索语法或命令。只需像询问同事一样输入你的问题,系统会处理查找和综合相关信息的复杂性。
+
+## 使用筛选器聚焦搜索
+
+左侧边栏包含筛选器,帮助你在提问前将搜索范围缩小到特定录音。当你拥有庞大的录音库并希望聚焦于特定上下文时,这特别有用。
+
+标签筛选器允许你选择带有特定标签的录音,当你想要在特定项目、客户或会议类型内搜索时非常完美。如果你一致地标记录音,这将成为分割搜索的强大方式。系统会显示每个标签匹配多少条录音,帮助你了解搜索范围。
+
+说话人筛选器允许你聚焦于特定人员在各条录音中说的话。当你需要追踪某人的承诺、了解某人对各种话题的观点,或为与特定人员的会议做准备时,这非常有价值。
+
+日期范围筛选器帮助你聚焦最近的讨论或在特定时间段内搜索。这对于追踪讨论如何随时间演变,或查找特定季度或项目阶段的信息特别有用。
+
+## 提出有效的问题
+
+
+
+从 Inquire 模式获得出色结果的关键是提出清晰、具体的问题。当你提供上下文并明确你要查找的内容时,系统效果最佳。
+
+不要搜索像"deadline"这样的单个词,而是提出完整的问题,如"移动应用项目的截止日期有哪些?"这有助于 AI 不仅理解你想要什么信息,还能理解你为什么需要以及如何最实用地呈现它。
+
+该系统擅长处理不同类型的查询。你可以要求总结多次会议中的特定主题、请求行动项目或决策列表、搜索特定陈述或承诺、分析讨论中的模式或趋势,或识别问题和建议的解决方案。
+
+## 理解你的结果
+
+
+
+当 Inquire 模式返回结果时,它会提供一个综合答案,从所有相关录音中提取信息。响应不仅仅是搜索结果列表——它是一个连贯的叙述,将来自多个来源的信息整合为统一的答案。
+
+每条信息都包含格式为(录音 ID:XX)的引用,直接链接到源录音。点击这些链接会带你进入完整录音,在那里你可以看到完整的上下文。这种透明度确保你始终可以验证信息,并在需要时深入了解。
+
+系统智能地组织信息,将相关要点分组在一起,并以逻辑流程呈现。如果多个录音讨论同一主题,答案将综合这些讨论,展示对话如何随时间演变,同时保持对每个来源的清晰归属。
+
+## 实际应用场景
+
+Inquire 模式在众多真实场景中表现出色。在准备会议时,你可以快速回顾之前讨论过的议程主题、哪些问题尚未解决以及做出了哪些承诺。这确保你在充分知情的情况下参加会议,而无需花费数小时回顾旧录音。
+
+对于项目管理,该系统有助于追踪多次会议中的决策、识别分配给团队成员的所有行动项目,并监控项目需求或优先级如何随时间变化。你可以提出"产品发布已识别了哪些风险?"这样的问题,并获得来自所有相关讨论的综合列表。
+
+研究和分析变得更加高效。无论你是分析客户反馈模式、追踪竞争对手提及,还是识别访谈中反复出现的主题,Inquire 模式都能揭示可能隐藏在数小时录音中的洞察。
+
+该系统还可作为出色的合规和文档工具。你可以快速找到对客户做出的具体承诺、定位关于监管要求的讨论,或验证谈判中达成的协议。引用系统提供了追溯到原始录音的审计轨迹。
+
+## 高级搜索策略
+
+随着你对 Inquire 模式越来越熟悉,你可以开发复杂的搜索策略。从更广泛的问题开始,以了解可用信息的全貌,然后通过越来越具体的查询深入挖掘。这种迭代方法有助于你发现可能不知道直接询问的信息。
+
+战略性地组合筛选器。例如,在准备客户会议时,按该客户的标签和相关日期范围进行筛选,然后询问未决问题或承诺。这种聚焦方法会产生高度相关的结果,而不会受到不相关录音的干扰。
+
+注意系统如何解读你的问题。如果结果不完全符合预期,请尝试用不同的措辞或添加更多上下文重新提问。系统会从你录音中的模式中学习,因此使用与实际讨论一致的术语通常会产生更好的结果。
+
+## 技术考量
+
+Inquire 模式的强大功能来自其语义理解能力。当启用 embeddings(需安装 sentence-transformers)时,系统会创建数学意义上的表示,使其能够理解超越简单关键词匹配的概念。这意味着即使使用不同的词汇表达相同的想法,它也能找到相关信息。
+
+Speakr 使用 all-MiniLM-L6-v2 embedding 模型,这是一个经过深思熟虑的轻量级选择,在性能和资源效率之间取得了平衡。该模型生成 384 维向量,并且可以在仅 CPU 的系统上舒适运行,这对于大多数 Speakr 部署在标准服务器或个人机器上(没有专用 GPU)的情况至关重要。虽然存在更新、更大的 embedding 模型,但它们需要显著更多的计算资源,可能会使系统在典型硬件上变得缓慢或无法使用。MiniLM 模型为会话内容提供了出色的语义理解,即使在处理数百条录音时也能保持快速响应。
+
+系统以分块方式处理录音,使其能够高效地搜索非常长的录音。每个分块被独立分析,但结果会被综合以保持上下文和连续性。这种方法确保了速度和准确性,即使在大型录音库中也是如此。
+
+性能在正确设置的情况下扩展良好。录音的初始索引在转录后自动完成,后续搜索利用这个预计算的索引。响应时间通常从聚焦搜索的几秒钟到大型库中复杂查询的稍长时间不等。
+
+## 充分利用 Inquire 模式
+
+要最大化 Inquire 模式的价值,请保持良好的录音习惯。确保录音具有清晰的音频质量以获得准确的转录,使用说话人识别来追踪谁说了什么,并应用一致的标签来组织内容。输入数据越好,搜索功能就越强大。
+
+利用搜索的迭代特性。从一般性问题开始,查看结果,然后提出后续问题以深入挖掘。系统会在会话中保持上下文,使你能够根据初始结果轻松优化搜索。
+
+请记住,Inquire 模式是对标准录音界面的补充,而非替代。使用 Inquire 模式在录音之间进行发现和综合,但当你需要深入关注单个录音时,带有聊天界面的标准转录视图可能更合适。
+
+## 需要记住的限制
+
+尽管功能强大,Inquire 模式也有边界。它仅搜索转录文本,不直接搜索音频,因此转录中的任何错误都会影响搜索结果。系统无法推断录音中未明确陈述的信息——它查找和综合所说的内容,而非意图但未说出的内容。
+
+语言支持针对英语进行了优化,因为 embedding 模型(all-MiniLM-L6-v2)主要在英语文本上训练。其他语言可能以不同程度的成功率工作。如果非常长的录音超出处理限制,可能会被摘要而非完全索引。系统要求录音完全处理后才能被搜索,因此非常新的录音可能不会立即出现在结果中。
+
+---
+
+下一篇:[分享录音](sharing.md) →
diff --git a/speakr/docs/user-guide/recording.md b/speakr/docs/user-guide/recording.md
new file mode 100644
index 0000000..6f6f6b7
--- /dev/null
+++ b/speakr/docs/user-guide/recording.md
@@ -0,0 +1,200 @@
+# 录音与上传音频
+
+Speakr 提供了两种强大的方式来向您的资料库添加内容:上传现有音频文件进行转录,或直接在浏览器中录制新音频。两种方法都包含相同的强大功能,用于组织、[打标签](settings.md#tag-management-tab)和处理。录制完成后,您可以[处理转录内容](transcripts.md)并使用[AI 功能](../features.md#ai-powered-intelligence)。
+
+## 重要提示:录制的浏览器要求
+
+在开始录制之前,了解浏览器安全功能可能会影响您录制音频的能力非常重要,尤其是系统音频。出于安全原因,大多数浏览器要求使用 HTTPS 连接来进行音频录制功能。如果您通过本地方式访问 Speakr(而非 localhost),可能需要配置浏览器以允许在 HTTP 连接上进行音频录制。本页面底部提供了详细的浏览器配置说明。
+
+录制系统音频时,您需要授予屏幕共享权限,并特别启用"同时分享系统音频"选项。如果不启用,将只能录制您的麦克风。不同浏览器对该选项的表述可能有所不同,但这是捕获电脑音频的关键。
+
+## 访问新建录制界面
+
+点击顶部导航栏中的"+ New Recording"(新建录制)按钮即可访问录制界面。这将打开一个专用屏幕,您可以在其中上传文件或开始实时录制。
+
+
+
+## 上传音频文件
+
+上传界面在屏幕顶部提供了一个简单的拖放区域。您可以直接从文件管理器中将音频文件拖放到此区域,或点击该区域打开文件浏览器选择文件。
+
+Speakr 支持[广泛的音频和视频格式](../faq.md#what-audio-formats-does-speakr-support)。常见的音频格式如 MP3、WAV、M4A、FLAC、AAC 和 OGG 均可完美使用。您也可以上传视频文件,包括 MP4、MOV 和 AVI,Speakr 将提取并处理音频轨道。还支持 AMR、3GP 和 3GPP 等移动端录制格式。默认文件大小限制为 500MB,但管理员可以在[系统设置](../admin-guide/system-settings.md#maximum-file-size)中进行配置。对于超过 25MB 的文件使用 OpenAI 的情况,请参阅[分块配置](../troubleshooting.md#files-over-25mb-fail-with-openai)。
+
+上传文件时,它会立即出现在上传队列中,并带有显示上传状态的进度条。上传完成后,您可以添加[标签](settings.md#tag-management-tab)、设置自定义标题,并在开始转录前配置处理选项。标签可以包含[自定义 AI 提示](../admin-guide/prompts.md)用于专门处理。
+
+## 录制实时音频
+
+在上传区域下方,您将找到音频录制部分,包含三种不同的录制模式,每种模式针对不同的场景:
+
+
+
+### 麦克风录制
+
+红色麦克风按钮从您选择的麦克风捕获音频。此模式非常适合录制线下会议、个人语音笔记或访谈。当您点击此选项时,如果尚未授权,浏览器将请求麦克风权限。如果您连接了多个输入设备,可以选择使用哪个麦克风。
+
+### 系统音频录制
+
+蓝色系统音频按钮捕获通过电脑播放的所有声音。这非常适合录制您主要是在聆听的在线会议、网络研讨会、视频演示或电脑上播放的任何音频内容。此功能使用浏览器的屏幕捕获 API,因此开始录制时需要选择要共享的屏幕或应用程序。请务必在共享对话框中勾选"分享系统音频"复选框。
+
+### 组合录制(麦克风 + 系统音频)
+
+紫色组合按钮同时将您的麦克风和系统音频录制到单一同步轨道中。这是在线会议的推荐模式(当您作为积极参与者时),因为它能捕获对话的双方。系统会智能地混合两个音频源,确保清晰录制您的声音和会议音频。
+
+## 如何启用系统音频录制
+
+当您点击录制系统音频(或同时录制麦克风和系统音频)时,浏览器会显示屏幕共享对话框。起初这可能看起来令人困惑,因为您想要录制的是音频而不是共享屏幕,但这是浏览器提供系统音频访问的方式。
+
+### 从浏览器标签页录制
+
+如果要在特定浏览器标签页中播放音频(如 YouTube 视频、基于网页的会议平台如 Google Meet 或基于浏览器的 Zoom,或在线培训视频),请选择标签页选项。
+
+
+
+选择标签页时,您将看到所有打开标签页的预览。选择包含音频源的标签页,然后务必在对话框底部启用**"Also share tab audio"(同时分享标签页音频)**复选框。这个复选框至关重要——如果不启用,您将只能录制到静音。确切的措辞可能因浏览器而异(Chrome 可能显示"Share tab audio",而 Edge 的措辞可能略有不同),但总会有一个与音频相关的复选框必须勾选。
+
+此选项只会捕获特定标签页的音频,因此请确保在开始录制前,您感兴趣的所有音频都在该单个标签页内。
+
+### 从整个屏幕录制
+
+如果要录制的音频来自桌面应用程序或多个来源,请选择屏幕选项。
+
+
+
+这是以下录制场景的推荐选项:
+- 桌面应用程序,如 Zoom 客户端、Microsoft Teams、Skype 或 Discord
+- 来自多个浏览器标签页的同时音频
+- 您要捕获的系统声音或通知
+- 电脑上的任何音频源组合
+
+当您选择"您的整个屏幕"时,您将看到所有可用屏幕的预览(如果您有多个显示器)。选择哪个屏幕并不重要,因为 Speakr 只录制音频,不录制视频。关键步骤是在对话框底部启用**"Also share system audio"(同时分享系统音频)**复选框。如果不启用此复选框,您将只能录制到静音。
+
+**重要 macOS 限制:** "Also share system audio"选项在 Windows 和 Linux 上可用,但由于操作系统限制,在 macOS 上可能不可用。macOS 用户可能只能从浏览器标签页录制音频,而无法从整个系统或桌面应用程序录制。这是 macOS 安全模型施加的限制,而非 Speakr 或浏览器的限制。
+
+## 录制期间 - 实时界面
+
+开始录制后,界面会转换以显示活动的录制会话:
+
+ and take markdown notes.png)
+
+### 实时音频监控
+
+录制界面会为每个活动的输入源显示实时音频可视化器。使用麦克风录制时,您将看到显示语音电平的实时波形。录制系统音频时,单独的可视化器会显示电脑音频电平。这些可视化器帮助您确认音频是否被正确捕获以及电平是否适当。
+
+顶部有一个显眼的计时器,以分钟和秒显示已录制的时长,实时更新。在计时器下方,您将看到基于当前录制时长和质量设置的文件大小估算。这有助于您了解录制将占用多少存储空间。
+
+### 使用 Markdown 实时记笔记
+
+Speakr 最强大的功能之一是在录制时进行结构化笔记。笔记区域出现在音频可视化器下方,支持完整的 Markdown 格式。
+
+
+
+Markdown 编辑器包含一个格式化工具栏,带有常用格式化选项的按钮,如粗体、斜体、标题、引用、列表和链接。如果您愿意,也可以直接输入 Markdown 语法。编辑器支持所有标准 Markdown 元素,使您能够创建结构良好的笔记来补充录制内容。
+
+您的笔记会自动保存,并与录制内容关联。稍后查看录制内容时,它们会出现在 Notes(笔记)标签页中,并且可以与转录内容一起进行全文搜索。这使得捕获音频中可能未明确表达的重要上下文、决策或行动事项变得非常容易。
+
+实时笔记的常见用例包括:在会议期间捕获行动事项和截止日期、记录重要时间戳以便日后参考、记录参与者姓名和角色、记录决策及其理由,以及添加仅从音频中可能不清晰的上下文信息。
+
+## 完成录制
+
+停止录制或选择上传的文件后,您将看到完成界面,可以在其中添加元数据和配置处理选项:
+
+
+
+### 添加标签
+
+标签系统是 Speakr 最强大的组织功能之一。标签显示为彩色药丸形状,您可以选择它们来对录制内容进行分类。您可以为单个录制内容应用多个标签,从而轻松在不同类别之间交叉引用内容。
+
+
+
+在上传文件之前选择相关标签非常重要,这样在摘要生成时就会应用适当的摘要提示。每个标签可以有关联的自定义 AI 提示,这些提示会影响摘要的生成方式。例如,"会议"标签可能使摘要侧重于行动事项和决策,而"讲座"标签可能强调关键概念和学习要点。
+
+**智能提示堆叠:** 当您选择多个标签时,它们关联的提示会按照您选择的顺序进行拼接,从而实现指令的智能堆叠。这个强大的功能让您能够组合通用和特定的提示。例如,您可能有一个带有通用会议指令的标准"会议"标签,然后添加一个项目、客户或情境特定的标签,为摘要生成添加额外的上下文和指令。AI 将通过拼接通用会议要求和特定修改来应用这两组指令。这使您能够创建复杂的摘要规则,而无需为每种可能的组合创建单独的标签。要创建和管理带有自定义提示的标签,请参阅[账户设置中的标签管理](settings.md#tag-management-tab)。
+
+如界面所示,如果您添加了包含摘要指令的标签,UI 中会显示相应指示。顺序很重要——先选择主要标签,然后添加修饰标签来叠加额外的指令。标签在主视图中也是可搜索和可过滤的,方便日后查找相关录制内容。
+
+### 高级 ASR 选项
+
+如果您的管理员配置了支持说话人分离的 ASR 端点,您将看到可扩展的"高级 ASR 选项"部分。在此您可以指定转录语言(如果您的内容不是英文)、设置预期的最少和最多说话人数量以提高分离准确性,以及根据您的配置设置其他 ASR 特定选项。
+
+这些设置对于有已知参与者的会议特别有用,因为设置准确的说话人数量可以提高 AI 在转录中分离不同声音的能力。
+
+### 最终操作
+
+在模态框底部,您有三个选项可继续操作。"Upload"(上传)或"Start Processing"(开始处理)按钮会立即使用您选择的设置开始转录。录制内容将出现在您的资料库中,并带有处理指示器,而转录在后台运行。"Discard"(丢弃)选项会删除录制内容而不保存,适用于测试录制或捕获了错误内容的情况。某些配置还可能提供"Save Draft"(保存草稿)选项,用于存储录制内容而不立即处理。
+
+## 高质量录制的最佳实践
+
+### 优化音频质量
+
+转录质量始于录制质量。使用麦克风时,请找一个安静、回声和背景噪音最小的空间。柔软的家具和铺有地毯的房间通常比空旷的硬表面房间提供更好的声学效果。将麦克风保持在距离嘴部约 6-12 英寸的一致位置,并以稳定的音量说话。
+
+**手机录制的重要提示:** 许多智能手机具有积极的降噪算法,旨在增强单人通话。当使用手机麦克风录制会议或多人对话时,这些降噪功能可能会错误地将其他说话人识别为背景噪音并将其过滤掉。这可能导致距离手机较远的说话人声音模糊或缺失。这不是 Speakr 的限制,而是现代手机处理音频的方式。对于多人录制,请考虑使用不带降噪功能的外部麦克风,或将手机放置在所有说话人等距的位置。
+
+录制系统音频时,关闭可能产生通知声音或背景音频的不必要应用程序。如果录制视频通话,请确保网络连接稳定以避免音频中断。对于重要录制,使用有线网络而非 WiFi 可以提高稳定性。
+
+同时录制麦克风和系统音频时,请使用耳机以防止回声和反馈。这确保系统音频不会被麦克风拾取,否则会造成混乱的双重录制。
+
+### 从一开始就有效组织
+
+为录制内容的命名和标签制定一致的方法。在标题中包含关键信息,如会议类型、主题或项目名称。如果原始会议日期与上传日期不同,请务必在上传时更新会议日期,使按时间排序更加容易。
+
+在录制之前或之后立即应用标签,此时上下文在您的脑海中仍然清晰。考虑为不同类型的内容(如会议、讲座、访谈或个人笔记)创建一组标准标签。如果您的组织使用特定的项目代码或客户名称,请将这些纳入您的标签系统。
+
+利用实时笔记功能捕获仅从音频中可能不清晰的信息。在会议开始时记录参与者姓名,在做出关键决策时标记重要时间戳,并记录任何已共享但不会在音频中捕获的视觉信息。
+
+
+## 本地部署的浏览器配置
+
+### 系统音频录制要求
+
+系统音频录制需要特定的浏览器支持和配置。该功能在 Chrome 和其他基于 Chromium 的浏览器(如 Edge 或 Brave)中效果最佳。Firefox 的支持有限,而 Safari 目前完全不支持系统音频录制。
+
+对于使用 HTTPS 的生产部署,音频录制无需额外配置即可正常工作。但是,如果您通过 HTTP 访问 Speakr(localhost 除外),则需要配置浏览器以允许在不安全连接上进行音频录制。
+
+### Chrome 的 HTTP 访问配置
+
+如果您通过 HTTP(非 localhost)访问 Speakr,Chrome 默认会阻止音频录制。要启用它:
+
+1. 打开 Chrome 并导航到 `chrome://flags`
+2. 搜索 "Insecure origins treated as secure"
+3. 在文本字段中,输入您的 Speakr URL(例如 `http://192.168.1.100:8899`)
+4. 将下拉菜单设置为 "Enabled"
+5. 点击 "Relaunch" 以使用新设置重新启动 Chrome
+
+此配置告诉 Chrome 将您的特定 HTTP URL 视为安全连接,从而启用所有音频录制功能。
+
+### Firefox 的 HTTP 访问配置
+
+Firefox 需要不同的方法来启用 HTTP 站点的麦克风访问:
+
+1. 打开 Firefox 并导航到 `about:config`
+2. 出现警告时点击 "Accept the Risk and Continue"(接受风险并继续)
+3. 搜索 `media.devices.insecure.enabled`
+4. 双击该设置将其从 `false` 更改为 `true`
+5. 重启 Firefox 使更改生效
+
+请注意,即使有此设置,Firefox 的系统音频捕获也可能无法可靠工作。对于系统音频录制,强烈推荐使用 Chrome。
+
+### 安全注意事项
+
+这些浏览器配置会降低安全性,仅应用于本地开发或受信任的内部网络。对于生产部署,请务必使用带有适当 SSL 证书的 HTTPS。这确保所有浏览器功能正常工作,并为用户维护安全性。
+
+### 常见问题与解决方案
+
+如果未检测到麦克风,请首先检查浏览器是否有权访问它。点击地址栏中的挂锁或信息图标,确保允许麦克风访问。验证麦克风是否正确连接并在系统设置中被选为默认输入设备。如果问题仍然存在,请尝试使用不同的浏览器或重启当前浏览器。
+
+当系统音频录制无法工作时,最常见的问题是在选择共享内容时忘记勾选"Also share system audio"(同时分享系统音频)复选框。该复选框出现在屏幕/标签页选择对话框的底部,必须启用。确保您选择的是整个屏幕或浏览器标签页,而不是单独的应用程序窗口,因为应用程序音频共享通常不受支持。
+
+如果您在本地托管实例上无法录制音频,请检查是否通过 HTTP 访问 Speakr。出于安全原因,浏览器会阻止不安全连接上的音频录制。可以设置带有反向代理的 HTTPS,或使用上述说明配置浏览器以允许在您的特定 HTTP URL 上进行音频录制。
+
+如果录制意外停止,请检查可用磁盘空间,因为浏览器对本地存储有限制。确保网络连接稳定,尤其是启用了自动保存功能时。检查浏览器的开发者控制台(F12)以查找可能表明问题的任何错误消息。
+
+对于音频质量不佳的问题,首先检查麦克风位置和增益设置。外部 USB 麦克风通常比内置笔记本麦克风提供更好的质量。通过关闭窗户、关闭风扇和静音其他设备来减少背景噪音。如果录制系统音频,请确保源音频质量良好,因为 Speakr 无法改善劣质源音频。
+
+## 继续探索
+
+现在您已了解如何创建和上传录制内容,接下来可以探索如何处理转录内容,并利用 Speakr 的 AI 驱动功能进行摘要和对话。
+
+---
+
+下一篇:[处理转录内容](transcripts.md) →
diff --git a/speakr/docs/user-guide/settings.md b/speakr/docs/user-guide/settings.md
new file mode 100644
index 0000000..d86799f
--- /dev/null
+++ b/speakr/docs/user-guide/settings.md
@@ -0,0 +1,185 @@
+# 账户设置
+
+账户设置是个性化 Speakr 以满足您需求的控制中心。您在此处设置的每个偏好都会影响您的日常体验,从界面语言到 AI 如何总结录音。通过点击顶部导航栏中的用户名并选择"账户"来访问这些设置。
+
+## 账户信息标签页
+
+
+
+账户信息标签页提供您的个人资料、统计数据和账户操作的全面视图。
+
+### 个人信息
+
+在左侧,您可以找到用于更新全名、职位和公司或组织的字段。这些详细信息有助于在协作环境中识别您的身份,并为您的录音提供上下文。保持信息更新可确保同事能够识别您的贡献,管理员也能有效地管理用户。
+
+### 语言偏好
+
+三种不同的语言设置塑造您的整个 Speakr 体验。界面语言下拉菜单会立即将所有菜单、按钮和消息转换为您选择的语言——英语、西班牙语、法语、中文或德语。转录语言字段接受 ISO 语言代码(如"en"或"es")以优化识别准确率,但留空可启用多语言内容的自动检测。首选聊天机器人和总结语言确保所有 AI 生成的内容都以您选择的语言显示,无论源音频的语言是什么。
+
+### 账户统计数据
+
+右侧一目了然地显示您的录音指标。您将看到录音总数以及已完成处理的数量。下方,当前正在处理的录音数量和任何失败的录音数量可让您立即了解内容状态。
+
+### 用户详情
+
+您的用户名和电子邮件地址将在此处以只读字段显示。电子邮件作为您的登录凭据,未经管理员协助无法更改。
+
+### 账户操作
+
+账户操作部分提供对常用功能的快速访问。"前往录音"按钮直接将您带到录音库。"更改密码"按钮打开安全对话框以更新凭据。"管理说话人"链接提供跳转到说话人资料管理的快捷方式,该功能也可通过其专用标签页访问。
+
+## 自定义提示词标签页
+
+
+
+自定义提示词标签页解锁了 Speakr 最强大的功能之一——能够塑造 AI 如何解读和总结您的录音。
+
+### 您的自定义总结提示词
+
+大型文本区域接受自然语言的详细指令,这些指令将成为 AI 的指令集。将其想象为教助理如何准备会议笔记。您可以请求特定部分,如针对工程会议的"关键技术决策"或针对医疗咨询的"患者观察"。
+
+### 当前默认提示词
+
+在自定义提示词区域下方,您将看到留空时应用的默认提示词。这种透明度展示了 AI 遵循的基线指令——通常请求关键问题、决策和行动项目。以此为基础进行构建,或用您自己的方法完全替代。
+
+### 提示词优先级
+
+理解提示词优先级有助于您有效使用此功能。标签提示词具有最高优先级——当您用"法律"或"销售"标记录音时,与这些标签关联的任何提示词将覆盖其他所有内容。您的个人自定义提示词次之,适用于所有没有标签特定提示词的录音。再往下是 Speakr 管理员设置的默认值,最后是系统回退,确保始终生成总结。
+
+### 提示词叠加
+
+多个标签提示词在同时应用时会智能组合。标记为"客户"和"技术"的录音将接收两组指令,创建全面的总结,而无需复杂的单个提示词来覆盖所有场景。
+
+### 编写有效的提示词
+
+根据您实际使用总结的方式来编写提示词。您是否提取行动项目用于项目管理?寻找影响战略的决策?跟踪技术细节用于文档?您的提示词应准确请求这些后续步骤所需的内容。
+
+### 编写更好提示词的技巧
+
+可展开的技巧部分提供关于编写更好指令的指导。专注于清晰度,使用章节或要点构建请求,并明确说明您需要的详细程度。请记住,您的提示词适用于所有没有标签特定提示词的录音,因此应设计得具有通用性。
+
+## 共享转录文本标签页
+
+
+
+共享转录文本标签页提供您已共享的每条录音的完整可见性和控制权。
+
+### 初始状态
+
+首次访问此标签页时,您将看到"您尚未共享任何转录文本"——当您从任何录音创建第一个分享链接后,此状态将改变。
+
+### 分享信息显示
+
+共享录音后,每个条目将显示录音标题、创建分享链接的时间、包含的信息(总结和/或备注)以及访问的完整 URL。界面与您在分享模态框中看到的内容保持一致,维护了应用程序的一致性。
+
+### 管理分享设置
+
+"分享总结"复选框决定收件人是否看到 AI 生成的总结——便于快速概述,无需阅读完整转录文本。"分享备注"复选框控制对个人备注的访问,即您在录音后添加的额外想法和上下文。
+
+您可以随时修改这些设置,无需生成新链接。打开或关闭总结或备注,更改将立即应用于访问该链接的任何人。这种灵活性让您能够根据不断变化的需求或收件人的反馈调整分享设置。
+
+### 撤销访问
+
+删除按钮(垃圾桶图标)立即撤销访问。删除后,尝试访问该链接的任何人都会看到错误消息。当您意外分享给错误方或访问不再合适时,这种即时撤销提供了安全保障。请记住,删除无法收回收件人已查看或下载的信息。
+
+## 说话人管理标签页
+
+
+
+说话人管理标签页提供用于管理录音中识别的所有说话人的综合界面。
+
+### 自动创建说话人
+
+当您在转录编辑期间识别说话人时,系统会自动保存。随着您在录音中持续为参与者命名,系统会逐步建立您的说话人库。
+
+### 说话人卡片信息
+
+每个说话人卡片以清晰、可快速浏览的格式显示基本信息。说话人姓名醒目显示,后跟使用统计信息,显示他们在您的录音中被识别的次数。最后使用日期帮助您了解哪些说话人是当前的,哪些是历史记录的。说话人添加到库中的时间也会被跟踪以供参考。
+
+### 界面布局
+
+网格布局每行容纳多个说话人,有效利用屏幕空间同时保持可读性。每张卡片包含操作按钮——用于更新说话人详情的编辑图标和用于删除的删除图标。每张卡片上的红色垃圾桶图标提供单个删除,而底部的"全部删除"按钮可在需要时进行批量清理。
+
+### 库统计信息
+
+在界面底部,一个计数显示您保存的说话人总数——示例中为"已保存 77 个说话人"。这有助于您跟踪说话人库的规模,并了解何时需要进行维护。
+
+### 维护最佳实践
+
+定期维护可保持说话人库的相关性。移除不再出现在录音中的说话人,合并由轻微名称变体创建的重复项,并确保名称一致以获得更好的转录连贯性。定期审查有助于保持库的易于管理和实用性。
+
+## 标签管理标签页
+
+
+
+标签管理将简单标签转换为强大的处理指令。您创建的每个标签都具有多种功能——用于视觉识别的颜色、塑造 AI 总结的可选自定义提示词,甚至优化特定场景转录的默认 ASR(自动语音识别)设置。
+
+### 标签显示和功能
+
+界面将标签显示为卡片,每张卡片显示标签名称及其颜色指示器、自定义提示词(如果已配置)以及设置时的 ASR 默认值。在所示示例中,"BSB 会议"带有蓝色指示器,并指示以极其详细的方式总结转录文本。"法庭行动"使用红色,包括自定义提示词和设置为"最多 10 个"说话人的 ASR 默认值——非常适合多方参与的法律程序。绿色的"趣味"标签保持总结轻松有趣,展示了创意提示词如何服务于不同目的。
+
+### 创建新标签
+
+通过醒目的"创建标签"按钮创建新标签。系统将引导您选择名称、从调色板中选择颜色,以及可选地添加自定义提示词。当您使用高级转录服务时,会出现 ASR 默认值部分,允许您预设带有此标签的录音的预期说话人数量。
+
+### 提示词叠加
+
+应用多个标签时,标签提示词会智能叠加。如果您用"BSB 会议"和"学生会议"同时标记录音,两个提示词将组合在一起,创建满足所有指定要求的综合总结。这种强大的叠加功能消除了尝试预测每个组合的复杂单个提示词的需要。
+
+### ASR 默认值
+
+ASR 默认值功能对于一致的会议类型特别有价值。为"法庭行动"标签设置"最多 10 个"可确保转录服务查找最多 10 个不同的说话人,提高多方程序的准确率。"一对一"标签可能默认设置为恰好 2 个说话人,而"网络研讨会"标签可指定 1-3 个说话人。
+
+### 管理标签
+
+每张卡片上的编辑和删除按钮提供完全控制。编辑以根据结果优化提示词、调整颜色以获得更好的组织,或随着会议模式的变化更新 ASR 默认值。删除不再需要的标签,但这不会影响已标记的录音——它们保留标签以确保历史准确性。
+
+## 关于标签页
+
+
+
+关于标签页提供 Speakr 安装的全面概述,结合版本信息、系统配置、功能亮点和资源快速访问。
+
+### 版本信息
+
+在顶部,Speakr 标志和标语"AI 驱动的音频转录和笔记记录"提醒您系统的核心目的。版本徽章(示例中的 v0.5.5)立即告诉您正在运行哪个版本,这是故障排除和确定可用功能的重要信息。
+
+### 系统配置
+
+此部分提供实例功能的完全透明度。大语言模型字段显示哪个 AI 模型驱动总结和聊天——此处使用 OpenRouter 端点和特定模型。语音识别部分详细描述转录设置,显示您使用的是标准 Whisper API 还是高级 ASR 端点。启用 ASR 后,您将看到额外配置,如端点 URL,表明增强的转录功能,包括说话人分离。
+
+### 项目链接
+
+快速访问按钮将您连接到重要资源。GitHub 存储库按钮链接到源代码、问题和发布版本——这是报告错误或请求功能的主要渠道。Docker Hub 按钮提供对官方容器镜像的访问,用于部署和更新。文档按钮打开设置指南和用户手册,确保帮助始终只需一次点击。
+
+### 核心功能
+
+彩色卡片提醒您 Speakr 的核心功能。音频转录突出显示对 Whisper API 和自定义 ASR 的支持,具有高准确率。AI 总结强调 OpenRouter 和 Ollama 集成,用于灵活、强大的总结生成。说话人分离展示自动识别和标记不同说话人的能力。交互式聊天展示用于探索转录文本的对话式 AI 功能。查询模式突出显示所有录音的语义搜索。分享和导出强调分享录音和导出为各种格式的能力。
+
+### 使用此信息
+
+这个信息丰富的标签页服务于多种目的。对于故障排除,它提供支持团队所需的所有版本和配置详细信息。对于规划,它显示哪些功能可用且已正确配置。对于学习,它链接到掌握 Speakr 功能所需的所有资源。
+
+## 隐私与安全注意事项
+
+您的账户设置包含敏感信息,这些信息塑造您的整个 Speakr 体验。您创建的自定义提示词可能会透露组织优先事项或机密项目详情。说话人资料可能表明您经常与谁会面。分享历史显示您已分发的信息。
+
+在共享计算机上使用时务必退出登录。Speakr 为方便起见维护会话,但这意味着任何能访问您浏览器的人都可以访问您的账户。用户菜单中的退出登录按钮立即终止您的会话,需要重新认证。
+
+定期审查您的共享录音。您几个月前创建的链接可能仍处于活动状态,提供对过时或敏感信息的访问。共享转录文本标签页使这种审查变得容易——定期扫描并撤销不再需要的内容。
+
+在国际环境中考虑语言设置的影响。如果您在共享计算机上将中文设置为界面语言,下一位用户可能会难以导航。同样,输出语言设置会影响所有 AI 交互,因此请确保它们符合您的实际需求。
+
+## 优化您的设置
+
+最有效的 Speakr 设置会随您的需求而演变。从基本配置开始——您的姓名、语言偏好和简单的自定义提示词。当您熟悉后,添加标签进行组织、优化提示词以获得更好的总结,并构建说话人库以改进转录。
+
+监控您实际使用的设置。如果您从不更改某些偏好,它们可能使用默认值就可以了。如果您经常调整其他设置,考虑不同的基础设置是否会减少这种摩擦。您的设置应为您工作,而不是需要持续关注。
+
+与团队分享成功的配置。如果您为技术总结精心编写了出色的提示词,与可能受益的同事分享。如果您的标签分类法运作良好,记录下来供其他人采用。集体改进惠及所有人。
+
+请记住,设置是生产力的工具,而不是终点本身。目标不是完美的配置,而是有效的录音管理。当您的设置退居幕后,Speakr 按照您期望的方式正常工作时,您就达到了正确的平衡。
+
+---
+
+下一步:返回 [用户指南](index.md) →
diff --git a/speakr/docs/user-guide/sharing.md b/speakr/docs/user-guide/sharing.md
new file mode 100644
index 0000000..68701f9
--- /dev/null
+++ b/speakr/docs/user-guide/sharing.md
@@ -0,0 +1,100 @@
+---
+layout: default
+title: Sharing Recordings
+parent: User Guide
+nav_order: 5
+---
+
+# 分享录音
+
+有时你需要与没有 Speakr 账号的人分享录音——可能是客户需要审阅会议内容、同事错过了重要讨论,或者你想向利益相关者提供文档。Speakr 的分享功能让你可以创建安全、只读的录音链接,同时完全控制所分享的信息。
+
+## 了解录音分享
+
+当你分享录音时,Speakr 会生成一个独特的安全链接,提供对该特定录音的访问权限,而无需接收方登录或拥有账号。每个分享链接都是经过加密的安全链接,无法被猜测,确保只有你明确分享的人才能访问你的内容。
+
+分享系统让你可以精细控制接收方可以看到的内容。你可以决定是否包含 AI 生成的摘要、你的个人笔记,或两者都包含。转录内容始终会被包含,因为它是录音的核心内容。接收方会看到一个简洁、专业的录音视图,不会看到任何管理界面或你的其他录音。
+
+## 分享的前提条件
+
+在分享录音之前,你的 Speakr 实例必须满足两个关键要求。首先,它必须通过带有有效 SSL 证书的 HTTPS 进行访问。其次,它必须能够从互联网访问,并具有正确的域名或公共 IP 地址。
+
+这些要求的存在是因为分享链接需要被没有账号且可能从任何地方访问的接收方访问。本地网络安装或非 HTTPS 设置无法生成可分享的链接——分享按钮将被禁用或显示错误消息,说明分享需要安全且可互联网访问的连接。如果你在没有 SSL 或公共访问的内部环境中运行 Speakr,你需要与 IT 团队合作设置适当的 HTTPS 和互联网路由,然后才能使用分享功能。
+
+## 创建分享链接
+
+
+
+当你的实例满足要求后,分享录音只需几次点击。在查看任何录音时,点击工具栏中的分享按钮以打开分享模态框。这里会向你展示两个简单的复选框,用于控制接收方可以看到哪些信息。
+
+"Share Summary"选项决定接收方是否可以看到录音的 AI 生成摘要。在与需要快速概览而非阅读完整转录的高管或利益相关者分享时,这个选项特别有用。摘要以易于理解的格式为他们提供关键点、决策和行动项。
+
+"Share Notes"选项控制你的个人笔记是否可见。这些是你添加的 Markdown 笔记,用于为录音提供上下文、后续跟进或额外想法。请仔细考虑这些笔记是否适合你的受众——它们可能包含不适合外部查看的内部观察或个人提醒。
+
+选择完选项后,点击"Create Share Link"生成安全 URL。链接会立即出现在模态框中,准备复制到剪贴板。此链接在你明确撤销之前永久有效,因此接收方可以将其加入书签以便将来参考。
+
+## 接收方的体验
+
+
+
+当有人点击你的分享链接时,他们会被引导到一个精简的录音视图,专为轻松消费内容而设计,没有任何干扰。界面简洁专业,带有 Speakr 品牌标识,但仅显示你选择分享的内容。
+
+在页面顶部,接收方会看到醒目的录音标题以及表明这是共享录音的说明。音频播放器位于其下方,提供完整的播放控制,让他们可以按照自己的节奏收听原始录音。播放器显示总时长和当前位置,允许接收方导航到特定时刻或重播重要部分。
+
+主内容区域分为转录和摘要(如果你选择分享的话)。转录以清晰的格式显示,如果你的录音包含说话人分离,则包括说话人标签。每个被识别的说话人都显示为彩色标签,便于跟踪多人对话。如果存在说话人识别,界面还包括 Simple 和 Bubble 等视图选项,以及一个复制按钮以便轻松提取文本。
+
+如果你启用了摘要分享,它会出现在一个专用面板中,并具有自己的复制功能。摘要以完整的 Markdown 格式呈现,提供录音关键点的专业概览。如果你分享了笔记,它们会出现在类似的面板中,提供额外的上下文和见解。
+
+在页面底部,接收方会看到一个微妙的页脚,表明内容由"advanced AI transcription"提供支持,并带有指向 Speakr 的链接。这使你的分享看起来专业,同时保持对内容的关注。整个体验是只读的——接收方无法编辑任何内容,无法访问你的其他录音,也无法看到你的任何账号信息或设置。
+
+## 管理你的共享录音
+
+
+
+你所有共享的录音都通过账号设置中的 Shared Transcripts 部分集中管理。这让你可以全面了解你分享的所有内容以及分享对象。
+
+共享转录列表显示你分享的每个录音、创建分享链接的时间以及你选择的选项(摘要和/或笔记)。每个条目都包含完整的分享 URL,如果需要可以再次复制——也许你丢失了原始链接或需要与更多人重新分享。
+
+你可以直接在此界面中切换摘要和笔记复选框来修改分享中包含的内容。这些更改对任何访问链接的人立即生效。这在你最初只分享转录但后来决定包含摘要,或者想删除不再相关的笔记时非常有用。
+
+每个分享旁边的删除按钮会立即撤销访问权限。删除后,分享链接将失效,任何尝试访问的人都会看到错误消息。这让你可以完全控制共享内容的生命周期。
+
+## 安全和隐私考虑
+
+Speakr 要求所有分享操作使用 HTTPS,确保分享链接只能通过安全连接创建和访问。这保护了传输过程中的内容,防止敏感录音被截获。
+
+每个分享链接都包含一个加密安全的随机令牌,该令牌对于该特定分享是唯一的。这些令牌使用安全最佳实践生成,长度足以使猜测或暴力攻击变得不可行。即使有人知道你分享了其他录音,他们也无法推导或猜测这些分享的链接。
+
+重要的是要理解分享链接不需要身份验证——任何拥有链接的人都可以访问录音。这使得分享很方便,但也意味着你应该像对待敏感文档一样小心处理这些链接。仅通过加密邮件或私密消息等安全渠道分享它们,切勿公开发布,并在不再需要时撤销访问权限。
+
+系统不会跟踪谁访问了共享链接或查看频率。这尊重了接收方的隐私,但也意味着你不会知道链接是否被转发给其他人。如果你需要更严格的访问控制,请考虑让接收方创建 Speakr 账号而不是使用公开分享。
+
+## 分享的最佳实践
+
+在组织内部分享时,通常可以同时包含摘要和笔记,因为同事对讨论有上下文了解,需要完整的信息。笔记通常包含有价值的行动项、后续跟进和澄清,帮助团队成员不仅理解说了什么,还理解接下来需要做什么。
+
+与客户或合作伙伴等外部分享时,考虑仅分享摘要而不包含内部笔记。摘要为他们提供专业、精炼的要点,而你的笔记则保留供内部使用。分享前请审查内容,确保不包含任何机密或不适当的信息。
+
+当出于文档或合规目的分享时,专注于转录本身作为权威记录。带时间戳的逐字转录提供讨论的准确记录,而摘要和笔记代表可能不适合官方记录的解释。
+
+发送链接之前,始终审查你要分享的内容。一旦有人访问了内容,他们可能会保存或截图,因此稍后撤销链接不会删除他们已经查看的信息的访问权限。如果你意识到分享了不适当的内容,请立即撤销链接以防止进一步访问,但要假设之前的查看者可能已保留了信息。
+
+## 常见分享场景
+
+会议参与者通常在重要讨论后感谢收到分享链接。同时包含摘要和笔记,为他们提供决策和所需行动的完整画面。这对于可能错过部分会议或需要审查特定细节的参与者特别有价值。
+
+在向客户更新项目进度时,分享内部讨论的录音并附带摘要,但不包含内部笔记。这让他们了解你的流程和决策的透明度,同时保持关于内部审议的适当界限。
+
+对于培训和入职,共享录音成为有价值的学习资源。新团队成员可以收听过去的讨论以了解项目历史、决策依据和团队动态。包含全面的笔记以提供仅从转录中可能不明显的上下文。
+
+法律和合规团队可能需要重要对话的未编辑转录。在这些情况下,仅分享转录而不包含可能被视为解释或编辑评论的摘要或笔记。原始转录提供所说内容的客观记录。
+
+## 限制和注意事项
+
+分享链接旨在用于与个人或小团体进行偶尔分享,而非大规模分发或公开发布。系统未针对数百个并发查看者进行优化,公开发布的链接可能会将敏感信息暴露给非预期受众。
+
+目前,分享不会自动过期,尽管此功能可能会在未来更新中添加。创建后,分享链接将保持有效,直到你手动撤销。这意味着你应该定期审查共享录音并删除不再需要的访问权限。
+
+出于安全原因,分享功能仅在 HTTPS 连接上可用。如果你在没有 SSL 证书的本地网络上运行 Speakr,你将无法创建分享链接。这是一项有意的安全措施,旨在防止敏感录音通过未加密连接分享。
+
+接收方需要现代 Web 浏览器才能查看共享录音。音频播放器和转录显示需要 JavaScript 和 HTML5 支持。虽然这涵盖了绝大多数用户,但使用非常旧的浏览器或受限企业环境的接收方可能会遇到问题。
diff --git a/speakr/docs/user-guide/transcript-templates.md b/speakr/docs/user-guide/transcript-templates.md
new file mode 100644
index 0000000..189e2a7
--- /dev/null
+++ b/speakr/docs/user-guide/transcript-templates.md
@@ -0,0 +1,271 @@
+# 转写模板指南
+
+## 概述
+
+Speakr 中的转写模板允许你自定义下载转写内容时的格式。你可以针对不同的使用场景创建多个模板,而不是使用固定格式——无论你需要字幕的时间戳、采访的说话人导向格式,还是媒体制作的剧本风格格式。
+
+## 访问转写模板
+
+1. 点击右上角的用户名
+2. 导航到 **Account Settings**(账户设置)
+3. 选择 **Transcript Templates**(转写模板)标签页
+
+## 理解模板变量
+
+模板使用占位符(变量),这些占位符会被实际的转写数据替换:
+
+### 可用变量
+
+- `{{index}}` - 转写片段的序号(1, 2, 3...)
+- `{{speaker}}` - 说话人名称(例如 "Speaker 1"、"John" 等)
+- `{{text}}` - 实际的语音文本/内容
+- `{{start_time}}` - 片段开始时间(格式:HH:MM:SS)
+- `{{end_time}}` - 片段结束时间(格式:HH:MM:SS)
+
+### 过滤器
+
+可以通过添加管道符(|)来修改变量:
+
+- `{{speaker|upper}}` - 将说话人名称转换为大写
+- `{{text|upper}}` - 将文本转换为大写
+- `{{start_time|srt}}` - 将时间格式化为 SRT 字幕格式(HH:MM:SS,mmm)
+- `{{end_time|srt}}` - 将时间格式化为 SRT 字幕格式(HH:MM:SS,mmm)
+
+## 创建你的第一个模板
+
+### 第一步:点击"创建模板"
+在转写模板部分,点击 **Create Template**(创建模板)按钮。
+
+### 第二步:填写模板详情
+
+**模板名称**:为你的模板取一个描述性名称
+- 示例:"采访格式"、"SRT 字幕"、"会议纪要"
+
+**描述**(可选):添加何时使用此模板的简要说明
+- 示例:"用于客户采访转写"
+
+**模板**:使用变量输入你的格式模式
+- 示例:`[{{start_time}}] {{speaker}}: {{text}}`
+
+### 第三步:设为默认(可选)
+如果你希望下载转写时预选此模板,请勾选"设为默认模板"。
+
+### 第四步:保存
+点击 **Save**(保存)以创建你的模板。
+
+## 常见模板示例
+
+### 1. 简单对话
+```
+{{speaker}}: {{text}}
+```
+**输出:**
+```
+John: Hello, how are you today?
+Sarah: I'm doing great, thanks for asking!
+```
+
+### 2. 带时间戳格式
+```
+[{{start_time}} - {{end_time}}] {{speaker}}: {{text}}
+```
+**输出:**
+```
+[00:00:01 - 00:00:03] John: Hello, how are you today?
+[00:00:04 - 00:00:07] Sarah: I'm doing great, thanks for asking!
+```
+
+### 3. 采访/问答格式
+```
+{{speaker|upper}}:
+{{text}}
+
+```
+**输出:**
+```
+INTERVIEWER:
+What brought you to this field?
+
+GUEST:
+I've always been passionate about technology...
+```
+
+### 4. SRT 字幕格式
+```
+{{index}}
+{{start_time|srt}} --> {{end_time|srt}}
+{{text}}
+
+```
+**输出:**
+```
+1
+00:00:01,000 --> 00:00:03,000
+Hello, how are you today?
+
+2
+00:00:04,000 --> 00:00:07,000
+I'm doing great, thanks for asking!
+```
+
+### 5. 会议纪要风格
+```
+• [{{start_time}}] {{speaker}}: {{text}}
+```
+**输出:**
+```
+• [00:00:01] John: Let's begin today's meeting with updates.
+• [00:00:05] Sarah: I'll start with the marketing report.
+```
+
+### 6. 剧本格式
+```
+ {{speaker|upper}}
+ {{text}}
+
+```
+**输出:**
+```
+ JOHN
+ Hello, how are you today?
+
+ SARAH
+ I'm doing great, thanks for asking!
+```
+
+### 7. 法庭记录风格
+```
+{{index}} {{speaker|upper}}: {{text}}
+```
+**输出:**
+```
+1 ATTORNEY: Please state your name for the record.
+2 WITNESS: My name is John Smith.
+```
+
+## 下载时使用模板
+
+### 方法一:从转写视图
+1. 打开一个包含转写的录音
+2. 点击转写旁边的 **Download**(下载)按钮
+3. 在弹出窗口中选择你想要的模板
+4. 转写文件将以你选择的格式下载
+
+### 方法二:设置默认模板
+1. 编辑模板并勾选"设为默认模板"
+2. 下载时将预选此模板
+3. 你仍然可以在下载时选择不同的模板
+
+### 方法三:下载原始转写
+- 选择"No Template (Raw Export)"(无模板-原始导出)以不带任何格式下载转写
+- 当你需要在其他应用程序中处理文本时很有用
+
+## 管理模板
+
+### 编辑模板
+1. 点击列表中的任意模板以打开它
+2. 修改名称、描述或格式
+3. 点击 **Save**(保存)以更新
+
+### 删除模板
+1. 打开你想删除的模板
+2. 点击 **Delete**(删除)按钮
+3. 确认删除
+
+### 创建默认模板
+如果你没有任何模板,点击 **Create Default Templates**(创建默认模板)以生成一套全面的初始模板:
+- Simple Conversation(简单对话)- 仅包含说话人名称的简洁格式
+- Timestamped(带时间戳)- 包含时间范围的基本格式
+- Interview Q&A(采访问答)- 专业的采访格式
+- Meeting Minutes(会议纪要)- 用于会议记录的列表格式
+- Court Transcript(法庭记录)- 带行号的法律笔录风格
+- SRT Subtitle(SRT 字幕)- 视频的标准字幕格式
+- Screenplay(剧本)- 电影剧本格式
+
+## 高级技巧
+
+### 1. 多行模板
+你可以通过添加换行来创建多行模板:
+```
+=================
+Time: {{start_time}}
+Speaker: {{speaker}}
+Message: {{text}}
+=================
+```
+
+### 2. 组合使用过滤器
+在同一个模板中使用多个过滤器:
+```
+[{{start_time|srt}}] {{speaker|upper}}: {{text}}
+```
+
+### 3. 创建分隔符
+在片段之间添加视觉分隔符:
+```
+{{speaker}}: {{text}}
+---
+```
+
+### 4. 缩进以提高可读性
+使用空格或制表符进行缩进:
+```
+ {{start_time}} | {{speaker}}
+ {{text}}
+```
+
+## 使用场景
+
+### 记者
+创建一个带有时间戳和说话人标签的"采访格式"模板,以便在撰写文章时方便参考。
+
+### 研究人员
+使用带时间戳的编号格式来引用录制采访或焦点小组中的特定时刻。
+
+### 内容创作者
+导出为 SRT 格式以便为视频添加字幕,或使用剧本格式编写视频脚本。
+
+### 商务会议
+创建一个"会议纪要"模板,清晰显示谁在什么时候说了什么,非常适合行动项和后续跟进。
+
+### 法律专业人士
+使用带行号和大写说话人名称的法庭记录风格格式来制作笔录和证词。
+
+### 播客主持人
+格式化带时间戳的转写内容用于节目说明,听众可以点击跳转到特定主题。
+
+## 故障排除
+
+### 模板未显示
+- 确保你已保存模板
+- 必要时刷新页面
+- 检查你是否登录了正确的账户
+
+### 变量未替换
+- 验证变量名称拼写正确
+- 确保使用了双大括号 `{{}}`
+- 检查转写是否包含所需数据(说话人、时间戳)
+
+### 下载问题
+- 确保录音已完成转写
+- 先尝试不使用模板下载以验证转写是否存在
+- 检查浏览器的下载设置
+
+## 最佳实践
+
+1. **清晰命名模板**:使用描述性名称来表明用途
+2. **测试你的模板**:下载示例转写以验证格式
+3. **保留多个模板**:为不同的使用场景创建不同的模板
+4. **记录复杂模板**:使用描述字段来解释何时使用每个模板
+5. **从简单开始**:从基本模板开始,根据需要增加复杂性
+
+## 需要帮助?
+
+如果你遇到转写模板的问题或有疑问:
+1. 查看本指南以获取示例和故障排除信息
+2. 尝试使用默认模板作为起点
+3. 在 [GitHub Issues](https://github.com/anthropics/claude-code/issues) 上报告问题
+
+---
+
+*最后更新:2024年12月*
diff --git a/speakr/docs/user-guide/transcripts.md b/speakr/docs/user-guide/transcripts.md
new file mode 100644
index 0000000..c78baea
--- /dev/null
+++ b/speakr/docs/user-guide/transcripts.md
@@ -0,0 +1,201 @@
+# 处理转录文本
+
+音频处理完成后,Speakr 提供了丰富的工具用于查看、编辑转录文本以及与之交互。本节涵盖处理转录内容所需了解的所有内容。有关转录过程本身的信息,请参阅[转录功能](../features.md#core-transcription-features)。
+
+## 理解转录视图
+
+从侧边栏选择录音后,中心面板会显示完整的转录文本。该布局专为轻松阅读和导航而设计,对不同说话人和时间戳有清晰的视觉标识。
+
+
+
+## 说话人识别
+
+如果您的录音启用了[说话人分离](../features.md#speaker-diarization)功能进行处理,每位说话人的发言都会被标记上彩色标签,如 SPEAKER_01、SPEAKER_02 等。这需要配置[ASR endpoint](../getting-started.md#option-b-custom-asr-endpoint-configuration)。这些标签在转录过程中自动生成,帮助您跟踪多人对话。
+
+### 识别说话人
+
+当您需要为说话人标签分配真实姓名时,点击工具栏中的说话人识别按钮。这将打开一个模态窗口,您可以在其中:
+
+
+
+说话人识别模态窗口会展示每位检测到的说话人及其对话样本,帮助您识别他们。您可以为每位说话人输入真实姓名,这些姓名将替换转录文本中的通用标签。系统会记住这些分配以供将来参考,使在后续录音中识别相同说话人变得更加容易。
+
+### 管理已保存的说话人
+
+您识别的所有说话人都会自动保存到您的说话人数据库中。您可以通过[账户设置](settings.md)中的专属[说话人管理标签页](settings.md#speakers-management-tab)来管理这些已保存的说话人。导航至 Account > Speakers Management 即可查看所有已保存的说话人。
+
+
+
+说话人管理界面以响应式网格布局显示所有已保存的说话人。每张说话人卡片显示说话人姓名、使用统计信息(包括被识别的次数、上次在录音中使用的时间以及首次添加到数据库的时间)。该界面包含内部滚动功能,可在处理大量说话人时保持标题和操作按钮始终可见。
+
+您可以点击说话人卡片上的垃圾桶图标删除单个说话人,或使用"全部删除"按钮清空整个说话人数据库。这在维护隐私或需要以一组新说话人重新开始时非常有用。底部的说话人计数会跟踪您保存了多少说话人。
+
+系统会利用这些已保存的说话人,在您识别新录音中的说话人时提供建议,随着您构建说话人数据库,识别过程会随时间推移变得越来越快。
+
+## 编辑转录文本
+
+Speakr 根据您处理的转录文本类型提供两种不同的编辑模式。
+
+### 简单文本编辑
+
+对于没有说话人分离的标准转录文本,点击工具栏中的编辑按钮即可进入简单文本编辑器。即使您[重新生成摘要](../features.md#automatic-summarization)或使用聊天功能,您的编辑也会被保留。这允许您将整个转录文本作为单个文本块进行更正。您可以修正转录错误、添加标点符号、插入澄清说明以及改进格式。点击保存按钮后,您的更改将被保存。
+
+### 高级 ASR 段落编辑
+
+对于使用[ASR](../features.md#speaker-diarization)和说话人分离处理的转录文本,Speakr 提供强大的基于段落的编辑器,可保留转录文本的结构和时间信息。如果遇到说话人识别问题,请参阅[故障排除](../troubleshooting.md#speaker-identification-not-working)。
+
+
+
+ASR 编辑器将您的转录文本呈现为段落表格,每行代表一次发言及其关联的元数据。对于每个段落,您可以:
+
+**编辑说话人姓名**:每个段落显示谁在发言。您可以点击任何说话人字段进行更改,系统会提供智能下拉菜单,从您已保存的说话人数据库中推荐说话人。输入时,系统会过滤建议以帮助您快速找到正确的说话人。这对于纠正错误识别的说话人或统一整个转录文本中的说话人姓名特别有用。
+
+**调整时间戳**:可以使用调整箭头微调每个段落的开始和结束时间。当自动分割不太准确时,这非常有用,允许您精确标记每个人开始和停止说话的时间。
+
+**编辑文本内容**:每次发言的主要内容可以在其文本字段中直接编辑。您可以在保持段落结构的同时修正转录错误、修复语法、添加标点符号或澄清不清楚的语音。
+
+**管理段落**:使用垃圾桶图标删除不应出现在转录文本中的段落(如错误检测或噪音)。如果 ASR 系统遗漏了某些内容,您还可以添加新段落,并为其设置适当的时间戳。
+
+ASR 编辑器保持转录文本的 JSON 结构,这对于基于说话人的搜索、音频同步(点击文本跳转到音频中的对应位置)以及在气泡视图中的正确显示等功能至关重要。当您在 ASR 编辑器中保存更改时,系统会保留所有时间信息和说话人信息,同时应用您的更正。
+
+点击"保存更改"后,您的编辑将保存到数据库。系统会维护转录文本已被手动编辑的记录,这对于质量控制和审计目的很有用。请注意,如果稍后使用完整转录重新处理功能重新处理转录文本,您的手动编辑将被覆盖。要在更新摘要的同时保留编辑,请改用仅摘要重新处理。
+
+## 视图选项
+
+转录面板提供两种显示模式以适应不同的偏好:
+
+### 简单视图
+
+默认的简单视图将转录文本呈现为带有内联说话人标签的流式文本。这种格式非常适合快速浏览内容并复制文本以供其他应用程序使用。干净的类文档外观使您能够轻松专注于内容,而不会受到视觉干扰。
+
+### 气泡视图
+
+气泡视图将转录文本格式化为类似聊天的对话形式,每位说话人的发言都在独立的消息气泡中。这种格式特别适合访谈、辩论或任何包含来回对话的录音。视觉分隔使您更容易跟踪快速交流中谁说了什么。
+
+## 使用摘要功能
+
+右侧面板中的摘要标签页包含 AI 生成的录音概述。此摘要在转录后自动创建,捕获关键点、决策和行动项。
+
+
+
+该摘要旨在通过突出录音中最重要的信息来节省您的时间。它通常包括讨论的主要主题、达成的关键决策或结论、行动项和分配、提及的重要日期或截止日期,以及提出的任何重大问题或担忧。
+
+### 自定义摘要提示词
+
+Speakr 提供多个级别的摘要生成自定义功能,允许您根据自身需求调整 AI 的输出。摘要提示词可以在三个不同级别进行自定义:
+
+**管理员级别**:系统管理员可以设置默认的摘要提示词,适用于尚未配置自己提示词的所有用户。这在管理仪表板的 Default Prompts 下进行配置,并作为整个系统的基线。
+
+**用户级别**: individual 用户可以在[账户设置的自定义提示词](settings.md#custom-prompts-tab)中设置个人摘要提示词。这将覆盖管理员默认值,并应用于其所有录音,除非使用特定标签的提示词。
+
+**标签级别**:每个标签都可以配置自己的自定义提示词。创建或编辑标签时,您可以指定一个摘要提示词,该提示词将用于带有该标签的任何录音。这对于不同类型的内容特别强大——例如,"法律会议"标签可能侧重于合规问题和风险因素,而"产品规划"标签可能强调功能和时间线。
+
+### 提示词优先级和叠加
+
+生成摘要时,Speakr 遵循明确的优先级顺序来确定使用哪个提示词:
+
+1. **标签自定义提示词**(最高优先级):如果录音带有自定义提示词的标签,则这些标签优先。当多个带有自定义提示词的标签应用于同一录音时,Speakr 会按标签添加的顺序智能地组合它们。这会产生复杂的叠加效果,您可以有一个基础标签如"会议"包含通用指令,然后添加修饰标签如"面向客户"或"技术讨论"来添加特定要求。提示词无缝合并,为 AI 创建全面的指令。
+
+2. **用户摘要提示词**:如果没有标签提示词,系统将使用您在账户设置中配置的个人摘要提示词。
+
+3. **管理员默认提示词**:当未配置标签或用户提示词时,系统回退到管理员配置的默认提示词。
+
+4. **系统回退**:如果任何级别都不存在自定义提示词,Speakr 将使用内置默认值,生成关键问题、决策和行动项的章节。
+
+### 摘要质量因素
+
+生成摘要的质量取决于两个关键因素:
+
+**提示词设计**:清晰指定您想要提取哪些信息的精心设计的提示词将产生更有用的摘要。考虑包含您想要的具体章节、所需的详细程度以及与您的用例相关的任何特定重点领域。
+
+**LLM 选择**:您使用的 AI 模型会显著影响摘要质量。更先进的模型(如 GPT-4 或 Claude)通常比小型模型产生更细致和准确的摘要。管理员可以在系统设置中配置模型,在质量和成本之间取得平衡。
+
+### 编辑和导出摘要
+
+摘要可以使用与 Notes 部分类似的 Markdown 编辑器进行完全编辑。点击摘要工具栏中的编辑按钮(铅笔图标)进入编辑模式。Markdown 编辑器提供丰富的编辑体验,带有粗体、斜体、标题、列表、链接等格式化工具。您可以重构摘要、添加额外见解或优化 AI 生成的内容以更好地满足您的需求。更改会在输入时自动保存,确保您不会丢失编辑内容。
+
+您可以通过多种方式导出摘要。复制按钮将整个摘要以 Markdown 格式复制到剪贴板,随时可以粘贴到电子邮件、文档或其他应用程序中。下载按钮将摘要导出为 Microsoft Word (.docx) 文件,保留格式并便于与偏好传统文档的同事共享。下载的文件使用录音标题和日期命名,便于识别。
+
+如果需要,您可以使用不同的设置重新生成摘要,这在您想要关注内容的不同方面或对转录文本进行了重大编辑时非常有用。重新生成时,您可以临时调整标签以应用不同的提示词,而无需永久更改录音的分类。
+
+## 事件提取
+
+在您的账户设置中启用事件提取功能后,Speakr 会在摘要生成过程中自动从录音中识别值得添加到日历的事件。此功能智能地检测对话中提到的会议、截止日期、预约和其他时间敏感事项。
+
+
+
+### 查看提取的事件
+
+启用事件提取功能处理录音后,如果检测到任何事件,右侧面板将出现事件标签页。该标签页显示已识别事件的清晰列表,每个事件显示从对话中提取的事件标题、日期、时间和描述。事件会自动解析,具有智能日期识别功能,能够根据录音日期理解"下周二"或"两周后"等相对引用。
+
+### 导出到日历
+
+每个提取的事件都可以导出为 ICS 文件,该文件与几乎所有日历应用程序兼容,包括 Google Calendar、Outlook、Apple Calendar 和 Thunderbird。只需点击任何事件旁边的下载按钮即可将其保存为日历文件。ICS 文件包含事件标题、日期和时间,以及来自录音上下文的描述。当对话中没有提及具体时间时,事件默认设置为上午 9 点,以确保它们在您的日历中显眼显示。
+
+### 启用事件提取
+
+事件提取可以在账户设置的自定义提示词标签页中开启或关闭。启用后,系统会在您的摘要提示词中添加事件提取指令,要求 AI 在摘要生成过程中识别值得添加到日历的项目。此功能与系统中配置的任何 LLM 提供商配合使用,并适应不同的对话风格,从正式会议到非正式讨论。事件提取的质量取决于所使用的 AI 模型,更先进的模型能够更好地检测细微的调度引用。
+
+## 交互式聊天
+
+聊天标签页提供 AI 助手,可以回答有关您录音的问题。此功能对于长录音特别强大,因为在长录音中查找特定信息可能很耗时。
+
+
+
+### 有效的聊天查询
+
+AI 聊天理解上下文,可以回答有关录音的各种类型的问题。您可以询问特定信息,如"Sarah 关于预算说了什么?"或"项目的截止日期是什么时候?"AI 还可以提供分析,如"提出的主要担忧是什么?"或"总结市场团队的行动项。"
+
+聊天在整个对话过程中保持上下文,因此您可以提出后续问题。例如,在询问预算讨论后,您可以继续提问"提出了什么解决方案?"AI 会理解您仍在讨论预算话题。
+
+### 聊天最佳实践
+
+为了在聊天功能中获得最佳效果,提问时应具体,而不是提出过于宽泛的查询。尽可能引用特定主题、人员或时间范围。如果 AI 的回答不太符合您的需求,请尝试用更多上下文重新表述您的问题。
+
+聊天功能最适合回答录音中说了什么的事实性问题。它对于主观解释或言外之意效果较差,但可以在一定程度上识别语气和情感。
+
+### 导出聊天对话
+
+您的整个聊天对话可以导出以供将来参考或共享。聊天界面提供两种导出选项:
+
+**复制到剪贴板**:点击复制按钮将整个聊天对话复制到剪贴板。这保留了问答格式,便于粘贴到电子邮件、报告或文档中。每条消息都包含一个标签,指示是来自您还是 AI 助手。
+
+**下载为 Word 文档**:下载按钮将您的完整聊天记录导出为格式化的 Microsoft Word (.docx) 文件。这包括所有问题和回答,采用干净、专业的格式,便于与团队成员共享或包含在报告中。文件会自动以录音标题和导出日期命名,便于组织多个聊天导出。
+
+当您使用聊天提取特定见解或对录音进行详细分析时,这些导出功能特别有用,允许您保存和共享发现,而无需重新创建对话。
+
+## 添加和管理笔记
+
+笔记标签页是您的个人工作空间,用于为录音添加背景和想法。这些笔记是您私有的,用您自己的见解补充转录文本。
+
+
+
+笔记支持完整的 Markdown 格式,允许您创建带有标题、列表、链接和强调的结构化文档。Markdown 编辑器提供带有格式化选项的工具栏,并支持键盘快捷键以提高笔记效率。您可以在编辑和预览模式之间切换,以查看格式化后的笔记外观。
+
+笔记的常见用途包括:
+
+- 对会议或对话的个人反思,这些不适合放在共享转录文本中
+- 根据讨论需要完成的后续任务
+- 关于所做决定或为何讨论某些主题的额外背景信息
+- 录音中提及的相关文档或资源的链接
+- 您想要以后研究或澄清的问题
+
+您的笔记可以与转录文本一起进行全文搜索,这使它们对将来的参考非常有价值。您可以搜索特定术语,无论它出现在转录文本中还是您的个人笔记中都能找到。笔记会在输入时自动保存,确保您永远不会丢失想法。
+
+## 复制和导出
+
+转录文本工具栏中的复制按钮以纯文本格式将整个转录文本复制到剪贴板。这保留了段落结构,便于粘贴到文档或电子邮件中。
+
+### 下载转录文本
+
+在复制按钮旁边,您会找到下载按钮,它通过可自定义的模板提供灵活的导出选项。点击下载时,会出现一个弹出窗口,允许您从已保存的[转录模板](transcript-templates.md)中选择或导出未格式化的原始转录文本。模板让您精确控制转录文本的格式方式,无论您需要字幕的时间戳、访谈的说话人聚焦格式,还是剧本风格的格式。您可以在账户设置中创建和管理这些模板。
+
+如果您的录音使用 ASR 和说话人分离进行处理,导出的文本将包含说话人标识符(通用标签如 SPEAKER_01 或您已识别的实际姓名)。对于没有说话人分离的录音,转录文本将导出为连续文本。无论您是在简单视图还是气泡视图中查看转录文本,格式都保持一致——导出的文本使用底层数据的结构化格式,根据您选择的模板进行格式化。
+
+## 下一步
+
+现在您已了解如何处理转录文本,让我们探索 Speakr 强大的搜索功能——Inquire Mode,它允许您使用自然语言在所有录音中进行搜索。
+
+---
+
+下一节:[Inquire Mode - 语义搜索](inquire-mode.md) →
diff --git a/speakr/mkdocs.yml b/speakr/mkdocs.yml
new file mode 100644
index 0000000..9b82131
--- /dev/null
+++ b/speakr/mkdocs.yml
@@ -0,0 +1,179 @@
+site_name: Speakr
+site_description: AI-Powered Audio Transcription & Note-Taking Platform
+site_url: https://speakr.yourdomain.com
+repo_url: https://github.com/murtaza-nasir/speakr
+repo_name: speakr
+edit_uri: edit/main/docs/
+
+theme:
+ name: material
+ logo: assets/images/speakr-logo.png
+ favicon: assets/images/speakr-logo.png
+ palette:
+ # Light mode
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+ # Dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+
+ features:
+ - navigation.instant
+ - navigation.instant.progress
+ - navigation.tracking
+ - navigation.tabs
+ - navigation.tabs.sticky
+ - navigation.sections
+ - navigation.expand
+ - navigation.path
+ - navigation.indexes
+ - navigation.top
+ - navigation.footer
+ - toc.follow
+ - toc.integrate
+ - search.suggest
+ - search.highlight
+ - search.share
+ - header.autohide
+ - content.action.edit
+ - content.action.view
+ - content.code.copy
+ - content.code.annotate
+ - content.tooltips
+
+plugins:
+ - search:
+ separator: '[\s\-\.\_]+'
+ lang:
+ - en
+ - minify:
+ minify_html: true
+ minify_js: true
+ minify_css: true
+ htmlmin_opts:
+ remove_comments: true
+ - git-revision-date-localized:
+ enable_creation_date: true
+ type: iso_date
+ enabled: !ENV [CI, false] # Only enable in CI/production
+
+markdown_extensions:
+ # Python Markdown
+ - abbr
+ - admonition
+ - attr_list
+ - def_list
+ - footnotes
+ - md_in_html
+ - meta
+ - tables
+ - toc:
+ permalink: true
+ title: On this page
+
+ # Python Markdown Extensions
+ - pymdownx.arithmatex:
+ generic: true
+ - pymdownx.betterem:
+ smart_enable: all
+ - pymdownx.caret
+ - pymdownx.critic
+ - pymdownx.details
+ - pymdownx.emoji:
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
+ - pymdownx.keys
+ - pymdownx.mark
+ - pymdownx.smartsymbols
+ - pymdownx.snippets:
+ auto_append:
+ - includes/abbreviations.md
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format
+ - pymdownx.tabbed:
+ alternate_style: true
+ combine_header_slug: true
+ - pymdownx.tasklist:
+ custom_checkbox: true
+ - pymdownx.tilde
+
+extra:
+ social:
+ - icon: fontawesome/brands/github
+ link: https://github.com/murtaza-nasir/speakr
+ - icon: fontawesome/brands/docker
+ link: https://hub.docker.com/r/learnedmachine/speakr
+
+ analytics:
+ provider: google
+ property: G-XXXXXXXXXX
+ feedback:
+ title: Was this page helpful?
+ ratings:
+ - icon: material/emoticon-happy-outline
+ name: This page was helpful
+ data: 1
+ note: Thanks for your feedback!
+ - icon: material/emoticon-sad-outline
+ name: This page could be improved
+ data: 0
+ note: Thanks for your feedback!
+
+extra_css:
+ - stylesheets/extra.css
+
+extra_javascript:
+ - javascripts/extra.js
+
+exclude_docs: |
+ README.md
+ DOCUMENTATION_VERIFICATION_CHECKLIST.md
+ ci-cd-setup.md
+ .github-deploy.yml
+
+nav:
+ - Home:
+ - index.md
+ - Screenshots: screenshots.md
+ - Features: features.md
+ - Getting Started: getting-started.md
+ - Installation: getting-started/installation.md
+
+ - User Guide:
+ - user-guide/index.md
+ - First Steps: user-guide/first-steps.md
+ - Recording Audio: user-guide/recording.md
+ - Understanding Transcripts: user-guide/transcripts.md
+ - Transcript Templates: user-guide/transcript-templates.md
+ - Inquire Mode: user-guide/inquire-mode.md
+ - Sharing Recordings: user-guide/sharing.md
+ - Account Settings: user-guide/settings.md
+
+ - Admin Guide:
+ - admin-guide/index.md
+ - User Management: admin-guide/user-management.md
+ - System Statistics: admin-guide/statistics.md
+ - System Settings: admin-guide/system-settings.md
+ - Default Prompts: admin-guide/prompts.md
+ - Vector Store: admin-guide/vector-store.md
+
+ - Troubleshooting: troubleshooting.md
+ - FAQ: faq.md
\ No newline at end of file
diff --git a/speakr/requirements.txt b/speakr/requirements.txt
new file mode 100644
index 0000000..e520bd2
--- /dev/null
+++ b/speakr/requirements.txt
@@ -0,0 +1,26 @@
+flask==2.3.3
+flask-sqlalchemy==3.1.1
+flask-login==0.6.3
+flask-wtf==1.2.2
+flask-bcrypt==1.0.1
+Flask-Limiter==3.5.0
+email-validator==2.2.0
+openai==1.3.0
+werkzeug==2.3.7
+gunicorn==21.2.0
+python-dotenv==1.0.0
+markdown==3.5.1
+pytz==2024.1
+Babel==2.12.1
+bleach==6.1.0
+python-docx==1.1.0
+numpy==1.24.3
+sentence-transformers==2.7.0
+scikit-learn==1.3.0
+huggingface-hub>=0.19.0
+pypinyin==0.55.0
+tiktoken==0.12.0
+psycopg2-binary==2.9.11
+flask-sock==0.7.0
+websocket-client==1.8.0
+requests==2.32.3
diff --git a/speakr/run_docker.sh b/speakr/run_docker.sh
new file mode 100644
index 0000000..a64dc8e
--- /dev/null
+++ b/speakr/run_docker.sh
@@ -0,0 +1,8 @@
+docker run -d \
+ --name speakr \
+ --restart unless-stopped \
+ -p 5000:8899 \
+ -v /home/wjs/speakr/speakr:/app \
+ -v /home/wjs/speakr/uploads:/data/uploads \
+ -v /home/wjs/speakr/instance:/data/instance \
+ speakr:v2
\ No newline at end of file
diff --git a/speakr/scripts/create_admin.py b/speakr/scripts/create_admin.py
new file mode 100644
index 0000000..68b4760
--- /dev/null
+++ b/speakr/scripts/create_admin.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import getpass
+from email_validator import validate_email, EmailNotValidError
+
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# Try to import from app context
+try:
+ from flask import current_app
+ app = current_app._get_current_object()
+ with app.app_context():
+ db = app.extensions['sqlalchemy'].db
+ User = app.extensions['sqlalchemy'].db.metadata.tables['user']
+ bcrypt = app.extensions.get('bcrypt')
+except (RuntimeError, AttributeError, KeyError):
+ # If not in app context, import directly
+ try:
+ from src.app import app, db, User, bcrypt
+ except ImportError as e:
+ print(f"Error: Could not import required modules: {e}")
+ print("Make sure create_admin.py is runnable and PYTHONPATH is set.")
+ sys.exit(1)
+
+def create_admin_user():
+ """
+ Create an admin user interactively.
+ """
+ print("Creating admin user for Speakr application")
+ print("=========================================")
+
+ # Get username
+ while True:
+ username = input("Enter username (min 3 characters): ").strip()
+ if len(username) < 3:
+ print("Username must be at least 3 characters long.")
+ continue
+
+ # Check if username already exists
+ with app.app_context():
+ existing_user = db.session.query(User).filter_by(username=username).first()
+ if existing_user:
+ print(f"Username '{username}' already exists. Please choose another.")
+ continue
+ break
+
+ # Get email
+ while True:
+ email = input("Enter email address: ").strip()
+ try:
+ # Validate email
+ validate_email(email)
+
+ # Check if email already exists
+ with app.app_context():
+ existing_email = db.session.query(User).filter_by(email=email).first()
+ if existing_email:
+ print(f"Email '{email}' already exists. Please use another.")
+ continue
+ break
+ except EmailNotValidError as e:
+ print(f"Invalid email: {str(e)}")
+
+ # Get password
+ while True:
+ password = getpass.getpass("Enter password (min 8 characters): ")
+ if len(password) < 8:
+ print("Password must be at least 8 characters long.")
+ continue
+
+ confirm_password = getpass.getpass("Confirm password: ")
+ if password != confirm_password:
+ print("Passwords do not match. Please try again.")
+ continue
+ break
+
+ # Create user
+ with app.app_context():
+ hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
+ new_user = User(username=username, email=email, password=hashed_password, is_admin=True)
+ db.session.add(new_user)
+ db.session.commit()
+
+ print("\nAdmin user created successfully!")
+ print(f"Username: {username}")
+ print(f"Email: {email}")
+ print("You can now log in to the application with these credentials.")
+
+if __name__ == "__main__":
+ create_admin_user()
diff --git a/speakr/scripts/docker-entrypoint.sh b/speakr/scripts/docker-entrypoint.sh
new file mode 100644
index 0000000..f088778
--- /dev/null
+++ b/speakr/scripts/docker-entrypoint.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+set -e
+
+# 创建运行时数据目录
+mkdir -p /data/uploads /data/instance
+chmod 755 /data/uploads /data/instance
+
+# 首次启动时初始化数据库;非首次启动时触发应用初始化逻辑检查表结构。
+if [ ! -f /data/instance/transcriptions.db ]; then
+ echo "数据库不存在,正在创建..."
+ python -c "from src.app import app; from src.clients import db; app.app_context().push(); db.create_all()"
+ echo "数据库创建完成。"
+else
+ echo "数据库已存在,正在检查表结构更新..."
+ python -c "from src.app import app; app.app_context().push()"
+fi
+
+# 如果配置了管理员环境变量,则创建或更新管理员账号。
+if [ -n "$ADMIN_USERNAME" ] && [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then
+ echo "正在根据环境变量创建管理员账号..."
+ cd /app && python scripts/docker_create_admin.py
+fi
+
+# 启动应用
+exec "$@"
diff --git a/speakr/scripts/docker_create_admin.py b/speakr/scripts/docker_create_admin.py
new file mode 100644
index 0000000..0d0d418
--- /dev/null
+++ b/speakr/scripts/docker_create_admin.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+from email_validator import validate_email, EmailNotValidError
+
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# Try to import from app context
+try:
+ from flask import current_app
+ app = current_app._get_current_object()
+ with app.app_context():
+ db = app.extensions['sqlalchemy'].db
+ User = app.extensions['sqlalchemy'].db.metadata.tables['user']
+ bcrypt = app.extensions.get('bcrypt')
+except (RuntimeError, AttributeError, KeyError):
+ # If not in app context, import directly
+ try:
+ from src.app import app, db, User, bcrypt
+ except ImportError as e:
+ print(f"Error: Could not import required modules: {e}")
+ print("Make sure docker_create_admin.py is runnable and PYTHONPATH is set.")
+ sys.exit(1)
+
+def create_admin_user_from_env():
+ """
+ Create an admin user from environment variables.
+ Required environment variables:
+ - ADMIN_USERNAME
+ - ADMIN_EMAIL
+ - ADMIN_PASSWORD
+ """
+ print("Creating admin user for Speakr application from environment variables")
+ print("=================================================================")
+
+ # Get values from environment variables
+ username = os.environ.get('ADMIN_USERNAME')
+ email = os.environ.get('ADMIN_EMAIL')
+ password = os.environ.get('ADMIN_PASSWORD')
+
+ # Validate required environment variables
+ if not username or not email or not password:
+ print("Error: ADMIN_USERNAME, ADMIN_EMAIL, and ADMIN_PASSWORD environment variables must be set.")
+ sys.exit(1)
+
+ # Validate username
+ if len(username) < 3:
+ print("Error: Username must be at least 3 characters long.")
+ sys.exit(1)
+
+ # Validate email
+ try:
+ validate_email(email)
+ except EmailNotValidError as e:
+ print(f"Error: Invalid email: {str(e)}")
+ sys.exit(1)
+
+ # Validate password
+ if len(password) < 8:
+ print("Error: Password must be at least 8 characters long.")
+ sys.exit(1)
+
+ # Create user
+ with app.app_context():
+ # Check if username already exists
+ existing_user = db.session.query(User).filter_by(username=username).first()
+ if existing_user:
+ print(f"User with username '{username}' already exists.")
+ sys.exit(0)
+
+ # Check if email already exists
+ existing_email = db.session.query(User).filter_by(email=email).first()
+ if existing_email:
+ print(f"User with email '{email}' already exists.")
+ sys.exit(0)
+
+ # Create new admin user
+ hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
+ new_user = User(username=username, email=email, password=hashed_password, is_admin=True)
+ db.session.add(new_user)
+ db.session.commit()
+
+ print("\nAdmin user created successfully!")
+ print(f"Username: {username}")
+ print(f"Email: {email}")
+ print("You can now log in to the application with these credentials.")
+
+if __name__ == "__main__":
+ create_admin_user_from_env()
diff --git a/speakr/scripts/download_offline_deps.py b/speakr/scripts/download_offline_deps.py
new file mode 100644
index 0000000..d0d6433
--- /dev/null
+++ b/speakr/scripts/download_offline_deps.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""
+Download all CDN dependencies for offline deployment
+"""
+
+import os
+import requests
+from pathlib import Path
+
+# Base directory for vendor files
+VENDOR_DIR = Path(__file__).parent.parent / "static" / "vendor"
+
+# Dependencies to download
+DEPENDENCIES = {
+ "css": {
+ "fontawesome.min.css": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css",
+ "easymde.min.css": "https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css",
+ },
+ "js": {
+ "tailwind.min.js": "https://cdn.tailwindcss.com/3.4.0",
+ "vue.global.js": "https://unpkg.com/vue@3/dist/vue.global.js",
+ "vue.global.prod.js": "https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js",
+ "marked.min.js": "https://cdn.jsdelivr.net/npm/marked/marked.min.js",
+ "easymde.min.js": "https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js",
+ "axios.min.js": "https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js",
+ }
+}
+
+# Font Awesome webfonts
+FONTAWESOME_FONTS = [
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-brands-400.ttf",
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-brands-400.woff2",
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-regular-400.ttf",
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-regular-400.woff2",
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.ttf",
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2",
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-v4compatibility.ttf",
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-v4compatibility.woff2",
+]
+
+def download_file(url, filepath):
+ """Download a file from URL to filepath"""
+ print(f"Downloading {url} to {filepath}")
+ try:
+ response = requests.get(url, timeout=30)
+ response.raise_for_status()
+
+ # Create directory if it doesn't exist
+ filepath.parent.mkdir(parents=True, exist_ok=True)
+
+ # Write file
+ with open(filepath, 'wb') as f:
+ f.write(response.content)
+ print(f" ✓ Downloaded {filepath.name}")
+ return True
+ except Exception as e:
+ print(f" ✗ Failed to download {url}: {e}")
+ return False
+
+def main():
+ print("Downloading offline dependencies...")
+ print(f"Vendor directory: {VENDOR_DIR}")
+
+ # Download CSS and JS files
+ for file_type, files in DEPENDENCIES.items():
+ print(f"\n{file_type.upper()} Files:")
+ for filename, url in files.items():
+ filepath = VENDOR_DIR / file_type / filename
+ download_file(url, filepath)
+
+ # Download Font Awesome fonts
+ print("\nFont Awesome Webfonts:")
+ for url in FONTAWESOME_FONTS:
+ filename = url.split("/")[-1]
+ filepath = VENDOR_DIR / "fonts" / "webfonts" / filename
+ download_file(url, filepath)
+
+ # Update Font Awesome CSS to use local fonts
+ fa_css_path = VENDOR_DIR / "css" / "fontawesome.min.css"
+ if fa_css_path.exists():
+ print("\nUpdating Font Awesome CSS to use local fonts...")
+ with open(fa_css_path, 'r') as f:
+ content = f.read()
+
+ # Replace CDN URLs with local paths - handle both relative and absolute URLs
+ content = content.replace(
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/",
+ "../fonts/webfonts/"
+ )
+ # Also replace any relative URLs that might be in the minified CSS
+ content = content.replace(
+ "../webfonts/",
+ "../fonts/webfonts/"
+ )
+ content = content.replace(
+ "./webfonts/",
+ "../fonts/webfonts/"
+ )
+
+ with open(fa_css_path, 'w') as f:
+ f.write(content)
+ print(" ✓ Updated Font Awesome CSS paths")
+
+ print("\n✅ All dependencies downloaded successfully!")
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/speakr/scripts/migrate_docker.sh b/speakr/scripts/migrate_docker.sh
new file mode 100644
index 0000000..780f1fa
--- /dev/null
+++ b/speakr/scripts/migrate_docker.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Manual migration script for Docker deployments
+# NOTE: The first 10 recordings are processed automatically on startup.
+# This script is for processing any remaining recordings.
+
+echo "🎯 Inquire Mode Manual Migration for Docker"
+echo "============================================="
+
+# Check if container is running
+if ! docker-compose ps | grep -q "speakr.*Up"; then
+ echo "❌ Speakr container is not running. Please start it first with:"
+ echo " docker-compose up -d"
+ exit 1
+fi
+
+echo "ℹ️ Note: The first 10 recordings are processed automatically on startup."
+echo "ℹ️ This script processes any remaining recordings that need chunking."
+echo ""
+echo "🔍 Checking how many recordings still need processing..."
+
+# First, do a dry run to see what would be processed
+docker-compose exec app python migrate_existing_recordings.py --dry-run
+
+echo ""
+echo "⚠️ Do you want to proceed with processing these recordings?"
+echo "⚠️ This will create embeddings and may take several minutes."
+read -p "Continue? (y/N): " -n 1 -r
+echo
+
+if [[ $REPLY =~ ^[Yy]$ ]]; then
+ echo "🚀 Starting migration..."
+ docker-compose exec app python migrate_existing_recordings.py --process --batch-size 5
+
+ if [ $? -eq 0 ]; then
+ echo "✅ Migration completed successfully!"
+ echo "🎉 Your existing recordings are now ready for Inquire Mode!"
+ else
+ echo "❌ Migration failed. Check the logs above for details."
+ exit 1
+ fi
+else
+ echo "❌ Migration cancelled."
+ exit 0
+fi
\ No newline at end of file
diff --git a/speakr/scripts/migrate_existing_recordings.py b/speakr/scripts/migrate_existing_recordings.py
new file mode 100644
index 0000000..eccc4ef
--- /dev/null
+++ b/speakr/scripts/migrate_existing_recordings.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""
+Migration script to process existing recordings for Inquire Mode.
+This script will chunk and vectorize all existing recordings that haven't been processed yet.
+"""
+import os
+import sys
+from src.app import app, db, Recording, TranscriptChunk, process_recording_chunks
+
+def count_recordings_needing_processing():
+ """Count how many recordings need chunk processing."""
+ with app.app_context():
+ # Get all completed recordings
+ completed_recordings = Recording.query.filter_by(status='COMPLETED').all()
+
+ # Check which ones don't have chunks
+ recordings_needing_processing = []
+ for recording in completed_recordings:
+ if recording.transcription: # Has transcription
+ chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count()
+ if chunk_count == 0: # No chunks yet
+ recordings_needing_processing.append(recording)
+
+ return recordings_needing_processing
+
+def migrate_existing_recordings(batch_size=10, dry_run=False):
+ """
+ Process existing recordings in batches to create chunks and embeddings.
+
+ Args:
+ batch_size (int): Number of recordings to process at once
+ dry_run (bool): If True, just show what would be processed
+ """
+ with app.app_context():
+ recordings_to_process = count_recordings_needing_processing()
+
+ print(f"🔍 Found {len(recordings_to_process)} recordings that need chunk processing")
+
+ if len(recordings_to_process) == 0:
+ print("✅ All recordings are already processed!")
+ return True
+
+ if dry_run:
+ print("\n📋 Recordings that would be processed:")
+ for i, recording in enumerate(recordings_to_process, 1):
+ print(f" {i}. {recording.title} (ID: {recording.id}) - {len(recording.transcription)} chars")
+ print(f"\nThis is a dry run. Use --process to actually run the migration.")
+ return True
+
+ print(f"🚀 Processing {len(recordings_to_process)} recordings in batches of {batch_size}")
+
+ processed = 0
+ errors = 0
+
+ for i in range(0, len(recordings_to_process), batch_size):
+ batch = recordings_to_process[i:i + batch_size]
+ print(f"\n📦 Processing batch {i//batch_size + 1} ({len(batch)} recordings)...")
+
+ for recording in batch:
+ try:
+ print(f" ⏳ Processing: {recording.title} (ID: {recording.id})")
+
+ success = process_recording_chunks(recording.id)
+ if success:
+ processed += 1
+ # Get chunk count to report
+ chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count()
+ print(f" ✅ Created {chunk_count} chunks")
+ else:
+ errors += 1
+ print(f" ❌ Failed to process recording {recording.id}")
+
+ except Exception as e:
+ errors += 1
+ print(f" ❌ Error processing recording {recording.id}: {e}")
+
+ # Commit batch
+ try:
+ db.session.commit()
+ print(f" 💾 Batch committed successfully")
+ except Exception as e:
+ db.session.rollback()
+ print(f" ❌ Error committing batch: {e}")
+ errors += len(batch)
+
+ print(f"\n📊 Migration Summary:")
+ print(f" ✅ Successfully processed: {processed}")
+ print(f" ❌ Errors: {errors}")
+ print(f" 📈 Success rate: {(processed/(processed+errors)*100):.1f}%" if (processed+errors) > 0 else "N/A")
+
+ return errors == 0
+
+def main():
+ """Main function to handle command line arguments."""
+ import argparse
+
+ parser = argparse.ArgumentParser(description='Migrate existing recordings for Inquire Mode')
+ parser.add_argument('--dry-run', action='store_true',
+ help='Show what would be processed without actually processing')
+ parser.add_argument('--process', action='store_true',
+ help='Actually process the recordings')
+ parser.add_argument('--batch-size', type=int, default=10,
+ help='Number of recordings to process in each batch (default: 10)')
+
+ args = parser.parse_args()
+
+ if not args.dry_run and not args.process:
+ print("❌ Please specify either --dry-run or --process")
+ print("Use --help for more information")
+ return False
+
+ print("🎯 Inquire Mode Migration Tool")
+ print("=" * 40)
+
+ try:
+ if args.dry_run:
+ success = migrate_existing_recordings(args.batch_size, dry_run=True)
+ else:
+ print("⚠️ This will process all existing recordings and create embeddings.")
+ print("⚠️ This may take a while and use significant CPU/memory.")
+
+ confirm = input("Continue? (y/N): ")
+ if confirm.lower() != 'y':
+ print("❌ Migration cancelled by user")
+ return False
+
+ success = migrate_existing_recordings(args.batch_size, dry_run=False)
+
+ return success
+
+ except KeyboardInterrupt:
+ print("\n❌ Migration cancelled by user")
+ return False
+ except Exception as e:
+ print(f"❌ Migration failed: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+if __name__ == "__main__":
+ success = main()
+ sys.exit(0 if success else 1)
\ No newline at end of file
diff --git a/speakr/scripts/parse_asr_json.py b/speakr/scripts/parse_asr_json.py
new file mode 100644
index 0000000..df50d7a
--- /dev/null
+++ b/speakr/scripts/parse_asr_json.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""
+ASR JSON Parser - Analyzes speaker information in ASR response JSON files
+"""
+
+import json
+import sys
+from collections import defaultdict, Counter
+
+def analyze_asr_json(json_data):
+ """
+ Analyze ASR JSON data to understand speaker distribution and identify issues
+ """
+ if not isinstance(json_data, dict) or 'segments' not in json_data:
+ print("ERROR: Invalid JSON structure. Expected dict with 'segments' key.")
+ return
+
+ segments = json_data['segments']
+ if not isinstance(segments, list):
+ print("ERROR: 'segments' should be a list.")
+ return
+
+ print(f"=== ASR JSON Analysis ===")
+ print(f"Total segments: {len(segments)}")
+ print()
+
+ # Track segment-level speakers
+ segment_speakers = []
+ segments_with_speaker = 0
+ segments_without_speaker = 0
+
+ # Track word-level speakers
+ word_speakers = []
+ words_with_speaker = 0
+ words_without_speaker = 0
+
+ # Track segments with null speakers
+ null_speaker_segments = []
+
+ for i, segment in enumerate(segments):
+ # Analyze segment-level speaker
+ segment_speaker = segment.get('speaker')
+ if segment_speaker is not None:
+ segment_speakers.append(segment_speaker)
+ segments_with_speaker += 1
+ else:
+ segments_without_speaker += 1
+ null_speaker_segments.append(i)
+
+ # Analyze word-level speakers
+ words = segment.get('words', [])
+ for word_data in words:
+ word_speaker = word_data.get('speaker')
+ if word_speaker is not None:
+ word_speakers.append(word_speaker)
+ words_with_speaker += 1
+ else:
+ words_without_speaker += 1
+
+ # Print segment-level analysis
+ print("=== SEGMENT-LEVEL SPEAKERS ===")
+ print(f"Segments with speakers: {segments_with_speaker}")
+ print(f"Segments without speakers: {segments_without_speaker}")
+
+ if segment_speakers:
+ segment_speaker_counts = Counter(segment_speakers)
+ print(f"Unique segment speakers: {sorted(segment_speaker_counts.keys())}")
+ print("Segment speaker distribution:")
+ for speaker, count in segment_speaker_counts.most_common():
+ print(f" {speaker}: {count} segments")
+ else:
+ print("No segment-level speakers found!")
+
+ print()
+
+ # Print word-level analysis
+ print("=== WORD-LEVEL SPEAKERS ===")
+ print(f"Words with speakers: {words_with_speaker}")
+ print(f"Words without speakers: {words_without_speaker}")
+
+ if word_speakers:
+ word_speaker_counts = Counter(word_speakers)
+ print(f"Unique word speakers: {sorted(word_speaker_counts.keys())}")
+ print("Word speaker distribution:")
+ for speaker, count in word_speaker_counts.most_common():
+ print(f" {speaker}: {count} words")
+ else:
+ print("No word-level speakers found!")
+
+ print()
+
+ # Analyze segments without speakers
+ if null_speaker_segments:
+ print("=== SEGMENTS WITHOUT SPEAKERS ===")
+ print(f"Segment indices without speakers: {null_speaker_segments[:10]}{'...' if len(null_speaker_segments) > 10 else ''}")
+
+ print("\nFirst few segments without speakers:")
+ for i in null_speaker_segments[:5]:
+ segment = segments[i]
+ text = segment.get('text', '').strip()
+ start = segment.get('start')
+ end = segment.get('end')
+ words = segment.get('words', [])
+
+ print(f" Segment {i}: '{text}' ({start}-{end}s)")
+ print(f" Keys: {list(segment.keys())}")
+
+ # Check if words have speakers even when segment doesn't
+ word_speakers_in_segment = [w.get('speaker') for w in words if w.get('speaker')]
+ if word_speakers_in_segment:
+ word_speaker_counts = Counter(word_speakers_in_segment)
+ print(f" Word speakers: {dict(word_speaker_counts)}")
+ else:
+ print(f" No word speakers either")
+ print()
+
+ # Suggest speaker assignment strategy
+ print("=== SPEAKER ASSIGNMENT STRATEGY ===")
+ if segments_without_speaker > 0:
+ print(f"Found {segments_without_speaker} segments without speakers.")
+
+ if words_with_speaker > 0:
+ print("RECOMMENDATION: Use word-level speaker information to assign segment speakers.")
+ print("Strategy: For segments without speakers, find the most common speaker among their words.")
+ else:
+ print("RECOMMENDATION: Assign a default speaker label (e.g., 'UNKNOWN_SPEAKER') to segments without speakers.")
+ else:
+ print("All segments have speakers assigned. No action needed.")
+
+def suggest_preprocessing_fix(json_data):
+ """
+ Suggest how to fix the preprocessing based on the JSON structure
+ """
+ print("\n=== PREPROCESSING FIX SUGGESTION ===")
+
+ segments = json_data.get('segments', [])
+ if not segments:
+ return
+
+ # Check if we can derive segment speakers from word speakers
+ segments_fixable = 0
+ for segment in segments:
+ if segment.get('speaker') is None:
+ words = segment.get('words', [])
+ word_speakers = [w.get('speaker') for w in words if w.get('speaker')]
+ if word_speakers:
+ segments_fixable += 1
+
+ if segments_fixable > 0:
+ print(f"✅ {segments_fixable} segments can be fixed using word-level speaker information.")
+ print("\nSuggested code fix:")
+ print("""
+# In the ASR processing function, replace the segment processing with:
+for i, segment in enumerate(asr_response_data['segments']):
+ speaker = segment.get('speaker')
+ text = segment.get('text', '').strip()
+
+ # If segment doesn't have a speaker, try to derive from words
+ if speaker is None:
+ words = segment.get('words', [])
+ word_speakers = [w.get('speaker') for w in words if w.get('speaker')]
+ if word_speakers:
+ # Use the most common speaker among the words
+ from collections import Counter
+ speaker_counts = Counter(word_speakers)
+ speaker = speaker_counts.most_common(1)[0][0]
+ app.logger.info(f"Derived speaker '{speaker}' for segment {i} from word-level data")
+ else:
+ speaker = 'UNKNOWN_SPEAKER'
+ app.logger.warning(f"No speaker info available for segment {i}, using UNKNOWN_SPEAKER")
+
+ simplified_segments.append({
+ 'speaker': speaker,
+ 'sentence': text,
+ 'start_time': segment.get('start'),
+ 'end_time': segment.get('end')
+ })
+""")
+ else:
+ print("❌ Segments cannot be fixed using word-level data.")
+ print("Recommendation: Assign 'UNKNOWN_SPEAKER' to segments without speakers.")
+
+def main():
+ if len(sys.argv) != 2:
+ print("Usage: python parse_asr_json.py ")
+ print(" or: python parse_asr_json.py -")
+ print(" (use '-' to read from stdin)")
+ sys.exit(1)
+
+ filename = sys.argv[1]
+
+ try:
+ if filename == '-':
+ # Read from stdin
+ json_text = sys.stdin.read()
+ else:
+ # Read from file
+ with open(filename, 'r', encoding='utf-8') as f:
+ json_text = f.read()
+
+ # Parse JSON
+ json_data = json.loads(json_text)
+
+ # Analyze the data
+ analyze_asr_json(json_data)
+ suggest_preprocessing_fix(json_data)
+
+ except FileNotFoundError:
+ print(f"ERROR: File '{filename}' not found.")
+ sys.exit(1)
+ except json.JSONDecodeError as e:
+ print(f"ERROR: Invalid JSON - {e}")
+ sys.exit(1)
+ except Exception as e:
+ print(f"ERROR: {e}")
+ sys.exit(1)
+
+if __name__ == "__main__":
+ main()
diff --git a/speakr/scripts/reset_db.py b/speakr/scripts/reset_db.py
new file mode 100644
index 0000000..f74ce6f
--- /dev/null
+++ b/speakr/scripts/reset_db.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+
+# Add this near the top if you run this standalone often outside app context
+import os
+import sys
+import shutil
+# Add project root to path if necessary for 'app' import
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+
+# Load environment variables in case DB path relies on them (optional here)
+# from dotenv import load_dotenv
+# load_dotenv()
+
+# Check if running within app context already (e.g., via Flask command)
+try:
+ from flask import current_app
+ # Ensure app context is pushed if needed for config access
+ app = current_app._get_current_object()
+ # Make sure db is initialized within the app context if needed
+ # (SQLAlchemy initialization in app.py handles this mostly)
+ with app.app_context():
+ db = app.extensions['sqlalchemy'].db # Access db via extensions
+except (RuntimeError, AttributeError, KeyError):
+ # If not in app context, import directly
+ try:
+ # Ensure this import reflects the updated app.py with the new model
+ from src.app import app, db
+ except ImportError as e:
+ print(f"Error: Could not import 'app' and 'db': {e}")
+ print("Make sure reset_db.py is runnable and PYTHONPATH is set.")
+ sys.exit(1)
+
+def reset_database(delete_uploads=True):
+ # Determine the database path relative to the instance folder
+ # Use app config if available
+ instance_path = app.instance_path if hasattr(app, 'instance_path') else os.path.join(os.getcwd(), 'instance')
+ try:
+ # Ensure app context for config access if not already present
+ with app.app_context():
+ # Use absolute path from config
+ db_uri = app.config.get('SQLALCHEMY_DATABASE_URI', 'sqlite:///instance/transcriptions.db')
+ # Handle relative vs absolute paths specified in URI
+ if db_uri.startswith('sqlite:///'):
+ # Assume absolute path from URI root if starts with '///'
+ db_path = db_uri.replace('sqlite:///', '/', 1) # Replace only first
+ # Ensure instance path reflects the directory containing the DB
+ instance_path = os.path.dirname(db_path)
+ elif db_uri.startswith('sqlite://'):
+ # Assume relative path from instance folder
+ db_filename = db_uri.split('/')[-1]
+ db_path = os.path.join(instance_path, db_filename)
+ else: # Handle other DB types or formats if needed
+ print(f"Warning: Non-SQLite URI detected: {db_uri}. Deletion logic might need adjustment.")
+ # Attempt to parse or fallback
+ db_filename = db_uri.split('/')[-1] # Best guess
+ db_path = os.path.join(instance_path, db_filename)
+
+ except Exception as config_e:
+ print(f"Error accessing app config for DB path: {config_e}. Using default.")
+ # Fallback if config access fails
+ instance_path = os.path.join(os.getcwd(), 'instance')
+ db_filename = 'transcriptions.db'
+ db_path = os.path.join(instance_path, db_filename)
+
+ # Ensure instance directory exists
+ print(f"Ensuring instance directory exists: {instance_path}")
+ os.makedirs(instance_path, exist_ok=True)
+ print(f"Database path identified as: {db_path}")
+
+ # Remove existing database if it exists
+ if os.path.exists(db_path):
+ print(f"Removing existing database at {db_path}")
+ try:
+ os.remove(db_path)
+ # Also remove journal file if it exists
+ journal_path = db_path + "-journal"
+ if os.path.exists(journal_path):
+ os.remove(journal_path)
+ print(f"Removing existing journal file at {journal_path}")
+ except OSError as e:
+ print(f"Error removing database file: {e}. Check permissions or if it's in use.")
+ # Decide whether to exit or continue
+ # sys.exit(1)
+
+ # Create application context to work with the database
+ try:
+ with app.app_context():
+ print("Creating new database schema (including 'summary' column)...")
+ # Create all tables defined in models (app.py)
+ db.create_all()
+ print("Database schema created successfully!")
+ except Exception as e:
+ print(f"Error creating database schema: {e}")
+ # Attempt rollback if possible (though less relevant for create_all)
+ try:
+ db.session.rollback()
+ except Exception as rb_e:
+ print(f"Rollback attempt failed: {rb_e}")
+ sys.exit(1)
+
+ # Delete all files in the uploads directory if requested
+ if delete_uploads:
+ try:
+ uploads_dir = os.path.join(os.getcwd(), 'uploads')
+ if os.path.exists(uploads_dir):
+ print(f"Deleting all files in uploads directory: {uploads_dir}")
+ for filename in os.listdir(uploads_dir):
+ file_path = os.path.join(uploads_dir, filename)
+ try:
+ if os.path.isfile(file_path):
+ os.remove(file_path)
+ print(f"Deleted file: {file_path}")
+ elif os.path.isdir(file_path):
+ shutil.rmtree(file_path)
+ print(f"Deleted directory: {file_path}")
+ except Exception as e:
+ print(f"Error deleting {file_path}: {e}")
+ print("All files in uploads directory have been deleted.")
+ else:
+ print(f"Uploads directory not found: {uploads_dir}")
+ # Create the directory if it doesn't exist
+ os.makedirs(uploads_dir, exist_ok=True)
+ print(f"Created uploads directory: {uploads_dir}")
+ except Exception as e:
+ print(f"Error cleaning uploads directory: {e}")
+
+if __name__ == "__main__":
+ print("Attempting to reset the database and clean up all data...")
+ reset_database(delete_uploads=True)
+ print("Database reset process finished.")
diff --git a/speakr/scripts/resize_logo.py b/speakr/scripts/resize_logo.py
new file mode 100644
index 0000000..d1eff3f
--- /dev/null
+++ b/speakr/scripts/resize_logo.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""
+Logo Resizer Script for Speakr
+Resizes a source PNG image to all required icon sizes for PWA and favicon support.
+
+Usage:
+ python resize_logo.py
+
+Requirements:
+ pip install Pillow
+
+This script will create all the necessary icon sizes in the static/img/ directory.
+"""
+
+import sys
+import os
+from PIL import Image, ImageDraw
+import argparse
+
+def create_maskable_version(image, size):
+ """Create a maskable version with safe zone padding (20% on all sides)"""
+ # Calculate the size of the logo with padding
+ logo_size = int(size * 0.6) # Logo takes 60% of the canvas (20% padding on each side)
+
+ # Create new image with transparent background
+ maskable = Image.new('RGBA', (size, size), (0, 0, 0, 0))
+
+ # Resize the original logo
+ logo_resized = image.resize((logo_size, logo_size), Image.Resampling.LANCZOS)
+
+ # Calculate position to center the logo
+ x = (size - logo_size) // 2
+ y = (size - logo_size) // 2
+
+ # Paste the logo onto the center of the canvas
+ maskable.paste(logo_resized, (x, y), logo_resized if logo_resized.mode == 'RGBA' else None)
+
+ return maskable
+
+def resize_logo(source_path, output_dir="static/img"):
+ """Resize the source image to all required sizes"""
+
+ # Check if source file exists
+ if not os.path.exists(source_path):
+ print(f"Error: Source file '{source_path}' not found!")
+ return False
+
+ # Create output directory if it doesn't exist
+ os.makedirs(output_dir, exist_ok=True)
+
+ try:
+ # Open the source image
+ with Image.open(source_path) as img:
+ # Convert to RGBA if not already (for transparency support)
+ if img.mode != 'RGBA':
+ img = img.convert('RGBA')
+
+ print(f"Source image: {img.size[0]}x{img.size[1]} pixels")
+ print(f"Output directory: {output_dir}")
+ print()
+
+ # Define all the sizes we need
+ sizes = {
+ # Essential PWA icons
+ 'icon-192x192.png': 192,
+ 'icon-512x512.png': 512,
+
+ # Additional recommended icons
+ 'icon-16x16.png': 16,
+ 'icon-32x32.png': 32,
+ 'icon-180x180.png': 180, # Apple touch icon
+
+ # Maskable version
+ 'icon-maskable-512x512.png': 512,
+ }
+
+ # Resize to each required size
+ for filename, size in sizes.items():
+ output_path = os.path.join(output_dir, filename)
+
+ if 'maskable' in filename:
+ # Create maskable version with safe zone
+ resized = create_maskable_version(img, size)
+ print(f"✓ Created maskable icon: {filename} ({size}x{size})")
+ else:
+ # Regular resize
+ resized = img.resize((size, size), Image.Resampling.LANCZOS)
+ print(f"✓ Created icon: {filename} ({size}x{size})")
+
+ # Save the resized image
+ resized.save(output_path, 'PNG', optimize=True)
+
+ print()
+ print("🎉 All icons created successfully!")
+ print()
+ print("Next steps:")
+ print("1. Replace static/img/favicon.svg with your SVG version (if you have one)")
+ print("2. Clear browser cache and test the new icons")
+ print("3. Test PWA installation to verify icons appear correctly")
+
+ return True
+
+ except Exception as e:
+ print(f"Error processing image: {e}")
+ return False
+
+def create_ico_favicon(source_path, output_dir="static/img"):
+ """Create a multi-size ICO favicon file"""
+ try:
+ with Image.open(source_path) as img:
+ if img.mode != 'RGBA':
+ img = img.convert('RGBA')
+
+ # Create different sizes for the ICO file
+ sizes = [16, 32, 48]
+ images = []
+
+ for size in sizes:
+ resized = img.resize((size, size), Image.Resampling.LANCZOS)
+ images.append(resized)
+
+ # Save as ICO file
+ ico_path = os.path.join(output_dir, 'favicon.ico')
+ images[0].save(ico_path, format='ICO', sizes=[(img.width, img.height) for img in images])
+ print(f"✓ Created favicon.ico with sizes: {sizes}")
+
+ except Exception as e:
+ print(f"Warning: Could not create favicon.ico: {e}")
+
+def main():
+ parser = argparse.ArgumentParser(description='Resize logo for Speakr PWA icons')
+ parser.add_argument('source', help='Source PNG image file')
+ parser.add_argument('--output-dir', default='static/img', help='Output directory (default: static/img)')
+ parser.add_argument('--create-ico', action='store_true', help='Also create favicon.ico file')
+
+ args = parser.parse_args()
+
+ print("🎨 Speakr Logo Resizer")
+ print("=" * 50)
+
+ # Resize the logo
+ success = resize_logo(args.source, args.output_dir)
+
+ if success and args.create_ico:
+ print()
+ create_ico_favicon(args.source, args.output_dir)
+
+ if success:
+ print()
+ print("📁 Files created in", args.output_dir + ":")
+ for file in os.listdir(args.output_dir):
+ if file.startswith('icon-') and file.endswith('.png'):
+ file_path = os.path.join(args.output_dir, file)
+ size = os.path.getsize(file_path)
+ print(f" {file} ({size:,} bytes)")
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python resize_logo.py ")
+ print("Example: python resize_logo.py my_logo.png")
+ sys.exit(1)
+
+ main()
diff --git a/speakr/scripts/resize_logo.sh b/speakr/scripts/resize_logo.sh
new file mode 100644
index 0000000..dcf0e06
--- /dev/null
+++ b/speakr/scripts/resize_logo.sh
@@ -0,0 +1,83 @@
+#!/bin/bash
+
+# Logo Resizer Script for Speakr (ImageMagick version)
+# Resizes a source PNG image to all required icon sizes for PWA and favicon support.
+#
+# Usage: ./resize_logo.sh
+# Requirements: ImageMagick (sudo apt install imagemagick)
+
+set -e
+
+# Check if source file is provided
+if [ $# -eq 0 ]; then
+ echo "Usage: $0 "
+ echo "Example: $0 my_logo.png"
+ exit 1
+fi
+
+SOURCE_FILE="$1"
+OUTPUT_DIR="static/img"
+
+# Check if source file exists
+if [ ! -f "$SOURCE_FILE" ]; then
+ echo "Error: Source file '$SOURCE_FILE' not found!"
+ exit 1
+fi
+
+# Check if ImageMagick is installed
+if ! command -v convert &> /dev/null; then
+ echo "Error: ImageMagick is not installed!"
+ echo "Install it with: sudo apt install imagemagick"
+ exit 1
+fi
+
+# Create output directory if it doesn't exist
+mkdir -p "$OUTPUT_DIR"
+
+echo "🎨 Speakr Logo Resizer (ImageMagick)"
+echo "=================================================="
+echo "Source file: $SOURCE_FILE"
+echo "Output directory: $OUTPUT_DIR"
+echo
+
+# Define sizes
+declare -A SIZES=(
+ ["icon-16x16.png"]=16
+ ["icon-32x32.png"]=32
+ ["icon-180x180.png"]=180
+ ["icon-192x192.png"]=192
+ ["icon-512x512.png"]=512
+)
+
+# Resize to each size
+for filename in "${!SIZES[@]}"; do
+ size=${SIZES[$filename]}
+ output_path="$OUTPUT_DIR/$filename"
+
+ convert "$SOURCE_FILE" -resize "${size}x${size}" "$output_path"
+ echo "✓ Created icon: $filename (${size}x${size})"
+done
+
+# Create maskable version with padding
+echo "✓ Creating maskable icon with safe zone..."
+convert "$SOURCE_FILE" -resize 307x307 -gravity center -extent 512x512 -background transparent "$OUTPUT_DIR/icon-maskable-512x512.png"
+echo "✓ Created maskable icon: icon-maskable-512x512.png (512x512)"
+
+# Create favicon.ico (optional)
+if command -v convert &> /dev/null; then
+ echo "✓ Creating favicon.ico..."
+ convert "$SOURCE_FILE" -resize 16x16 -resize 32x32 -resize 48x48 "$OUTPUT_DIR/favicon.ico"
+ echo "✓ Created favicon.ico"
+fi
+
+echo
+echo "🎉 All icons created successfully!"
+echo
+echo "📁 Files created:"
+ls -la "$OUTPUT_DIR"/icon-*.png "$OUTPUT_DIR"/favicon.ico 2>/dev/null || true
+
+echo
+echo "Next steps:"
+echo "1. Replace static/img/favicon.svg with your SVG version (if you have one)"
+echo "2. Clear browser cache and test the new icons"
+echo "3. Test PWA installation to verify icons appear correctly"
diff --git a/speakr/scripts/test-docs-build.sh b/speakr/scripts/test-docs-build.sh
new file mode 100644
index 0000000..f59b9c4
--- /dev/null
+++ b/speakr/scripts/test-docs-build.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+
+# Test script to validate documentation build locally
+# This mimics what the GitHub Actions workflow does
+
+set -e
+
+echo "Testing documentation build..."
+
+# Check if we're in the right directory
+if [ ! -f "docs/mkdocs.yml" ]; then
+ echo "Error: docs/mkdocs.yml not found. Run this script from the project root."
+ exit 1
+fi
+
+# Create a virtual environment for testing
+echo "Creating virtual environment..."
+python3 -m venv .venv-docs-test
+source .venv-docs-test/bin/activate
+
+# Install dependencies
+echo "Installing dependencies..."
+pip install --upgrade pip
+pip install -r docs/requirements-docs.txt
+
+# Build the documentation
+echo "Building documentation..."
+cd docs
+export CI=true # Enable git plugin in CI mode
+mkdocs build --strict --site-dir _test_site
+
+echo ""
+echo "✅ Documentation build successful!"
+echo "Built site is in: docs/_test_site"
+echo ""
+echo "To serve locally for testing:"
+echo " cd docs && mkdocs serve"
+
+# Cleanup
+cd ..
+deactivate
+rm -rf .venv-docs-test
+
+echo "Cleanup complete."
\ No newline at end of file
diff --git a/speakr/scripts/update_version.py b/speakr/scripts/update_version.py
new file mode 100644
index 0000000..dcdfaf9
--- /dev/null
+++ b/speakr/scripts/update_version.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""
+Simple script to update the VERSION file.
+Usage: python update_version.py v0.4.3
+"""
+import sys
+import re
+
+def update_version(new_version):
+ # Validate version format (basic check)
+ if not re.match(r'^v?\d+\.\d+\.\d+', new_version):
+ print(f"Warning: Version '{new_version}' doesn't follow standard format (v1.2.3)")
+
+ # Ensure version starts with 'v'
+ if not new_version.startswith('v'):
+ new_version = 'v' + new_version
+
+ # Write to VERSION file
+ try:
+ with open('VERSION', 'w') as f:
+ f.write(new_version)
+ print(f"✅ Updated VERSION file to: {new_version}")
+
+ # Optional: Create git tag if in a git repo
+ import subprocess
+ try:
+ # Check if we're in a git repo
+ subprocess.check_output(['git', 'status'], stderr=subprocess.DEVNULL)
+
+ # Create and push tag
+ subprocess.check_output(['git', 'tag', new_version], stderr=subprocess.DEVNULL)
+ print(f"✅ Created git tag: {new_version}")
+
+ # Ask user if they want to push
+ response = input("Push tag to remote? (y/N): ").strip().lower()
+ if response == 'y':
+ subprocess.check_output(['git', 'push', 'origin', new_version])
+ print(f"✅ Pushed tag {new_version} to remote")
+
+ except subprocess.CalledProcessError:
+ print("ℹ️ Not in a git repo or git tag already exists")
+ except Exception as e:
+ print(f"ℹ️ Git operations failed: {e}")
+
+ except Exception as e:
+ print(f"❌ Failed to update VERSION file: {e}")
+ return False
+
+ return True
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ print("Usage: python update_version.py ")
+ print("Example: python update_version.py v0.4.3")
+ print("Example: python update_version.py 0.4.3-alpha")
+ sys.exit(1)
+
+ new_version = sys.argv[1]
+ success = update_version(new_version)
+ sys.exit(0 if success else 1)
\ No newline at end of file
diff --git a/speakr/src/api/__init__.py b/speakr/src/api/__init__.py
new file mode 100644
index 0000000..264fc5b
--- /dev/null
+++ b/speakr/src/api/__init__.py
@@ -0,0 +1,146 @@
+# API 蓝图统一导出和注册
+# 所有蓝图文件在此统一导出,并提供统一的注册函数
+
+from .auth import auth_bp, init_auth_bp
+from .recording import recording_bp, init_recording_bp
+from .recording_download import recording_download_bp
+from .share import share_bp, init_share_bp
+from .tag import tag_bp, init_tag_bp
+from .speaker import speaker_bp, init_speaker_bp
+from .admin import admin_bp, init_admin_bp
+from .search import search_bp, init_search_bp
+from .voiceprint import voiceprint_bp, init_voiceprint_bp
+from .config import config_bp, init_config_bp
+from .hotword import hotword_bp, init_hotword_bp
+from .template import template_bp, init_template_bp
+from .event import event_bp
+from .main import main_bp, init_main_bp
+from .draft_api import draft_bp
+from . import asr_ws # noqa: F401
+
+# 从 flask_ext 导入扩展实例(不再由 app.py 传入)
+from src.flask_ext import csrf, limiter, bcrypt
+
+# 从 clients 导入 LLM 客户端
+from src.clients import client
+
+
+def register_blueprints(app):
+ """
+ 统一注册所有蓝图到 Flask 应用
+
+ 扩展实例(csrf、limiter、bcrypt)和 LLM 客户端(client)
+ 直接从 flask_ext 和 clients 模块导入,无需外部传入。
+
+ Args:
+ app: Flask 应用实例
+ """
+ # 所有蓝图列表
+ blueprints = [
+ auth_bp, recording_bp, recording_download_bp, share_bp, tag_bp, speaker_bp,
+ search_bp, voiceprint_bp, config_bp, hotword_bp,
+ template_bp, event_bp, main_bp, admin_bp
+ ]
+
+ # 注册所有蓝图
+ for bp in blueprints:
+ app.register_blueprint(bp)
+ csrf.exempt(bp)
+
+ app.register_blueprint(draft_bp, url_prefix='/api/draft')
+ csrf.exempt(draft_bp)
+
+ # 初始化所有蓝图(传入各自需要的参数)
+ init_auth_bp(
+ limiter_instance=limiter,
+ csrf_instance=csrf,
+ bcrypt_instance=bcrypt,
+ use_asr_endpoint=app.config.get('USE_ASR_ENDPOINT', False),
+ asr_diarize=app.config.get('ASR_DIARIZE', False)
+ )
+
+ init_recording_bp(
+ limiter_instance=limiter,
+ csrf_instance=csrf,
+ bcrypt_instance=bcrypt,
+ logger=app.logger,
+ flask_app_instance=app,
+ openai_client=client,
+ use_asr_endpoint=app.config.get('USE_ASR_ENDPOINT', False),
+ asr_base_url=app.config.get('ASR_BASE_URL', ''),
+ asr_diarize=app.config.get('ASR_DIARIZE', False),
+ asr_min_speakers=app.config.get('ASR_MIN_SPEAKERS', 1),
+ asr_max_speakers=app.config.get('ASR_MAX_SPEAKERS', 10),
+ enable_chunking=app.config.get('ENABLE_CHUNKING', False),
+ chunking_svc=app.config.get('chunking_service'),
+ enable_inquire_mode=app.config.get('ENABLE_INQUIRE_MODE', False)
+ )
+
+ init_share_bp(csrf_instance=csrf)
+
+ init_tag_bp(
+ limiter_instance=limiter,
+ csrf_instance=csrf
+ )
+
+ init_speaker_bp(csrf_instance=csrf)
+
+ init_search_bp(
+ limiter_instance=limiter,
+ csrf_instance=csrf,
+ enable_inquire=app.config.get('ENABLE_INQUIRE_MODE', False),
+ use_asr_endpoint=app.config.get('USE_ASR_ENDPOINT', False)
+ )
+
+ init_voiceprint_bp(
+ limiter_instance=limiter,
+ csrf_instance=csrf,
+ bcrypt_instance=bcrypt
+ )
+
+ init_config_bp(
+ limiter_instance=limiter,
+ csrf_instance=csrf,
+ bcrypt_instance=bcrypt,
+ use_asr_endpoint=app.config.get('USE_ASR_ENDPOINT', False),
+ enable_chunking=app.config.get('ENABLE_CHUNKING', False),
+ chunking_svc=app.config.get('chunking_service')
+ )
+
+ init_hotword_bp(csrf_instance=csrf)
+
+ init_template_bp(csrf_instance=csrf)
+
+ init_main_bp(
+ limiter_instance=limiter,
+ csrf_instance=csrf,
+ bcrypt_instance=bcrypt,
+ use_asr_endpoint=app.config.get('USE_ASR_ENDPOINT', False),
+ asr_diarize=app.config.get('ASR_DIARIZE', False),
+ enable_inquire_mode=app.config.get('ENABLE_INQUIRE_MODE', False),
+ valid_token=app.config.get('VALID_TOKEN', '')
+ )
+
+ init_admin_bp(
+ limiter_instance=limiter,
+ csrf_instance=csrf,
+ bcrypt_instance=bcrypt,
+ enable_inquire_mode=app.config.get('ENABLE_INQUIRE_MODE', False),
+ embeddings_available=app.config.get('EMBEDDINGS_AVAILABLE', False),
+ use_asr_endpoint=app.config.get('USE_ASR_ENDPOINT', False),
+ asr_base_url=app.config.get('ASR_BASE_URL', ''),
+ text_model_base_url=app.config.get('TEXT_MODEL_BASE_URL', ''),
+ text_model_name=app.config.get('TEXT_MODEL_NAME', ''),
+ transcription_base_url=app.config.get('TRANSCRIPTION_BASE_URL', 'https://api.openai.com/v1'),
+ valid_token=app.config.get('VALID_TOKEN', '')
+ )
+
+
+__all__ = [
+ # 蓝图实例
+ 'auth_bp', 'recording_bp', 'share_bp', 'tag_bp', 'speaker_bp',
+ 'admin_bp', 'search_bp', 'voiceprint_bp', 'config_bp', 'hotword_bp',
+ 'template_bp', 'event_bp', 'main_bp', 'draft_bp',
+ # 注册函数
+ 'register_blueprints',
+]
diff --git a/speakr/src/api/admin.py b/speakr/src/api/admin.py
new file mode 100644
index 0000000..e07bfee
--- /dev/null
+++ b/speakr/src/api/admin.py
@@ -0,0 +1,731 @@
+# 管理后台蓝图
+# 处理管理员界面、用户管理、系统设置、统计信息等功能
+
+from flask import Blueprint, render_template, request, jsonify, redirect, flash, current_app
+from flask_login import login_required, current_user
+import os
+
+from src.clients import db
+from src.models import User, Recording, TranscriptChunk, SystemSetting
+from src.services import process_recording_chunks, get_file_monitor_functions
+
+# 创建管理后台蓝图
+admin_bp = Blueprint('admin', __name__)
+
+# 全局变量(从 app.py 导入)
+limiter = None
+csrf = None
+bcrypt = None
+ENABLE_INQUIRE_MODE = False
+EMBEDDINGS_AVAILABLE = False
+USE_ASR_ENDPOINT = False
+ASR_BASE_URL = None
+TEXT_MODEL_BASE_URL = None
+TEXT_MODEL_NAME = None
+TRANSCRIPTION_BASE_URL = None
+VALID_TOKEN = None
+
+def init_admin_bp(
+ limiter_instance, csrf_instance, bcrypt_instance,
+ enable_inquire_mode, embeddings_available,
+ use_asr_endpoint, asr_base_url,
+ text_model_base_url, text_model_name,
+ transcription_base_url, valid_token
+):
+ """初始化蓝图所需的全局变量"""
+ global limiter, csrf, bcrypt
+ global ENABLE_INQUIRE_MODE, EMBEDDINGS_AVAILABLE
+ global USE_ASR_ENDPOINT, ASR_BASE_URL
+ global TEXT_MODEL_BASE_URL, TEXT_MODEL_NAME
+ global TRANSCRIPTION_BASE_URL, VALID_TOKEN
+
+ limiter = limiter_instance
+ csrf = csrf_instance
+ bcrypt = bcrypt_instance
+ ENABLE_INQUIRE_MODE = enable_inquire_mode
+ EMBEDDINGS_AVAILABLE = embeddings_available
+ USE_ASR_ENDPOINT = use_asr_endpoint
+ ASR_BASE_URL = asr_base_url
+ TEXT_MODEL_BASE_URL = text_model_base_url
+ TEXT_MODEL_NAME = text_model_name
+ TRANSCRIPTION_BASE_URL = transcription_base_url
+ VALID_TOKEN = valid_token
+
+
+# ==================== 管理员界面路由 ====================
+
+@admin_bp.route('/admin', methods=['GET'])
+@login_required
+def admin():
+ """管理员界面主页"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ flash('您没有访问管理员页面的权限。', 'danger')
+ return redirect('/tool/speakr')
+ return render_template('admin.html', title='管理后台', inquire_mode_enabled=ENABLE_INQUIRE_MODE)
+
+
+# ==================== 用户管理路由 ====================
+
+@admin_bp.route('/admin/users', methods=['GET'])
+@login_required
+def admin_get_users():
+ """获取所有用户列表"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ users = User.query.all()
+ user_data = []
+
+ for user in users:
+ # 获取录音数量和存储使用量
+ recordings_count = len(user.recordings)
+ storage_used = sum(r.file_size for r in user.recordings if r.file_size) or 0
+
+ user_data.append({
+ 'id': user.id,
+ 'username': user.username,
+ 'email': user.email,
+ 'is_admin': user.is_admin,
+ 'recordings_count': recordings_count,
+ 'storage_used': storage_used
+ })
+
+ return jsonify(user_data)
+
+
+@admin_bp.route('/admin/users', methods=['POST'])
+@login_required
+def admin_add_user():
+ """添加新用户"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供数据'}), 400
+
+ # 验证必填字段
+ required_fields = ['username', 'email', 'password']
+ for field in required_fields:
+ if field not in data:
+ return jsonify({'error': f'缺少必填字段: {field}'}), 400
+
+ # 检查用户名或邮箱是否已存在
+ if User.query.filter_by(username=data['username']).first():
+ return jsonify({'error': '用户名已存在'}), 400
+
+ if User.query.filter_by(email=data['email']).first():
+ return jsonify({'error': '邮箱已存在'}), 400
+
+ # 创建新用户
+ hashed_password = bcrypt.generate_password_hash(data['password']).decode('utf-8')
+ new_user = User(
+ username=data['username'],
+ email=data['email'],
+ password=hashed_password,
+ is_admin=data.get('is_admin', False)
+ )
+
+ db.session.add(new_user)
+ db.session.commit()
+
+ return jsonify({
+ 'id': new_user.id,
+ 'username': new_user.username,
+ 'email': new_user.email,
+ 'is_admin': new_user.is_admin,
+ 'recordings_count': 0,
+ 'storage_used': 0
+ }), 201
+
+
+@admin_bp.route('/admin/users/', methods=['PUT'])
+@login_required
+def admin_update_user(user_id):
+ """更新用户信息"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ user = db.session.get(User, user_id)
+ if not user:
+ return jsonify({'error': '用户不存在'}), 404
+
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供数据'}), 400
+
+ # 更新用户字段
+ if 'username' in data and data['username'] != user.username:
+ # 检查用户名是否已存在
+ if User.query.filter_by(username=data['username']).first():
+ return jsonify({'error': '用户名已存在'}), 400
+ user.username = data['username']
+
+ if 'email' in data and data['email'] != user.email:
+ # 检查邮箱是否已存在
+ if User.query.filter_by(email=data['email']).first():
+ return jsonify({'error': '邮箱已存在'}), 400
+ user.email = data['email']
+
+ if 'password' in data and data['password']:
+ user.password = bcrypt.generate_password_hash(data['password']).decode('utf-8')
+
+ if 'is_admin' in data:
+ user.is_admin = data['is_admin']
+
+ db.session.commit()
+
+ # 获取录音数量和存储使用量
+ recordings_count = len(user.recordings)
+ storage_used = sum(r.file_size for r in user.recordings if r.file_size) or 0
+
+ return jsonify({
+ 'id': user.id,
+ 'username': user.username,
+ 'email': user.email,
+ 'is_admin': user.is_admin,
+ 'recordings_count': recordings_count,
+ 'storage_used': storage_used
+ })
+
+
+@admin_bp.route('/admin/users/', methods=['DELETE'])
+@login_required
+def admin_delete_user(user_id):
+ """删除用户"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ # 防止删除自己
+ if user_id == current_user.id:
+ return jsonify({'error': '不能删除自己的账户'}), 400
+
+ user = db.session.get(User, user_id)
+ if not user:
+ return jsonify({'error': '用户不存在'}), 404
+
+ # 删除用户的录音和音频文件
+ total_chunks = 0
+ if ENABLE_INQUIRE_MODE:
+ total_chunks = TranscriptChunk.query.filter_by(user_id=user_id).count()
+ if total_chunks > 0:
+ from flask import current_app
+ current_app.logger.info(f"正在删除用户 {user_id} 的 {total_chunks} 个转录片段及其嵌入向量")
+
+ for recording in user.recordings:
+ try:
+ if recording.audio_path and os.path.exists(recording.audio_path):
+ os.remove(recording.audio_path)
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"删除音频文件 {recording.audio_path} 时出错: {e}")
+
+ # 删除用户(级联删除将处理所有相关数据,包括片段/嵌入向量)
+ db.session.delete(user)
+ db.session.commit()
+
+ if ENABLE_INQUIRE_MODE and total_chunks > 0:
+ from flask import current_app
+ current_app.logger.info(f"成功删除用户 {user_id} 的 {total_chunks} 个嵌入向量和片段")
+
+ return jsonify({'success': True})
+
+
+@admin_bp.route('/admin/users//toggle-admin', methods=['POST'])
+@login_required
+def admin_toggle_admin(user_id):
+ """切换用户管理员权限"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ # 防止修改自己的管理员状态
+ if user_id == current_user.id:
+ return jsonify({'error': '不能修改自己的管理员状态'}), 400
+
+ user = db.session.get(User, user_id)
+ if not user:
+ return jsonify({'error': '用户不存在'}), 404
+
+ # 切换管理员状态
+ user.is_admin = not user.is_admin
+ db.session.commit()
+
+ return jsonify({'success': True, 'is_admin': user.is_admin})
+
+
+# ==================== 系统统计路由 ====================
+
+@admin_bp.route('/admin/stats', methods=['GET'])
+@login_required
+def admin_get_stats():
+ """获取系统统计信息"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ # 获取总用户数
+ total_users = User.query.count()
+
+ # 获取总录音数
+ total_recordings = Recording.query.count()
+
+ # 按状态获取录音数
+ completed_recordings = Recording.query.filter_by(status='COMPLETED').count()
+ processing_recordings = Recording.query.filter(Recording.status.in_(['PROCESSING', 'SUMMARIZING'])).count()
+ pending_recordings = Recording.query.filter_by(status='PENDING').count()
+ failed_recordings = Recording.query.filter_by(status='FAILED').count()
+
+ # 获取总存储使用量
+ total_storage = db.session.query(db.func.sum(Recording.file_size)).scalar() or 0
+
+ # 获取存储使用量最多的前5个用户
+ top_users_query = db.session.query(
+ User.id,
+ User.username,
+ db.func.count(Recording.id).label('recordings_count'),
+ db.func.sum(Recording.file_size).label('storage_used')
+ ).join(Recording, User.id == Recording.user_id, isouter=True) \
+ .group_by(User.id) \
+ .order_by(db.func.sum(Recording.file_size).desc()) \
+ .limit(5)
+
+ top_users = []
+ for user_id, username, recordings_count, storage_used in top_users_query:
+ top_users.append({
+ 'id': user_id,
+ 'username': username,
+ 'recordings_count': recordings_count or 0,
+ 'storage_used': storage_used or 0
+ })
+
+ # 获取总查询数(聊天请求)
+ # 这是一个占位符 - 需要在数据库中跟踪此数据
+ total_queries = 0
+
+ return jsonify({
+ 'total_users': total_users,
+ 'total_recordings': total_recordings,
+ 'completed_recordings': completed_recordings,
+ 'processing_recordings': processing_recordings,
+ 'pending_recordings': pending_recordings,
+ 'failed_recordings': failed_recordings,
+ 'total_storage': total_storage,
+ 'top_users': top_users,
+ 'total_queries': total_queries
+ })
+
+
+# ==================== 系统设置路由 ====================
+
+@admin_bp.route('/admin/settings', methods=['GET'])
+@login_required
+def admin_get_settings():
+ """获取所有系统设置"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ settings = SystemSetting.query.all()
+ return jsonify([setting.to_dict() for setting in settings])
+
+
+@admin_bp.route('/admin/settings', methods=['POST'])
+@login_required
+def admin_update_setting():
+ """更新系统设置"""
+ # 检查用户是否为管理员
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供数据'}), 400
+
+ key = data.get('key')
+ value = data.get('value')
+ description = data.get('description')
+ setting_type = data.get('setting_type', 'string')
+
+ if not key:
+ return jsonify({'error': '设置键名是必填的'}), 400
+
+ # 验证设置类型
+ valid_types = ['string', 'integer', 'boolean', 'float']
+ if setting_type not in valid_types:
+ return jsonify({'error': f'无效的设置类型。必须是以下之一: {", ".join(valid_types)}'}), 400
+
+ # 根据类型验证值
+ if setting_type == 'integer':
+ try:
+ int(value) if value is not None and value != '' else None
+ except (ValueError, TypeError):
+ return jsonify({'error': '值必须是有效的整数'}), 400
+ elif setting_type == 'float':
+ try:
+ float(value) if value is not None and value != '' else None
+ except (ValueError, TypeError):
+ return jsonify({'error': '值必须是有效的数字'}), 400
+ elif setting_type == 'boolean':
+ if value not in ['true', 'false', '1', '0', 'yes', 'no', True, False, 1, 0]:
+ return jsonify({'error': '值必须是有效的布尔值 (true/false, 1/0, yes/no)'}), 400
+
+ try:
+ setting = SystemSetting.set_setting(key, value, description, setting_type)
+ return jsonify(setting.to_dict())
+ except Exception as e:
+ db.session.rollback()
+ from flask import current_app
+ current_app.logger.error(f"更新设置 {key} 时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+# ==================== 录音迁移路由 ====================
+
+@admin_bp.route('/api/admin/migrate_recordings', methods=['POST'])
+@login_required
+def migrate_existing_recordings_api():
+ """迁移现有录音以支持询问模式(仅限管理员)"""
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权。需要管理员权限。'}), 403
+
+ try:
+ # 统计需要处理的录音
+ completed_recordings = Recording.query.filter_by(status='COMPLETED').all()
+ recordings_needing_processing = []
+
+ for recording in completed_recordings:
+ if recording.transcription: # 有转录文本
+ chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count()
+ if chunk_count == 0: # 还没有片段
+ recordings_needing_processing.append(recording)
+
+ if len(recordings_needing_processing) == 0:
+ return jsonify({
+ 'success': True,
+ 'message': '所有录音都已为询问模式处理完成',
+ 'processed': 0,
+ 'total': len(completed_recordings)
+ })
+
+ # 分批处理以避免超时
+ batch_size = min(5, len(recordings_needing_processing)) # 每次最多处理5个
+ processed = 0
+ errors = 0
+
+ for i in range(min(batch_size, len(recordings_needing_processing))):
+ recording = recordings_needing_processing[i]
+ try:
+ success = process_recording_chunks(recording.id)
+ if success:
+ processed += 1
+ else:
+ errors += 1
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"迁移时处理录音 {recording.id} 出错: {e}")
+ errors += 1
+
+ remaining = max(0, len(recordings_needing_processing) - batch_size)
+
+ return jsonify({
+ 'success': True,
+ 'message': f'已处理 {processed} 个录音。剩余 {remaining} 个。',
+ 'processed': processed,
+ 'errors': errors,
+ 'remaining': remaining,
+ 'total': len(recordings_needing_processing)
+ })
+
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"迁移 API 出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+# ==================== 自动处理路由 ====================
+
+@admin_bp.route('/admin/auto-process/status', methods=['GET'])
+@login_required
+def admin_get_auto_process_status():
+ """获取自动文件处理系统的状态"""
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ try:
+ _, _, get_file_monitor_status = get_file_monitor_functions()
+ status = get_file_monitor_status()
+
+ # 添加配置信息(从 Flask app.config 读取)
+ config = {
+ 'enabled': current_app.config.get('ENABLE_AUTO_PROCESSING', 'false'),
+ 'watch_directory': current_app.config.get('AUTO_PROCESS_WATCH_DIR', '/data/auto-process'),
+ 'check_interval': current_app.config.get('AUTO_PROCESS_CHECK_INTERVAL', 30),
+ 'mode': current_app.config.get('AUTO_PROCESS_MODE', 'admin_only'),
+ 'default_username': current_app.config.get('AUTO_PROCESS_DEFAULT_USERNAME')
+ }
+
+ return jsonify({
+ 'status': status,
+ 'config': config
+ })
+
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"获取自动处理状态时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@admin_bp.route('/admin/auto-process/start', methods=['POST'])
+@login_required
+def admin_start_auto_process():
+ """启动自动文件处理系统"""
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ try:
+ start_file_monitor, _, _ = get_file_monitor_functions()
+ start_file_monitor()
+ return jsonify({'success': True, 'message': '自动处理已启动'})
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"启动自动处理时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@admin_bp.route('/admin/auto-process/stop', methods=['POST'])
+@login_required
+def admin_stop_auto_process():
+ """停止自动文件处理系统"""
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ try:
+ _, stop_file_monitor, _ = get_file_monitor_functions()
+ stop_file_monitor()
+ return jsonify({'success': True, 'message': '自动处理已停止'})
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"停止自动处理时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@admin_bp.route('/admin/auto-process/config', methods=['POST'])
+@login_required
+def admin_update_auto_process_config():
+ """更新自动处理配置(需要重启)"""
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ try:
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供配置数据'}), 400
+
+ # 此端点通常会更新环境变量或配置文件
+ # 目前,我们只返回当前配置并说明需要重启
+ return jsonify({
+ 'success': True,
+ 'message': '配置已更新。需要重启才能应用更改。',
+ 'note': '环境变量需要手动更新,并且需要重启应用程序。'
+ })
+
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"更新自动处理配置时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+# ==================== Inquire 管理路由 ====================
+
+@admin_bp.route('/admin/inquire/process-recordings', methods=['POST'])
+@login_required
+def admin_process_recordings_for_inquire():
+ """处理所有剩余的录音以支持询问模式(对它们进行分块和嵌入)"""
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ try:
+ # 从请求中获取可选参数
+ data = request.json or {}
+ batch_size = data.get('batch_size', 10)
+ max_recordings = data.get('max_recordings', None)
+
+ # 查找需要处理的录音
+ completed_recordings = Recording.query.filter_by(status='COMPLETED').all()
+ recordings_needing_processing = []
+
+ for recording in completed_recordings:
+ if recording.transcription: # 有转录文本
+ chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count()
+ if chunk_count == 0: # 还没有片段
+ recordings_needing_processing.append(recording)
+ if max_recordings and len(recordings_needing_processing) >= max_recordings:
+ break
+
+ total_to_process = len(recordings_needing_processing)
+
+ if total_to_process == 0:
+ return jsonify({
+ 'success': True,
+ 'message': '所有录音都已为询问模式处理完成。',
+ 'processed': 0,
+ 'total': 0
+ })
+
+ # 分批处理录音
+ processed = 0
+ failed = []
+
+ for recording in recordings_needing_processing:
+ try:
+ success = process_recording_chunks(recording.id)
+ if success:
+ processed += 1
+ from flask import current_app
+ current_app.logger.info(f"管理员 API: 已处理录音的分块: {recording.title} ({recording.id})")
+ else:
+ failed.append({'id': recording.id, 'title': recording.title, 'reason': '处理返回 false'})
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"管理员 API: 处理录音 {recording.id} 失败: {e}")
+ failed.append({'id': recording.id, 'title': recording.title, 'reason': str(e)})
+
+ # 每批次后提交
+ if processed % batch_size == 0:
+ db.session.commit()
+
+ # 最终提交
+ db.session.commit()
+
+ return jsonify({
+ 'success': True,
+ 'message': f'已处理 {total_to_process} 个录音中的 {processed} 个。',
+ 'processed': processed,
+ 'total': total_to_process,
+ 'failed': failed
+ })
+
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"管理员处理录音端点出错: {e}")
+ db.session.rollback()
+ return jsonify({'error': str(e)}), 500
+
+
+@admin_bp.route('/admin/inquire/status', methods=['GET'])
+@login_required
+def admin_inquire_status():
+ """获取询问模式的录音状态"""
+ if not current_user.is_admin:
+ return jsonify({'error': '未授权'}), 403
+
+ try:
+ # 统计已完成的录音总数
+ total_completed = Recording.query.filter_by(status='COMPLETED').count()
+
+ # 统计有转录文本的录音数
+ recordings_with_transcriptions = Recording.query.filter(
+ Recording.status == 'COMPLETED',
+ Recording.transcription.isnot(None),
+ Recording.transcription != ''
+ ).count()
+
+ # 统计已为询问模式处理的录音数
+ processed_recordings = db.session.query(Recording.id).join(
+ TranscriptChunk, Recording.id == TranscriptChunk.recording_id
+ ).distinct().count()
+
+ # 统计仍需处理的录音数
+ completed_recordings = Recording.query.filter_by(status='COMPLETED').all()
+ need_processing = 0
+
+ for recording in completed_recordings:
+ if recording.transcription: # 有转录文本
+ chunk_count = TranscriptChunk.query.filter_by(recording_id=recording.id).count()
+ if chunk_count == 0: # 还没有片段
+ need_processing += 1
+
+ # 获取总片段数和嵌入向量数
+ total_chunks = TranscriptChunk.query.count()
+
+ return jsonify({
+ 'total_completed_recordings': total_completed,
+ 'recordings_with_transcriptions': recordings_with_transcriptions,
+ 'processed_for_inquire': processed_recordings,
+ 'need_processing': need_processing,
+ 'total_chunks': total_chunks,
+ 'embeddings_available': EMBEDDINGS_AVAILABLE
+ })
+
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"获取询问状态时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+# ==================== 系统信息和用户偏好路由 ====================
+
+@admin_bp.route('/api/user/preferences', methods=['POST'])
+@login_required
+def save_user_preferences():
+ """保存用户偏好设置,包括界面语言"""
+ data = request.json
+
+ if 'language' in data:
+ current_user.ui_language = data['language']
+
+ db.session.commit()
+
+ return jsonify({
+ 'success': True,
+ 'message': '偏好设置保存成功',
+ 'ui_language': current_user.ui_language
+ })
+
+
+def get_version():
+ """获取系统版本号"""
+ # 首先尝试读取 VERSION 文件(在 Docker 中有效)
+ try:
+ with open('VERSION', 'r') as f:
+ return f.read().strip()
+ except FileNotFoundError:
+ pass
+
+ # 回退到 git 标签(在开发环境中有效)
+ try:
+ import subprocess
+ return subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'],
+ stderr=subprocess.DEVNULL).decode().strip()
+ except:
+ pass
+
+ # 最终回退
+ return "unknown"
+
+
+@admin_bp.route('/api/system/info', methods=['GET'])
+def get_system_info():
+ """获取系统信息,包括版本和模型详情"""
+ try:
+ # 使用与启动时相同的版本检测逻辑
+ version = get_version()
+
+ return jsonify({
+ 'version': version,
+ 'llm_endpoint': TEXT_MODEL_BASE_URL,
+ 'llm_model': TEXT_MODEL_NAME,
+ 'whisper_endpoint': os.environ.get('TRANSCRIPTION_BASE_URL', 'https://api.openai.com/v1'),
+ 'asr_enabled': USE_ASR_ENDPOINT,
+ 'asr_endpoint': ASR_BASE_URL if USE_ASR_ENDPOINT else None
+ })
+ except Exception as e:
+ from flask import current_app
+ current_app.logger.error(f"获取系统信息时出错: {e}")
+ return jsonify({'error': '无法获取系统信息'}), 500
diff --git a/speakr/src/api/api_response.py b/speakr/src/api/api_response.py
new file mode 100644
index 0000000..e235535
--- /dev/null
+++ b/speakr/src/api/api_response.py
@@ -0,0 +1,89 @@
+# API 统一响应工具
+# 为新建的 API 接口提供标准化的返回格式
+# 注意:旧接口暂不强制迁移,新接口统一使用本模块
+
+from flask import jsonify
+
+
+class ApiResponse:
+ """统一的 API 响应格式工具类
+
+ 使用示例:
+ # 成功响应(返回数据)
+ return ApiResponse.success({'recording': recording.to_dict()})
+
+ # 成功响应(带提示消息)
+ return ApiResponse.success(message='操作成功')
+
+ # 成功响应(数据 + 消息)
+ return ApiResponse.success({'data': result}, '保存成功')
+
+ # 成功响应(自定义状态码)
+ return ApiResponse.success({'user': user_data}, status_code=201)
+
+ # 错误响应
+ return ApiResponse.error('录音未找到', 404)
+
+ # 错误响应(带详情)
+ return ApiResponse.error('参数校验失败', 400, detail='email 格式不正确')
+
+ # 分页响应
+ return ApiResponse.paginated(items, pagination_meta)
+ """
+
+ @staticmethod
+ def success(data=None, message=None, status_code=200):
+ """成功响应
+
+ Args:
+ data: 业务数据(字典类型),会合并到响应体中
+ message: 可选的成功提示消息
+ status_code: HTTP 状态码,默认 200
+
+ Returns:
+ (Response, status_code) 元组
+ """
+ response = {}
+ if message:
+ response['message'] = message
+ if data is not None:
+ if not isinstance(data, dict):
+ raise TypeError('data 必须是字典类型,请使用 paginated() 返回列表数据')
+ response.update(data)
+ response['success'] = True
+ return jsonify(response), status_code
+
+ @staticmethod
+ def error(message, status_code=400, detail=None):
+ """错误响应
+
+ Args:
+ message: 用户友好的错误提示消息
+ status_code: HTTP 状态码,默认 400
+ detail: 可选的详细错误信息(仅开发环境建议返回)
+
+ Returns:
+ (Response, status_code) 元组
+ """
+ response = {'error': message}
+ if detail:
+ response['detail'] = detail
+ return jsonify(response), status_code
+
+ @staticmethod
+ def paginated(items, pagination, status_code=200):
+ """分页响应
+
+ Args:
+ items: 数据列表
+ pagination: 分页元数据字典,包含 page, per_page, total, total_pages, has_next, has_prev
+ status_code: HTTP 状态码,默认 200
+
+ Returns:
+ (Response, status_code) 元组
+ """
+ return jsonify({
+ 'items': items,
+ 'pagination': pagination,
+ 'success': True
+ }), status_code
diff --git a/speakr/src/api/asr_ws.py b/speakr/src/api/asr_ws.py
new file mode 100644
index 0000000..03580b0
--- /dev/null
+++ b/speakr/src/api/asr_ws.py
@@ -0,0 +1,179 @@
+import json
+import ssl
+import threading
+import time
+from urllib.parse import urlparse
+
+import websocket
+from flask import current_app, request
+from flask_login import current_user
+
+from src.config import FUNASR_WEBSOCKET_IP
+from src.flask_ext import sock
+from src.models import HotWord, YlHotWord
+
+
+SCENE_YL = 'yl'
+SCENE_CG = 'cg'
+
+
+def _send_json(ws, payload):
+ try:
+ ws.send(json.dumps(payload, ensure_ascii=False))
+ except Exception:
+ pass
+
+
+def _normalize_scene(scene):
+ return SCENE_YL if str(scene or '').strip().lower() == SCENE_YL else SCENE_CG
+
+
+def _hotword_model_for_scene(scene):
+ return YlHotWord if _normalize_scene(scene) == SCENE_YL else HotWord
+
+
+def _funasr_url():
+ base = (FUNASR_WEBSOCKET_IP or '').strip().rstrip('/')
+ if not base:
+ raise RuntimeError('FUNASR_WEBSOCKET_IP 未配置')
+ if not base.startswith(('ws://', 'wss://')):
+ base = f'wss://{base}'
+ parsed = urlparse(base)
+ if parsed.path and parsed.path != '/':
+ return base
+ return f'{base}/websocket_offline'
+
+
+def _hotwords_payload(logger, scene=SCENE_CG):
+ try:
+ hotword_model = _hotword_model_for_scene(scene)
+ rows = hotword_model.query.order_by(hotword_model.created_at.desc()).all()
+ return json.dumps({row.keyword: row.weight for row in rows}, ensure_ascii=False)
+ except Exception as exc:
+ logger.warning('加载实时 ASR 热词失败:%s', exc)
+ return '{}'
+
+
+def _asr_init_payload(mode, hotwords):
+ return {
+ 'chunk_size': [10, 10, 10],
+ 'wav_name': 'h5',
+ 'is_speaking': True,
+ 'chunk_interval': 10,
+ 'itn': True,
+ 'mode': mode,
+ 'hotwords': hotwords,
+ }
+
+
+@sock.route('/ws/asr/live')
+def live_asr_proxy(client_ws):
+ if not current_user.is_authenticated:
+ _send_json(client_ws, {'type': 'error', 'error': 'unauthorized'})
+ return
+
+ mode = request.args.get('mode') or 'online'
+ if mode not in {'online', 'offline', '2pass'}:
+ mode = 'online'
+ scene = _normalize_scene(request.args.get('scene'))
+
+ app = current_app._get_current_object()
+ logger = app.logger
+ hotwords = _hotwords_payload(logger, scene)
+
+ upstream_open = threading.Event()
+ upstream_closed = threading.Event()
+ stop_requested = threading.Event()
+ upstream_holder = {'ws': None}
+
+ def on_open(upstream):
+ upstream_holder['ws'] = upstream
+ upstream.send(json.dumps(_asr_init_payload(mode, hotwords), ensure_ascii=False))
+ upstream_open.set()
+ _send_json(client_ws, {'type': 'state', 'state': 'connected', 'mode': mode, 'scene': scene})
+
+ def on_message(_upstream, message):
+ try:
+ client_ws.send(message)
+ except Exception:
+ upstream_closed.set()
+
+ def on_error(_upstream, error):
+ logger.warning('FunASR WebSocket 错误:%s', error)
+ _send_json(client_ws, {'type': 'error', 'error': 'funasr_error', 'message': str(error)})
+
+ def on_close(_upstream, _status_code, _message):
+ upstream_closed.set()
+ _send_json(client_ws, {'type': 'state', 'state': 'closed'})
+
+ try:
+ upstream_url = _funasr_url()
+ except Exception as exc:
+ _send_json(client_ws, {'type': 'error', 'error': 'configuration_error', 'message': str(exc)})
+ return
+
+ upstream_app = websocket.WebSocketApp(
+ upstream_url,
+ on_open=on_open,
+ on_message=on_message,
+ on_error=on_error,
+ on_close=on_close,
+ )
+ run_options = {}
+ # FunASR 可能使用内网或自签名证书,仅对当前 WSS 上游连接跳过 TLS 证书和主机名校验。
+ if upstream_url.startswith('wss://'):
+ run_options['sslopt'] = {
+ 'cert_reqs': ssl.CERT_NONE,
+ 'check_hostname': False,
+ }
+ upstream_thread = threading.Thread(
+ target=upstream_app.run_forever,
+ kwargs=run_options,
+ daemon=True,
+ )
+ upstream_thread.start()
+
+ if not upstream_open.wait(timeout=10):
+ _send_json(client_ws, {'type': 'error', 'error': 'funasr_unavailable', 'message': 'Timed out connecting to FunASR'})
+ upstream_app.close()
+ return
+
+ try:
+ while not upstream_closed.is_set():
+ message = client_ws.receive(timeout=1)
+ if message is None:
+ continue
+ upstream = upstream_holder.get('ws')
+ if not upstream or not upstream.sock or not upstream.sock.connected:
+ _send_json(client_ws, {'type': 'error', 'error': 'funasr_closed'})
+ break
+ if isinstance(message, bytes):
+ upstream.send(message, opcode=websocket.ABNF.OPCODE_BINARY)
+ continue
+
+ try:
+ payload = json.loads(message)
+ except (TypeError, ValueError):
+ upstream.send(message)
+ continue
+
+ if payload.get('type') == 'stop':
+ upstream.send(json.dumps({'is_speaking': False}, ensure_ascii=False))
+ stop_requested.set()
+ deadline = time.time() + 3.0
+ while not upstream_closed.is_set() and time.time() < deadline:
+ time.sleep(0.05)
+ break
+ if 'is_speaking' in payload or payload.get('mode'):
+ upstream.send(json.dumps(payload, ensure_ascii=False))
+ except Exception as exc:
+ logger.warning('Realtime ASR proxy session ended with error: %s', exc)
+ _send_json(client_ws, {'type': 'error', 'error': 'proxy_error', 'message': str(exc)})
+ finally:
+ try:
+ upstream_app.close()
+ except Exception:
+ pass
+ upstream_closed.set()
+ _send_json(client_ws, {'type': 'state', 'state': 'closed'})
+ time.sleep(0.05)
\ No newline at end of file
diff --git a/speakr/src/api/auth.py b/speakr/src/api/auth.py
new file mode 100644
index 0000000..b6656ea
--- /dev/null
+++ b/speakr/src/api/auth.py
@@ -0,0 +1,246 @@
+# 认证蓝图
+# 处理用户注册、登录、登出、账户管理等认证相关功能
+
+from flask import Blueprint, render_template, request, redirect, flash, session, make_response, jsonify
+from flask_login import login_required, current_user, logout_user
+from wtforms import ValidationError
+import os
+
+from src.clients import db
+from src.models import User, SystemSetting, password_check
+from src.services import auto_login
+
+# 创建认证蓝图
+auth_bp = Blueprint('auth', __name__)
+
+# 全局变量(从 app.py 导入)
+limiter = None
+csrf = None
+bcrypt = None
+USE_ASR_ENDPOINT = None
+ASR_DIARIZE = None
+
+def init_auth_bp(limiter_instance, csrf_instance, bcrypt_instance, use_asr_endpoint, asr_diarize):
+ """初始化蓝图所需的全局变量"""
+ global limiter, csrf, bcrypt, USE_ASR_ENDPOINT, ASR_DIARIZE
+ limiter = limiter_instance
+ csrf = csrf_instance
+ bcrypt = bcrypt_instance
+ USE_ASR_ENDPOINT = use_asr_endpoint
+ ASR_DIARIZE = asr_diarize
+
+ # 应用路由级限流(在 init 中应用,避免模块导入时 limiter 为 None)
+ # 注册限流:每分钟最多5次,防止恶意注册
+ auth_bp.view_functions['register'] = limiter.limit("5 per minute")(register)
+ # 登录限流:每分钟最多10次,暴力破解防护
+ auth_bp.view_functions['login'] = limiter.limit("10 per minute")(login)
+ # 修改密码限流:每分钟最多3次
+ auth_bp.view_functions['change_password'] = limiter.limit("3 per minute")(change_password)
+
+@auth_bp.route('/register', methods=['GET', 'POST'])
+def register():
+ """用户注册路由"""
+ # 如果已认证,重定向到首页
+ if current_user.is_authenticated:
+ return redirect('/tool/speakr')
+
+ # 自动登录并重定向
+ auto_login()
+ return redirect('/tool/speakr')
+
+@auth_bp.route('/login', methods=['GET', 'POST'])
+def login():
+ """用户登录路由"""
+ # 如果已认证,重定向到首页
+ if current_user.is_authenticated:
+ return redirect('/tool/speakr')
+
+ # 自动登录并重定向
+ auto_login()
+ return redirect('/tool/speakr')
+
+@auth_bp.route('/logout')
+def logout():
+ """用户登出路由"""
+ # 清除 Flask-Login 会话
+ logout_user()
+
+ # 清除 Flask 会话
+ session.clear()
+
+ # 创建响应
+ response = make_response('''
+
+
+
+ 退出成功
+
+
+
+
+
+
+
✓ 退出成功
+
您已成功退出系统
+
感谢使用我们的服务!
+
+
+
+ ''')
+
+ # 设置响应头,防止缓存
+ response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = '0'
+
+ return response
+
+@auth_bp.route('/account', methods=['GET', 'POST'])
+@login_required
+def account():
+ """账户管理路由"""
+ if request.method == 'POST':
+ # 只更新表单中存在的字段
+ # 这可以防止在切换标签页时清除数据
+
+ # 检查是否是账户信息表单(包含 user_name 字段)
+ if 'user_name' in request.form:
+ # 处理个人信息更新
+ user_name = request.form.get('user_name')
+ user_job_title = request.form.get('user_job_title')
+ user_company = request.form.get('user_company')
+ ui_lang = request.form.get('ui_language')
+ transcription_lang = request.form.get('transcription_language')
+ output_lang = request.form.get('output_language')
+
+ current_user.name = user_name if user_name else None
+ current_user.job_title = user_job_title if user_job_title else None
+ current_user.company = user_company if user_company else None
+ current_user.ui_language = ui_lang if ui_lang else 'en'
+ current_user.transcription_language = transcription_lang if transcription_lang else None
+ current_user.output_language = output_lang if output_lang else None
+
+ # 检查是否是自定义提示词表单(包含 summary_prompt 字段)
+ elif 'summary_prompt' in request.form:
+ # 处理自定义提示词更新
+ summary_prompt_text = request.form.get('summary_prompt')
+ current_user.summary_prompt = summary_prompt_text if summary_prompt_text else None
+ # 处理事件提取设置
+ current_user.extract_events = 'extract_events' in request.form
+
+ # 只有在环境变量中没有设置 ASR_DIARIZE 时才更新
+ if 'ASR_DIARIZE' not in os.environ:
+ current_user.diarize = 'diarize' in request.form
+
+ db.session.commit()
+
+ # 为 AJAX 请求返回 JSON 响应
+ if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.accept_mimetypes.best == 'application/json':
+ return jsonify({'success': True, 'message': '账户信息更新成功!'})
+
+ # 常规表单提交并重定向
+ flash('账户信息更新成功!', 'success')
+
+ # 重定向时保留活动标签
+ if 'summary_prompt' in request.form:
+ return redirect('/tool/speakr/account' + '#prompts')
+ else:
+ return redirect('/tool/speakr/account')
+
+ # 从系统设置中获取管理员默认提示词
+ admin_default_prompt = SystemSetting.get_setting('admin_default_summary_prompt', None)
+ if admin_default_prompt:
+ default_summary_prompt_text = admin_default_prompt
+ else:
+ # 如果管理员没有设置,则使用硬编码的默认值
+ default_summary_prompt_text = """生成一份全面的摘要,包括以下部分:
+- **讨论的关键问题**:主要主题的要点列表
+- **做出的关键决定**:达成的任何决定的要点列表
+- **行动项**:分配的任务的要点列表,包括提到的负责人"""
+
+ asr_diarize_locked = 'ASR_DIARIZE' in os.environ
+
+ return render_template('account.html',
+ title='账户',
+ default_summary_prompt_text=default_summary_prompt_text,
+ use_asr_endpoint=USE_ASR_ENDPOINT,
+ asr_diarize_locked=asr_diarize_locked,
+ asr_diarize_env_value=ASR_DIARIZE)
+
+@auth_bp.route('/change_password', methods=['POST'])
+@login_required
+def change_password():
+ """修改密码路由"""
+ current_password = request.form.get('current_password')
+ new_password = request.form.get('new_password')
+ confirm_password = request.form.get('confirm_password')
+
+ # 验证表单数据
+ if not current_password or not new_password or not confirm_password:
+ flash('所有字段都是必填的。', 'danger')
+ return redirect('/tool/speakr/account')
+
+ if new_password != confirm_password:
+ flash('新密码和确认密码不匹配。', 'danger')
+ return redirect('/tool/speakr/account')
+
+ # 自定义验证新密码
+ try:
+ password_check(None, type('obj', (object,), {'data': new_password}))
+ except ValidationError as e:
+ flash(str(e), 'danger')
+ return redirect('/tool/speakr/account')
+
+ # 检查当前密码是否正确
+ if not bcrypt.check_password_hash(current_user.password, current_password):
+ flash('当前密码不正确。', 'danger')
+ return redirect('/tool/speakr/account')
+
+ # 更新密码
+ current_user.password = bcrypt.generate_password_hash(new_password).decode('utf-8')
+ db.session.commit()
+
+ flash('您的密码已成功更新。', 'success')
+ return redirect('/tool/speakr/account')
diff --git a/speakr/src/api/config.py b/speakr/src/api/config.py
new file mode 100644
index 0000000..fd1b5c1
--- /dev/null
+++ b/speakr/src/api/config.py
@@ -0,0 +1,198 @@
+# 系统配置蓝图
+# 处理系统配置、CSRF令牌等相关功能
+
+from flask import Blueprint, request, jsonify, current_app
+from flask_login import login_required, current_user
+from datetime import datetime
+
+from src.clients import db
+from src.models import SystemConfig, SystemSetting
+
+# 创建配置蓝图
+config_bp = Blueprint('config', __name__)
+
+# 全局变量
+limiter = None
+csrf = None
+bcrypt = None
+USE_ASR_ENDPOINT = False
+ENABLE_CHUNKING = False
+chunking_service = None
+
+def init_config_bp(limiter_instance, csrf_instance, bcrypt_instance, use_asr_endpoint, enable_chunking, chunking_svc):
+ """初始化蓝图所需的全局变量"""
+ global limiter, csrf, bcrypt, USE_ASR_ENDPOINT, ENABLE_CHUNKING, chunking_service
+ limiter = limiter_instance
+ csrf = csrf_instance
+ bcrypt = bcrypt_instance
+ USE_ASR_ENDPOINT = use_asr_endpoint
+ ENABLE_CHUNKING = enable_chunking
+ chunking_service = chunking_svc
+
+
+@config_bp.route('/api/config', methods=['GET'])
+def get_config():
+ """获取应用程序配置设置,供前端使用"""
+ try:
+ # 获取可配置的文件大小限制
+ max_file_size_mb = SystemSetting.get_setting('max_file_size_mb', 250)
+
+ # 获取分块配置信息(支持新旧两种格式)
+ chunking_info = {}
+ if ENABLE_CHUNKING and chunking_service:
+ mode, limit_value = chunking_service.parse_chunk_limit()
+ chunking_info = {
+ 'chunking_enabled': True,
+ 'chunking_mode': mode, # 'size' 或 'duration'
+ 'chunking_limit': limit_value, # 单位为 MB 或 秒
+ 'chunking_limit_display': f"{limit_value}{'MB' if mode == 'size' else 's'}"
+ }
+ else:
+ chunking_info = {
+ 'chunking_enabled': False,
+ 'chunking_mode': 'size',
+ 'chunking_limit': 20,
+ 'chunking_limit_display': '20MB'
+ }
+
+ return jsonify({
+ 'max_file_size_mb': max_file_size_mb,
+ 'recording_disclaimer': SystemSetting.get_setting('recording_disclaimer', ''),
+ 'use_asr_endpoint': USE_ASR_ENDPOINT,
+ **chunking_info
+ })
+ except Exception as e:
+ current_app.logger.error(f"获取配置时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@config_bp.route('/api/csrf-token', methods=['GET'])
+def get_csrf_token():
+ """为前端获取新鲜的 CSRF 令牌"""
+ try:
+ from flask_wtf.csrf import generate_csrf
+ token = generate_csrf()
+ current_app.logger.info("新鲜 CSRF 令牌生成成功")
+ return jsonify({'csrf_token': token})
+ except Exception as e:
+ current_app.logger.error(f"生成 CSRF 令牌时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@config_bp.route('/api/system-configs', methods=['GET'])
+def get_system_configs():
+ """获取所有系统配置(所有用户都可以访问)"""
+ try:
+ configs = SystemConfig.query.order_by(SystemConfig.config_key).all()
+ return jsonify({
+ 'success': True,
+ 'configs': [config.to_dict() for config in configs]
+ })
+ except Exception as e:
+ current_app.logger.error(f"获取系统配置时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@config_bp.route('/api/system-configs/', methods=['GET'])
+def get_system_config(config_key):
+ """获取特定系统配置(所有用户都可以访问)"""
+ try:
+ config = SystemConfig.query.filter_by(config_key=config_key).first()
+ if not config:
+ return jsonify({
+ 'success': True,
+ 'config': None,
+ 'message': '配置不存在'
+ })
+ # return jsonify({'error': '配置不存在'}), 404
+
+ return jsonify({
+ 'success': True,
+ 'config': config.to_dict()
+ })
+ except Exception as e:
+ current_app.logger.error(f"获取系统配置时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@config_bp.route('/api/system-configs/', methods=['PUT'])
+@login_required
+def update_system_config(config_key):
+ """更新系统配置(只有管理员可以更新)"""
+ try:
+ # 检查用户权限
+ if not current_user.is_admin:
+ return jsonify({'error': '只有管理员可以更新系统配置'}), 403
+
+ data = request.json
+ if not data:
+ return jsonify({'error': '没有提供数据'}), 400
+
+ config_value = data.get('config_value')
+ description = data.get('description')
+
+ if config_value is None:
+ return jsonify({'error': 'config_value 是必需的'}), 400
+
+ # 使用更新而不是删除再插入
+ existing_config = SystemConfig.query.filter_by(config_key=config_key).first()
+
+ if existing_config:
+ # 更新现有配置
+ existing_config.config_value = config_value
+ if description is not None:
+ existing_config.description = description
+ existing_config.updated_at = datetime.utcnow()
+ else:
+ # 创建新配置
+ existing_config = SystemConfig(
+ config_key=config_key,
+ config_value=config_value,
+ description=description
+ )
+ db.session.add(existing_config)
+
+ db.session.commit()
+
+ current_app.logger.info(f"管理员 {current_user.username} 更新了系统配置: {config_key}")
+
+ return jsonify({
+ 'success': True,
+ 'message': '配置更新成功',
+ 'config': existing_config.to_dict()
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"更新系统配置时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@config_bp.route('/api/system-configs/', methods=['DELETE'])
+@login_required
+def delete_system_config(config_key):
+ """删除系统配置(只有管理员可以删除)"""
+ try:
+ # 检查用户权限
+ if not current_user.is_admin:
+ return jsonify({'error': '只有管理员可以删除系统配置'}), 403
+
+ config = SystemConfig.query.filter_by(config_key=config_key).first()
+ if not config:
+ return jsonify({'error': '配置不存在'}), 404
+
+ db.session.delete(config)
+ db.session.commit()
+
+ current_app.logger.info(f"管理员 {current_user.username} 删除了系统配置: {config_key}")
+
+ return jsonify({
+ 'success': True,
+ 'message': '配置删除成功'
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"删除系统配置时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
diff --git a/speakr/src/api/draft_api.py b/speakr/src/api/draft_api.py
new file mode 100644
index 0000000..4a8b337
--- /dev/null
+++ b/speakr/src/api/draft_api.py
@@ -0,0 +1,375 @@
+import os
+import time
+import shutil
+import tempfile
+import subprocess
+from flask import Blueprint, request, jsonify, current_app
+from flask_login import login_required, current_user
+
+# 创建蓝图
+draft_bp = Blueprint('draft', __name__)
+
+# 辅助函数:获取模型和数据库实例,避免循环导入
+def get_models():
+ from src.clients import db
+ from src.models import DraftRecording, DraftSegment, Recording
+ return db, DraftRecording, DraftSegment, Recording
+
+@draft_bp.route('/create', methods=['POST'])
+@login_required
+def create_draft():
+ """创建新的草稿录音"""
+ db, DraftRecording, _, _ = get_models()
+ try:
+ data = request.json or {}
+ draft = DraftRecording(
+ user_id=current_user.id,
+ name=data.get('name', '未命名录音'),
+ recording_mode=data.get('recording_mode', 'microphone'),
+ notes=data.get('notes', '')
+ )
+ db.session.add(draft)
+ db.session.commit()
+
+ current_app.logger.info(f"已创建草稿录音 {draft.id},用户 ID:{current_user.id}")
+ return jsonify({'success': True, 'draft': draft.to_dict()}), 201
+ except Exception as e:
+ current_app.logger.error(f"创建草稿失败:{e}")
+ db.session.rollback()
+ return jsonify({'error': str(e)}), 500
+
+@draft_bp.route('//segment', methods=['POST'])
+@login_required
+def upload_draft_segment(draft_id):
+ """上传音频片段到草稿"""
+ db, DraftRecording, DraftSegment, _ = get_models()
+ try:
+ draft = DraftRecording.query.filter_by(id=draft_id, user_id=current_user.id).first()
+ if not draft:
+ return jsonify({'error': 'Draft not found'}), 404
+
+ if 'audio' not in request.files:
+ return jsonify({'error': 'No audio file provided'}), 400
+
+ audio_file = request.files['audio']
+ duration = float(request.form.get('duration', 0))
+ transcription = request.form.get('transcription', '')
+
+ # 确定片段顺序
+ existing_segments = DraftSegment.query.filter_by(draft_id=draft_id).count()
+ next_order = existing_segments + 1
+
+ # 保存音频文件
+ draft_folder = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'data/recordings'), 'drafts', str(current_user.id))
+ os.makedirs(draft_folder, exist_ok=True)
+
+ filename = f"draft_{draft_id}_segment_{next_order}_{int(time.time())}.webm"
+ file_path = os.path.join(draft_folder, filename)
+ audio_file.save(file_path)
+
+ # 创建片段记录
+ segment = DraftSegment(
+ draft_id=draft_id,
+ order=next_order,
+ file_path=file_path,
+ duration=duration,
+ transcription=transcription
+ )
+ db.session.add(segment)
+
+ # 更新草稿总时长和完整转录文本
+ draft.total_duration += duration
+ # 如果前端带入的是完整文本,直接保存
+ if transcription:
+ draft.combined_transcription = transcription
+
+ db.session.commit()
+
+ current_app.logger.info(f"已上传片段 {segment.id} 到草稿 {draft_id}")
+ return jsonify({'success': True, 'segment': segment.to_dict(), 'draft': draft.to_dict()}), 201
+ except Exception as e:
+ current_app.logger.error(f"上传草稿片段失败:{e}")
+ db.session.rollback()
+ return jsonify({'error': str(e)}), 500
+
+@draft_bp.route('//finalize', methods=['POST'])
+@login_required
+def finalize_draft(draft_id):
+ db, DraftRecording, DraftSegment, _ = get_models()
+ try:
+ draft = DraftRecording.query.filter_by(id=draft_id, user_id=current_user.id).first()
+ if not draft:
+ return jsonify({'error': 'Draft not found'}), 404
+
+ segments = DraftSegment.query.filter_by(draft_id=draft_id).order_by(DraftSegment.order).all()
+ if not segments:
+ return jsonify({'error': 'No segments to merge'}), 400
+
+ data = request.get_json(silent=True) or request.form or {}
+ notes = data.get('notes') or draft.notes
+ language = data.get('language', '')
+
+ def _optional_int(value):
+ try:
+ return int(value) if value not in (None, '') else None
+ except (TypeError, ValueError):
+ return None
+
+ min_speakers = _optional_int(data.get('min_speakers'))
+ max_speakers = _optional_int(data.get('max_speakers'))
+
+ tag_ids = data.get('tag_ids') or []
+ if isinstance(tag_ids, str):
+ tag_ids = [item for item in tag_ids.split(',') if item]
+ selected_tags = []
+ if tag_ids:
+ from src.models import Tag
+ selected_tags = Tag.query.filter(
+ Tag.user_id == current_user.id,
+ Tag.id.in_(tag_ids)
+ ).all()
+
+ upload_folder = current_app.config.get('UPLOAD_FOLDER', 'data/recordings')
+ os.makedirs(upload_folder, exist_ok=True)
+ output_filename = f"draft_{draft_id}_{int(time.time())}.webm"
+ output_path = os.path.join(upload_folder, output_filename)
+
+ if len(segments) == 1:
+ shutil.copy(segments[0].file_path, output_path)
+ else:
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as list_file:
+ for seg in segments:
+ abs_path = os.path.abspath(seg.file_path).replace("'", "'\\''")
+ list_file.write(f"file '{abs_path}'\n")
+ list_file_path = list_file.name
+
+ try:
+ cmd = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', list_file_path, '-c', 'copy', output_path]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
+ if result.returncode != 0:
+ current_app.logger.error(f"草稿 FFmpeg 合并失败:{result.stderr}")
+ return jsonify({'error': f'FFmpeg 合并失败:{result.stderr}'}), 500
+ finally:
+ if os.path.exists(list_file_path):
+ os.remove(list_file_path)
+
+ from src.api.recording import create_recording_from_existing_file
+ recording = create_recording_from_existing_file(
+ filepath=output_path,
+ original_filename=output_filename,
+ notes=notes,
+ selected_tags=selected_tags,
+ language=language,
+ min_speakers=min_speakers,
+ max_speakers=max_speakers,
+ mime_type='audio/webm',
+ processing_source='draft'
+ )
+
+ for seg in segments:
+ if os.path.exists(seg.file_path):
+ try:
+ os.remove(seg.file_path)
+ except Exception as e:
+ current_app.logger.warning(f"删除草稿片段文件失败:{seg.file_path},错误:{e}")
+
+ db.session.delete(draft)
+ db.session.commit()
+
+ current_app.logger.info(f"草稿 {draft_id} 已定稿为录音 {recording.id}")
+ return jsonify({'success': True, 'recording': recording.to_dict()}), 202
+ except Exception as e:
+ current_app.logger.error(f"草稿定稿失败:{e}")
+ db.session.rollback()
+ return jsonify({'error': str(e)}), 500
+
+@draft_bp.route('/list', methods=['GET'])
+@login_required
+def list_drafts():
+ """获取当前用户的所有草稿"""
+ db, DraftRecording, _, _ = get_models()
+ try:
+ drafts = DraftRecording.query.filter_by(user_id=current_user.id).order_by(DraftRecording.updated_at.desc()).all()
+ return jsonify([draft.to_dict() for draft in drafts])
+ except Exception as e:
+ current_app.logger.error(f"获取草稿列表失败:{e}")
+ return jsonify({'error': str(e)}), 500
+
+@draft_bp.route('/', methods=['GET'])
+@login_required
+def get_draft(draft_id):
+ """获取单个草稿详情"""
+ db, DraftRecording, DraftSegment, _ = get_models()
+ try:
+ draft = DraftRecording.query.filter_by(id=draft_id, user_id=current_user.id).first()
+ if not draft:
+ return jsonify({'error': 'Draft not found'}), 404
+
+ # 获取片段列表
+ segments = DraftSegment.query.filter_by(draft_id=draft_id).order_by(DraftSegment.order).all()
+
+ result = draft.to_dict()
+ result['segments'] = [seg.to_dict() for seg in segments]
+
+ # 合并返回所有片段的转录文本
+ if draft.combined_transcription:
+ result['combined_transcription'] = draft.combined_transcription
+ else:
+ result['combined_transcription'] = '\n'.join([seg.transcription or '' for seg in segments if seg.transcription])
+
+ return jsonify(result)
+ except Exception as e:
+ current_app.logger.error(f"获取草稿详情失败:{e}")
+ return jsonify({'error': str(e)}), 500
+
+@draft_bp.route('/', methods=['DELETE'])
+@login_required
+def delete_draft(draft_id):
+ """删除草稿及其所有片段"""
+ db, DraftRecording, DraftSegment, _ = get_models()
+ try:
+ draft = DraftRecording.query.filter_by(id=draft_id, user_id=current_user.id).first()
+ if not draft:
+ return jsonify({'error': 'Draft not found'}), 404
+
+ # 删除片段文件
+ segments = DraftSegment.query.filter_by(draft_id=draft_id).all()
+ for seg in segments:
+ if os.path.exists(seg.file_path):
+ try:
+ os.remove(seg.file_path)
+ except Exception as e:
+ current_app.logger.warning(f"删除草稿片段文件失败:{seg.file_path},错误:{e}")
+
+ # 删除草稿记录(片段会级联删除)
+ db.session.delete(draft)
+ db.session.commit()
+
+ current_app.logger.info(f"已删除草稿 {draft_id}")
+ return jsonify({'success': True})
+ except Exception as e:
+ current_app.logger.error(f"删除草稿失败:{e}")
+ db.session.rollback()
+ return jsonify({'error': str(e)}), 500
+
+@draft_bp.route('//rename', methods=['PUT'])
+@login_required
+def rename_draft(draft_id):
+ """重命名草稿"""
+ db, DraftRecording, _, _ = get_models()
+ try:
+ draft = DraftRecording.query.filter_by(id=draft_id, user_id=current_user.id).first()
+ if not draft:
+ return jsonify({'error': 'Draft not found'}), 404
+
+ data = request.json or {}
+ new_name = data.get('name')
+ if not new_name:
+ return jsonify({'error': 'Name is required'}), 400
+
+ draft.name = new_name
+ db.session.commit()
+
+ return jsonify({'success': True, 'draft': draft.to_dict()})
+ except Exception as e:
+ current_app.logger.error(f"重命名草稿失败:{e}")
+ db.session.rollback()
+ return jsonify({'error': str(e)}), 500
+
+@draft_bp.route('//notes', methods=['PUT'])
+@login_required
+def update_draft_notes(draft_id):
+ """更新草稿笔记"""
+ db, DraftRecording, _, _ = get_models()
+ try:
+ draft = DraftRecording.query.filter_by(id=draft_id, user_id=current_user.id).first()
+ if not draft:
+ return jsonify({'error': 'Draft not found'}), 404
+
+ data = request.json or {}
+ draft.notes = data.get('notes', '')
+ db.session.commit()
+
+ return jsonify({'success': True, 'draft': draft.to_dict()})
+ except Exception as e:
+ current_app.logger.error(f"更新草稿笔记失败:{e}")
+ db.session.rollback()
+ return jsonify({'error': str(e)}), 500
+
+
+@draft_bp.route('//download', methods=['GET'])
+@login_required
+def download_draft_audio(draft_id):
+ """下载草稿的合并音频(用于预览)"""
+ db, DraftRecording, DraftSegment, _ = get_models()
+ try:
+ draft = DraftRecording.query.filter_by(id=draft_id, user_id=current_user.id).first()
+ if not draft:
+ return jsonify({'error': 'Draft not found'}), 404
+
+ segments = DraftSegment.query.filter_by(draft_id=draft_id).order_by(DraftSegment.order).all()
+ if not segments:
+ return jsonify({'error': 'No segments to download'}), 400
+
+ # 如果只有一个片段,直接返回
+ if len(segments) == 1:
+ from flask import send_file
+ return send_file(
+ segments[0].file_path,
+ as_attachment=False,
+ download_name=f"录音草稿_{draft_id}.webm",
+ mimetype='audio/webm'
+ )
+
+ # 多个片段需要合并
+ upload_folder = current_app.config.get('UPLOAD_FOLDER', 'data/recordings')
+ temp_output = os.path.join(upload_folder, 'drafts', str(current_user.id), f"temp_merged_{draft_id}_{int(time.time())}.webm")
+ os.makedirs(os.path.dirname(temp_output), exist_ok=True)
+
+ # 创建 FFmpeg 输入列表文件
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as list_file:
+ for seg in segments:
+ abs_path = os.path.abspath(seg.file_path).replace("'", "'\\''")
+ list_file.write(f"file '{abs_path}'\n")
+ list_file_path = list_file.name
+
+ try:
+ # 使用 concat demuxer 无损合并
+ cmd = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', list_file_path, '-c', 'copy', temp_output]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
+
+ if result.returncode != 0:
+ current_app.logger.error(f"FFmpeg 合并失败:{result.stderr}")
+ return jsonify({'error': f'FFmpeg merge failed: {result.stderr}'}), 500
+
+ from flask import send_file
+
+ # 发送文件后删除临时文件
+ response = send_file(
+ temp_output,
+ as_attachment=False,
+ download_name=f"录音草稿_{draft_id}.webm",
+ mimetype='audio/webm'
+ )
+
+ # 由于 send_file 会在发送后关闭文件,我们使用 after_this_request 来清理
+ from flask import after_this_request
+ @after_this_request
+ def cleanup(response):
+ try:
+ if os.path.exists(temp_output):
+ os.remove(temp_output)
+ except Exception as e:
+ current_app.logger.warning(f"清理临时合并文件失败:{temp_output},错误:{e}")
+ return response
+
+ return response
+
+ finally:
+ # 清理列表文件
+ if os.path.exists(list_file_path):
+ os.remove(list_file_path)
+
+ except Exception as e:
+ current_app.logger.error(f"下载草稿音频失败:{e}")
+ return jsonify({'error': str(e)}), 500
diff --git a/speakr/src/api/event.py b/speakr/src/api/event.py
new file mode 100644
index 0000000..57bdf5d
--- /dev/null
+++ b/speakr/src/api/event.py
@@ -0,0 +1,84 @@
+# 事件蓝图
+# 处理录音事件的查看和日历导出
+
+from flask import Blueprint, jsonify, send_file
+from flask_login import login_required, current_user
+from src.clients import db
+from src.models import Event, Recording
+from src.services import generate_ics_content
+import io
+
+# 创建蓝图
+event_bp = Blueprint('event', __name__)
+
+@event_bp.route('/api/recording//events', methods=['GET'])
+@login_required
+def get_recording_events(recording_id):
+ """获取录音的所有事件"""
+ recording = Recording.query.filter_by(id=recording_id, user_id=current_user.id).first()
+ if not recording:
+ return jsonify({'error': '录音不存在'}), 404
+
+ events = Event.query.filter_by(recording_id=recording_id).all()
+ return jsonify([{
+ 'id': e.id,
+ 'title': e.title,
+ 'start_time': e.start_time.isoformat() if e.start_time else None,
+ 'end_time': e.end_time.isoformat() if e.end_time else None,
+ 'location': e.location,
+ 'description': e.description,
+ 'recording_id': e.recording_id
+ } for e in events])
+
+@event_bp.route('/api/event//ics', methods=['GET'])
+@login_required
+def download_event_ics(event_id):
+ """下载单个事件的ICS日历文件"""
+ event = Event.query.get(event_id)
+ if not event:
+ return jsonify({'error': '事件不存在'}), 404
+
+ # 检查权限
+ recording = Recording.query.filter_by(id=event.recording_id, user_id=current_user.id).first()
+ if not recording:
+ return jsonify({'error': '无权访问'}), 403
+
+ ics_content = generate_ics_content([event])
+
+ buffer = io.BytesIO()
+ buffer.write(ics_content.encode('utf-8'))
+ buffer.seek(0)
+
+ filename = f"{event.title.replace(' ', '_')}.ics"
+ return send_file(
+ buffer,
+ mimetype='text/calendar',
+ as_attachment=True,
+ download_name=filename
+ )
+
+@event_bp.route('/api/recording//events/ics', methods=['GET'])
+@login_required
+def download_all_events_ics(recording_id):
+ """下载录音所有事件的ICS日历文件"""
+ recording = Recording.query.filter_by(id=recording_id, user_id=current_user.id).first()
+ if not recording:
+ return jsonify({'error': '录音不存在'}), 404
+
+ events = Event.query.filter_by(recording_id=recording_id).all()
+ if not events:
+ return jsonify({'error': '没有事件'}), 404
+
+ ics_content = generate_ics_content(events)
+
+ buffer = io.BytesIO()
+ buffer.write(ics_content.encode('utf-8'))
+ buffer.seek(0)
+
+ filename = f"{recording.title.replace(' ', '_')}_events.ics"
+ return send_file(
+ buffer,
+ mimetype='text/calendar',
+ as_attachment=True,
+ download_name=filename
+ )
diff --git a/speakr/src/api/hotword.py b/speakr/src/api/hotword.py
new file mode 100644
index 0000000..b7c32c9
--- /dev/null
+++ b/speakr/src/api/hotword.py
@@ -0,0 +1,125 @@
+from flask import Blueprint, request, jsonify
+from flask_login import login_required, current_user
+
+from src.clients import db
+from src.models import HotWord, YlHotWord
+
+
+hotword_bp = Blueprint('hotword', __name__)
+
+csrf = None
+
+
+SCENE_YL = 'yl'
+SCENE_CG = 'cg'
+
+
+def init_hotword_bp(csrf_instance):
+ """Initialize blueprint dependencies."""
+ global csrf
+ csrf = csrf_instance
+
+
+def _normalize_scene(scene):
+ return SCENE_YL if str(scene or '').strip().lower() == SCENE_YL else SCENE_CG
+
+
+def _hotword_model_for_scene(scene):
+ return YlHotWord if _normalize_scene(scene) == SCENE_YL else HotWord
+
+
+def _parse_hotwords_text(hotwords_text):
+ """Parse textarea content into hotword rows."""
+ new_hotwords = []
+
+ for raw_line in (hotwords_text or '').strip().split('\n'):
+ line = raw_line.strip()
+ if not line:
+ continue
+
+ parts = line.split()
+ if len(parts) >= 2:
+ keyword = parts[0].strip()
+ try:
+ weight = int(parts[1])
+ except (TypeError, ValueError):
+ weight = 1
+ else:
+ keyword = line
+ weight = 1
+
+ if keyword:
+ new_hotwords.append({'keyword': keyword, 'weight': weight})
+
+ return new_hotwords
+
+
+def _format_hotwords_text(hotwords):
+ return '\n'.join([f'{hw.keyword} {hw.weight}' for hw in hotwords])
+
+
+@hotword_bp.route('/api/hotwords', methods=['GET'])
+@login_required
+def get_hotwords():
+ """Get scene hotwords."""
+ try:
+ scene = _normalize_scene(request.args.get('scene'))
+ hotword_model = _hotword_model_for_scene(scene)
+ hotwords = hotword_model.query.order_by(hotword_model.weight.desc(), hotword_model.keyword).all()
+ hotwords_text = _format_hotwords_text(hotwords)
+ return jsonify({
+ 'success': True,
+ 'scene': scene,
+ 'hotwords': [hw.to_dict() for hw in hotwords],
+ 'text_format': hotwords_text,
+ })
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@hotword_bp.route('/api/hotwords', methods=['POST'])
+@login_required
+def update_hotwords():
+ """Replace existing hotwords for the selected scene."""
+ try:
+ if not current_user.is_admin:
+ return jsonify({'error': '只有管理员可以修改热词配置'}), 403
+
+ data = request.json
+ if not data:
+ return jsonify({'error': '没有提供数据'}), 400
+
+ scene = _normalize_scene(data.get('scene') or request.args.get('scene'))
+ hotword_model = _hotword_model_for_scene(scene)
+ hotwords_text = data.get('hotwords', '')
+ if not hotwords_text.strip():
+ return jsonify({'error': '热词内容不能为空'}), 400
+
+ new_hotwords = _parse_hotwords_text(hotwords_text)
+ if not new_hotwords:
+ return jsonify({'error': '没有有效的热词数据'}), 400
+
+ try:
+ hotword_model.query.delete()
+
+ for hw_data in new_hotwords:
+ hotword = hotword_model(
+ keyword=hw_data['keyword'],
+ weight=hw_data['weight']
+ )
+ db.session.add(hotword)
+
+ db.session.commit()
+
+ return jsonify({
+ 'success': True,
+ 'scene': scene,
+ 'message': f'成功更新 {len(new_hotwords)} 个热词',
+ 'hotwords': [hw.to_dict() for hw in hotword_model.query.all()],
+ 'text_format': hotwords_text
+ })
+ except Exception as e:
+ db.session.rollback()
+ return jsonify({'error': f'更新热词时发生错误: {str(e)}'}), 500
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
diff --git a/speakr/src/api/main.py b/speakr/src/api/main.py
new file mode 100644
index 0000000..9c94042
--- /dev/null
+++ b/speakr/src/api/main.py
@@ -0,0 +1,434 @@
+# 主页蓝图
+# 处理主页、用户配置、文本总结、聊天等杂项路由
+
+from flask import Blueprint, render_template, request, jsonify, current_app, flash, Response, stream_with_context
+from flask_login import login_required, current_user, login_user
+from src.clients import db
+from src.models import User, SystemSetting
+from src.services import call_llm_completion, clean_llm_response, format_transcription_for_llm, auto_login
+from src.config import SCREEN_PUSH_IP
+import os
+import requests
+
+# 创建蓝图
+main_bp = Blueprint('main', __name__)
+
+# 全局变量
+limiter = None
+csrf = None
+bcrypt = None
+USE_ASR_ENDPOINT = None
+ASR_DIARIZE = None
+ENABLE_INQUIRE_MODE = None
+VALID_TOKEN = None
+
+
+def _chinese_to_pinyin(chinese_text):
+ """将汉字转换为拼音"""
+ if not chinese_text:
+ return ""
+ try:
+ import pypinyin
+ pinyin_list = pypinyin.lazy_pinyin(chinese_text)
+ return "".join(pinyin_list)
+ except ImportError:
+ # 如果 pypinyin 未安装,返回原文本
+ return chinese_text
+
+
+def _screen_push_url(path):
+ base = (SCREEN_PUSH_IP or '').strip().rstrip('/')
+ if not base:
+ raise RuntimeError('SCREEN_PUSH_IP is not configured')
+ if not base.startswith(('http://', 'https://')):
+ base = f'https://{base}'
+ return f"{base}/external/{path.lstrip('/')}"
+
+
+def _proxy_screen_push(path, stream=False):
+ try:
+ upstream = _screen_push_url(path)
+ except RuntimeError as exc:
+ return jsonify({'error': str(exc)}), 503
+
+ try:
+ if stream:
+ upstream_response = requests.post(
+ upstream,
+ json=request.get_json(silent=True) or {},
+ timeout=(10, 300),
+ stream=True,
+ )
+
+ def generate():
+ with upstream_response:
+ for chunk in upstream_response.iter_content(chunk_size=None):
+ if chunk:
+ yield chunk
+
+ return Response(
+ stream_with_context(generate()),
+ status=upstream_response.status_code,
+ content_type=upstream_response.headers.get('content-type', 'text/plain; charset=utf-8'),
+ )
+
+ upstream_response = requests.post(
+ upstream,
+ json=request.get_json(silent=True) or {},
+ timeout=(10, 120),
+ )
+ return Response(
+ upstream_response.content,
+ status=upstream_response.status_code,
+ content_type=upstream_response.headers.get('content-type', 'application/json'),
+ )
+ except requests.RequestException as exc:
+ current_app.logger.error('投屏推送代理失败 [%s]:%s', path, exc)
+ return jsonify({'error': '外部服务不可用'}), 502
+
+def init_main_bp(limiter_instance, csrf_instance, bcrypt_instance, use_asr_endpoint, asr_diarize, enable_inquire_mode=None, valid_token=None):
+ """初始化蓝图所需的全局变量"""
+ global limiter, csrf, bcrypt, USE_ASR_ENDPOINT, ASR_DIARIZE, ENABLE_INQUIRE_MODE, VALID_TOKEN
+ limiter = limiter_instance
+ csrf = csrf_instance
+ bcrypt = bcrypt_instance
+ USE_ASR_ENDPOINT = use_asr_endpoint
+ ASR_DIARIZE = asr_diarize
+ ENABLE_INQUIRE_MODE = enable_inquire_mode or False
+ VALID_TOKEN = valid_token or ''
+
+ # 应用路由级限流(在 init 中应用,避免模块导入时 limiter 为 None)
+ # 摘要生成限流:每分钟最多10次,防止LLM资源耗尽
+ main_bp.view_functions['summarize_text'] = limiter.limit("10 per minute")(summarize_text)
+ # 对话限流:每分钟最多10次
+ main_bp.view_functions['chat_with_transcription'] = limiter.limit("10 per minute")(chat_with_transcription)
+
+@main_bp.route('/')
+def index():
+ """主页 - 支持自动登录和 token 验证"""
+ # 获取 URL 参数
+ uid = request.args.get('uid')
+ uname = request.args.get('uname')
+ role = request.args.get('role')
+ token = request.args.get('token')
+
+ # 定义强制登录条件:参数都不为空,且 Token 正确
+ should_force_login = uid and uname and role and token and token == VALID_TOKEN
+
+ # 如果用户已经登录,直接渲染首页,不进行自动登录
+ if current_user.is_authenticated and not should_force_login:
+ user_language = current_user.ui_language if current_user.ui_language else 'en'
+ return render_template('index.html',
+ use_asr_endpoint=USE_ASR_ENDPOINT,
+ inquire_mode_enabled=ENABLE_INQUIRE_MODE,
+ user_language=user_language)
+
+ # Token 无效
+ if token and token != VALID_TOKEN:
+ flash('无效的访问令牌', 'danger')
+ return '''
+
+
+
+ 登录方式不合法
+
+
+
+
+
+
+
⚠
+
登录方式不合法
+
请从参谋助手登录
+
请通过正确的入口访问系统
+
+
+
+ '''
+
+ # 如果有 uid 参数,则进行自动登录/注册
+ if uid:
+ # 检查用户是否已存在
+ user = User.query.filter_by(id=uid).first()
+
+ if not user:
+ # 用户不存在,注册新用户
+ # 生成用户名(将汉字 uname 转换为拼音)
+ username = _chinese_to_pinyin(uname) if uname else f"user_{uid[:8]}"
+
+ # 确保用户名唯一
+ base_username = username
+ counter = 1
+ while User.query.filter_by(username=username).first():
+ username = f"{base_username}_{counter}"
+ counter += 1
+
+ # 生成密码
+ password = "11223344"
+
+ # 检查 role 是否包含环境变量中配置的执勤指挥员
+ is_admin = False
+ if role:
+ # 从环境变量读取执勤指挥员配置,支持多个角色用逗号分隔
+ duty_commanders = os.environ.get('DUTY_COMMANDER', '执勤指挥员').split(',')
+ for duty_commander in duty_commanders:
+ duty_commander = duty_commander.strip()
+ if duty_commander and duty_commander in role:
+ is_admin = True
+ break
+
+ # 创建新用户
+ user = User(
+ id=uid,
+ username=username,
+ email=f"{username}@auto.local",
+ password=bcrypt.generate_password_hash(password).decode('utf-8'),
+ is_admin=is_admin,
+ name=uname,
+ job_title=role,
+ transcription_language='zh',
+ ui_language='zh',
+ output_language='Chinese'
+ )
+
+ db.session.add(user)
+ db.session.commit()
+ current_app.logger.info(f"自动注册新用户: {username}, ID: {uid}, 管理员: {is_admin}")
+
+ # 登录用户
+ login_user(user)
+ current_app.logger.info(f"自动登录用户: {user.username}")
+
+ # 如果尚未认证,使用原来的自动登录
+ elif not current_user.is_authenticated:
+ auto_login()
+
+ # 将 ASR 配置、询问模式配置和用户语言偏好传递给模板
+ user_language = current_user.ui_language if current_user.is_authenticated and current_user.ui_language else 'en'
+ return render_template('index.html',
+ use_asr_endpoint=USE_ASR_ENDPOINT,
+ inquire_mode_enabled=ENABLE_INQUIRE_MODE,
+ user_language=user_language)
+
+@main_bp.route('/getUserConfig', methods=['GET'])
+@login_required
+def get_user_config():
+ """获取用户配置"""
+ return jsonify({
+ 'username': current_user.username,
+ 'email': current_user.email,
+ 'name': current_user.name,
+ 'job_title': current_user.job_title,
+ 'company': current_user.company,
+ 'ui_language': current_user.ui_language,
+ 'transcription_language': current_user.transcription_language,
+ 'output_language': current_user.output_language,
+ 'diarize': current_user.diarize,
+ 'extract_events': current_user.extract_events,
+ 'summary_prompt': current_user.summary_prompt,
+ 'use_asr_endpoint': USE_ASR_ENDPOINT,
+ 'asr_diarize': ASR_DIARIZE
+ })
+
+
+@main_bp.route('/api/external/summarize_text', methods=['POST'])
+@login_required
+def proxy_external_summarize_text():
+ return _proxy_screen_push('summarize_text')
+
+
+@main_bp.route('/api/external/submit', methods=['POST'])
+@login_required
+def proxy_external_submit():
+ return _proxy_screen_push('submit')
+
+
+@main_bp.route('/api/external/prompts/list', methods=['POST'])
+@login_required
+def proxy_external_prompts_list():
+ return _proxy_screen_push('prompts/list')
+
+
+@main_bp.route('/api/external/recommend_prompts', methods=['POST'])
+@login_required
+def proxy_external_recommend_prompts():
+ return _proxy_screen_push('recommend_prompts')
+
+
+@main_bp.route('/api/external/summarize_text_stream', methods=['POST'])
+@login_required
+def proxy_external_summarize_text_stream():
+ return _proxy_screen_push('summarize_text_stream', stream=True)
+
+@main_bp.route('/summarize_text', methods=['POST'])
+@login_required
+def summarize_text():
+ """总结文本"""
+ data = request.json
+ text = data.get('text', '')
+ prompt = data.get('prompt', '')
+
+ if not text:
+ return jsonify({'error': '缺少文本内容'}), 400
+
+ # 构建提示词
+ if prompt:
+ system_prompt = prompt
+ else:
+ system_prompt = "请对以下内容进行总结,提取关键信息和要点。"
+
+ try:
+ result = call_llm_completion(
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": text}
+ ],
+ stream=False
+ )
+
+ raw_response = result.choices[0].message.content if result and result.choices else ""
+ summary = clean_llm_response(raw_response) if raw_response else ""
+ return jsonify({'summary': summary})
+ except Exception as e:
+ current_app.logger.error(f"总结失败: {str(e)}")
+ return jsonify({'error': '总结失败,请稍后重试'}), 500
+
+@main_bp.route('/chat', methods=['POST'])
+@login_required
+def chat_with_transcription():
+ """与转录内容聊天"""
+ data = request.json
+ message = data.get('message', '')
+ recording_id = data.get('recording_id')
+
+ if not message:
+ return jsonify({'error': '缺少消息'}), 400
+
+ # 如果有录音ID,获取转录内容作为上下文
+ context = ""
+ if recording_id:
+ from src.models import Recording
+ recording = Recording.query.filter_by(id=recording_id, user_id=current_user.id).first()
+ if recording and recording.transcription:
+ context = recording.transcription
+
+ # 构建消息
+ messages = []
+ if context:
+ messages.append({
+ "role": "system",
+ "content": f"以下是录音转录内容,请基于此回答用户问题:\n\n{context}"
+ })
+ messages.append({"role": "user", "content": message})
+
+ try:
+ result = call_llm_completion(messages=messages, stream=False)
+ raw_response = result.choices[0].message.content if result and result.choices else ""
+ response = clean_llm_response(raw_response) if raw_response else ""
+ return jsonify({'response': response})
+ except Exception as e:
+ current_app.logger.error(f"聊天失败: {str(e)}")
+ return jsonify({'error': '聊天失败,请稍后重试'}), 500
+
+@main_bp.route('/docs/transcript-templates-guide')
+def transcript_templates_guide():
+ """转录模板指南文档"""
+ import os
+ import markdown
+
+ docs_path = os.path.join(current_app.root_path, '..', 'docs', 'transcript-templates-guide.md')
+
+ if not os.path.exists(docs_path):
+ return "文档不存在", 404
+
+ with open(docs_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # 转换 Markdown 为 HTML
+ html_content = markdown.markdown(content, extensions=['tables', 'fenced_code', 'codehilite'])
+
+ # 包装在 HTML 模板中
+ html_template = f'''
+
+
+
+
+
+ 转录模板指南 - Speakr
+
+
+
+
+
+
+
+
+ '''
+
+ return html_template
diff --git a/speakr/src/api/recording.py b/speakr/src/api/recording.py
new file mode 100644
index 0000000..288c6cd
--- /dev/null
+++ b/speakr/src/api/recording.py
@@ -0,0 +1,1512 @@
+# 录音管理蓝图
+# 处理录音的上传、查询、下载、更新、删除、重新处理等功能
+
+from flask import Blueprint, request, jsonify, send_file
+from flask_login import login_required, current_user
+from werkzeug.utils import secure_filename
+from werkzeug.exceptions import RequestEntityTooLarge
+from datetime import datetime, timedelta
+from sqlalchemy import select, func
+import os
+import json
+import re
+import subprocess
+import mimetypes
+import threading
+import shutil
+
+from src.clients import db
+from src.models import (
+ Recording, Tag, RecordingTag, Event, TranscriptChunk,
+ SystemSetting, SystemConfig
+)
+from src.services import (
+ # 转录服务
+ transcribe_audio_asr,
+ transcribe_audio_task,
+ # 摘要服务
+ generate_title_task,
+ generate_summary_only_task,
+ # 说话人服务
+ update_speaker_usage,
+ identify_unidentified_speakers_from_text,
+ # LLM 服务
+ format_transcription_for_llm,
+ call_llm_completion,
+ extract_corrected_text,
+)
+from src.utils.standard_word_mappings import apply_standard_word_mappings_with_details
+
+def _normalize_asr_scene(scene):
+ return 'yl' if str(scene or '').strip().lower() == 'yl' else 'cg'
+
+
+def _standard_word_mappings_key_for_scene(scene):
+ return 'standard_word_mappings_yl' if _normalize_asr_scene(scene) == 'yl' else 'standard_word_mappings'
+
+# 创建录音蓝图
+recording_bp = Blueprint('recording', __name__)
+
+# 全局变量(从 app.py 导入)
+limiter = None
+csrf = None
+bcrypt = None
+app_logger = None
+flask_app = None
+client = None
+USE_ASR_ENDPOINT = None
+ASR_BASE_URL = None
+ASR_DIARIZE = None
+ASR_MIN_SPEAKERS = None
+ASR_MAX_SPEAKERS = None
+ENABLE_CHUNKING = None
+chunking_service = None
+ENABLE_INQUIRE_MODE = None
+
+def init_recording_bp(
+ limiter_instance,
+ csrf_instance,
+ bcrypt_instance,
+ logger,
+ flask_app_instance,
+ openai_client,
+ use_asr_endpoint,
+ asr_base_url,
+ asr_diarize,
+ asr_min_speakers,
+ asr_max_speakers,
+ enable_chunking,
+ chunking_svc,
+ enable_inquire_mode
+):
+ """初始化蓝图所需的全局变量"""
+ global limiter, csrf, bcrypt, app_logger, flask_app, client
+ global USE_ASR_ENDPOINT, ASR_BASE_URL, ASR_DIARIZE, ASR_MIN_SPEAKERS, ASR_MAX_SPEAKERS
+ global ENABLE_CHUNKING, chunking_service, ENABLE_INQUIRE_MODE
+
+ limiter = limiter_instance
+ csrf = csrf_instance
+ bcrypt = bcrypt_instance
+ app_logger = logger
+ flask_app = flask_app_instance
+ client = openai_client
+ USE_ASR_ENDPOINT = use_asr_endpoint
+ ASR_BASE_URL = asr_base_url
+ ASR_DIARIZE = asr_diarize
+ ASR_MIN_SPEAKERS = asr_min_speakers
+ ASR_MAX_SPEAKERS = asr_max_speakers
+ ENABLE_CHUNKING = enable_chunking
+ chunking_service = chunking_svc
+ ENABLE_INQUIRE_MODE = enable_inquire_mode
+
+ # 应用路由级限流(在 init 中应用,避免模块导入时 limiter 为 None)
+ # 录音列表限流:每分钟最多30次
+ recording_bp.view_functions['get_recordings_paginated'] = limiter.limit("30 per minute")(get_recordings_paginated)
+ # 状态查询限流:每分钟最多60次(轮询接口)
+ recording_bp.view_functions['get_status'] = limiter.limit("60 per minute")(get_status)
+ # 上传限流:每分钟最多10次
+ recording_bp.view_functions['upload_file'] = limiter.limit("10 per minute")(upload_file)
+
+
+def create_recording_from_existing_file(
+ filepath,
+ original_filename,
+ notes=None,
+ selected_tags=None,
+ language='',
+ min_speakers=None,
+ max_speakers=None,
+ mime_type=None,
+ processing_source='upload'
+):
+ selected_tags = selected_tags or []
+ final_file_size = os.path.getsize(filepath)
+ if not mime_type:
+ mime_type, _ = mimetypes.guess_type(filepath)
+
+ now = datetime.utcnow()
+ recording = Recording(
+ audio_path=filepath,
+ original_filename=original_filename,
+ title=f"Recording - {original_filename}",
+ file_size=final_file_size,
+ status='PENDING',
+ meeting_date=now.date(),
+ user_id=current_user.id,
+ mime_type=mime_type,
+ notes=notes,
+ processing_source=processing_source
+ )
+ db.session.add(recording)
+ db.session.commit()
+
+ for order, tag in enumerate(selected_tags, 1):
+ db.session.add(RecordingTag(
+ recording_id=recording.id,
+ tag_id=tag.id,
+ order=order,
+ added_at=datetime.utcnow()
+ ))
+
+ if selected_tags:
+ db.session.commit()
+ tag_names = [tag.name for tag in selected_tags]
+ app_logger.info(f"Added {len(selected_tags)} tags to recording {recording.id}: {', '.join(tag_names)}")
+
+ app_logger.info(f"Created recording {recording.id} from {processing_source}")
+
+ start_time = datetime.utcnow()
+ first_tag = selected_tags[0] if selected_tags else None
+ task_kwargs = {
+ 'tag_id': first_tag.id if first_tag else None,
+ 'USE_ASR_ENDPOINT': USE_ASR_ENDPOINT,
+ 'generate_title_task': generate_title_task,
+ 'generate_summary_only_task': generate_summary_only_task,
+ 'app_logger': app_logger,
+ 'UPLOAD_FOLDER': flask_app.config.get('UPLOAD_FOLDER')
+ }
+
+ if USE_ASR_ENDPOINT:
+ task_kwargs.update({
+ 'language': language,
+ 'min_speakers': min_speakers,
+ 'max_speakers': max_speakers,
+ 'ASR_BASE_URL': ASR_BASE_URL,
+ 'ASR_DIARIZE': ASR_DIARIZE,
+ })
+
+ thread = threading.Thread(
+ target=transcribe_audio_task,
+ args=(flask_app.app_context(), recording.id, filepath, os.path.basename(filepath), start_time),
+ kwargs=task_kwargs
+ )
+ thread.start()
+ app_logger.info(f"Started background processing thread for recording {recording.id}")
+
+ return recording
+
+
+# ==================== 录音查询相关路由 ====================
+
+@recording_bp.route('/recordings', methods=['GET'])
+def get_recordings():
+ """获取所有录音(未分页)"""
+ try:
+ # 检查用户是否已登录
+ if not current_user.is_authenticated:
+ return jsonify([]) # 如果未登录返回空数组
+
+ # 按当前用户筛选录音
+ stmt = select(Recording).where(Recording.user_id == current_user.id).order_by(Recording.created_at.desc())
+ recordings = db.session.execute(stmt).scalars().all()
+ return jsonify([recording.to_dict() for recording in recordings])
+ except Exception as e:
+ app_logger.error(f"获取录音时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@recording_bp.route('/api/recordings', methods=['GET'])
+@login_required
+def get_recordings_paginated():
+ """获取录音(带分页和服务器端过滤)"""
+ try:
+ # 解析查询参数
+ page = request.args.get('page', 1, type=int)
+ per_page = min(request.args.get('per_page', 25, type=int), 100) # 每页最多 100 条
+ search_query = request.args.get('q', '').strip()
+
+ # 构建基础查询
+ stmt = select(Recording).where(Recording.user_id == current_user.id)
+
+ # 如果提供了搜索查询,应用过滤
+ if search_query:
+ # 解析搜索查询的特殊语法
+ # 提取日期过滤
+ date_filters = re.findall(r'date:(\S+)', search_query.lower())
+ date_from_filters = re.findall(r'date_from:(\S+)', search_query.lower())
+ date_to_filters = re.findall(r'date_to:(\S+)', search_query.lower())
+ tag_filters = re.findall(r'tag:(\S+)', search_query.lower())
+
+ # 移除特殊语法以获取文本搜索
+ text_query = re.sub(r'date:\S+', '', search_query, flags=re.IGNORECASE)
+ text_query = re.sub(r'date_from:\S+', '', text_query, flags=re.IGNORECASE)
+ text_query = re.sub(r'date_to:\S+', '', text_query, flags=re.IGNORECASE)
+ text_query = re.sub(r'tag:\S+', '', text_query, flags=re.IGNORECASE).strip()
+
+ # 应用日期过滤
+ for date_filter in date_filters:
+ if date_filter == 'today':
+ today = datetime.now().date()
+ stmt = stmt.where(
+ db.or_(
+ db.func.date(Recording.meeting_date) == today,
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) == today
+ )
+ )
+ )
+ elif date_filter == 'yesterday':
+ yesterday = datetime.now().date() - timedelta(days=1)
+ stmt = stmt.where(
+ db.or_(
+ db.func.date(Recording.meeting_date) == yesterday,
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) == yesterday
+ )
+ )
+ )
+ elif date_filter == 'thisweek':
+ today = datetime.now().date()
+ start_of_week = today - timedelta(days=today.weekday())
+ stmt = stmt.where(
+ db.or_(
+ Recording.meeting_date >= start_of_week,
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) >= start_of_week
+ )
+ )
+ )
+ elif date_filter == 'lastweek':
+ today = datetime.now().date()
+ end_of_last_week = today - timedelta(days=today.weekday())
+ start_of_last_week = end_of_last_week - timedelta(days=7)
+ stmt = stmt.where(
+ db.or_(
+ db.and_(
+ Recording.meeting_date >= start_of_last_week,
+ Recording.meeting_date < end_of_last_week
+ ),
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) >= start_of_last_week,
+ db.func.date(Recording.created_at) < end_of_last_week
+ )
+ )
+ )
+ elif date_filter == 'thismonth':
+ today = datetime.now().date()
+ start_of_month = today.replace(day=1)
+ stmt = stmt.where(
+ db.or_(
+ Recording.meeting_date >= start_of_month,
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) >= start_of_month
+ )
+ )
+ )
+ elif date_filter == 'lastmonth':
+ today = datetime.now().date()
+ first_day_this_month = today.replace(day=1)
+ last_day_last_month = first_day_this_month - timedelta(days=1)
+ first_day_last_month = last_day_last_month.replace(day=1)
+ stmt = stmt.where(
+ db.or_(
+ db.and_(
+ Recording.meeting_date >= first_day_last_month,
+ Recording.meeting_date <= last_day_last_month
+ ),
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) >= first_day_last_month,
+ db.func.date(Recording.created_at) <= last_day_last_month
+ )
+ )
+ )
+ elif re.match(r'^\d{4}-\d{2}-\d{2}$', date_filter):
+ # 特定日期格式 YYYY-MM-DD
+ target_date = datetime.strptime(date_filter, '%Y-%m-%d').date()
+ stmt = stmt.where(
+ db.or_(
+ db.func.date(Recording.meeting_date) == target_date,
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) == target_date
+ )
+ )
+ )
+ elif re.match(r'^\d{4}-\d{2}$', date_filter):
+ # 月份格式 YYYY-MM
+ year, month = map(int, date_filter.split('-'))
+ stmt = stmt.where(
+ db.or_(
+ db.and_(
+ db.extract('year', Recording.meeting_date) == year,
+ db.extract('month', Recording.meeting_date) == month
+ ),
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.extract('year', Recording.created_at) == year,
+ db.extract('month', Recording.created_at) == month
+ )
+ )
+ )
+ elif re.match(r'^\d{4}$', date_filter):
+ # 年份格式 YYYY
+ year = int(date_filter)
+ stmt = stmt.where(
+ db.or_(
+ db.extract('year', Recording.meeting_date) == year,
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.extract('year', Recording.created_at) == year
+ )
+ )
+ )
+
+ # 应用日期范围过滤
+ if date_from_filters and date_from_filters[0]:
+ try:
+ date_from = datetime.strptime(date_from_filters[0], '%Y-%m-%d').date()
+ stmt = stmt.where(
+ db.or_(
+ Recording.meeting_date >= date_from,
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) >= date_from
+ )
+ )
+ )
+ except ValueError:
+ pass # 无效的日期格式,忽略
+
+ if date_to_filters and date_to_filters[0]:
+ try:
+ date_to = datetime.strptime(date_to_filters[0], '%Y-%m-%d').date()
+ stmt = stmt.where(
+ db.or_(
+ Recording.meeting_date <= date_to,
+ db.and_(
+ Recording.meeting_date.is_(None),
+ db.func.date(Recording.created_at) <= date_to
+ )
+ )
+ )
+ except ValueError:
+ pass # 无效的日期格式,忽略
+
+ # 应用标签过滤
+ if tag_filters:
+ # 与标签表连接并按标签名称过滤
+ tag_conditions = []
+ for tag_filter in tag_filters:
+ # 将下划线替换回空格以进行匹配
+ tag_name = tag_filter.replace('_', ' ')
+ tag_conditions.append(Tag.name.ilike(f'%{tag_name}%'))
+
+ stmt = stmt.join(RecordingTag).join(Tag).where(db.or_(*tag_conditions))
+
+ # 应用文本搜索
+ if text_query:
+ text_conditions = [
+ Recording.title.ilike(f'%{text_query}%'),
+ Recording.participants.ilike(f'%{text_query}%'),
+ Recording.transcription.ilike(f'%{text_query}%'),
+ Recording.notes.ilike(f'%{text_query}%')
+ ]
+ stmt = stmt.where(db.or_(*text_conditions))
+
+ # 应用排序(基于 meeting_date 或 created_at 的最新优先)
+ stmt = stmt.order_by(
+ db.case(
+ (Recording.meeting_date.is_not(None), Recording.meeting_date),
+ else_=db.func.date(Recording.created_at)
+ ).desc(),
+ Recording.created_at.desc()
+ )
+
+ # 获取总数用于分页信息
+ count_stmt = select(db.func.count()).select_from(stmt.subquery())
+ total_count = db.session.execute(count_stmt).scalar()
+
+ # 应用分页
+ offset = (page - 1) * per_page
+ stmt = stmt.offset(offset).limit(per_page)
+
+ # 执行查询
+ recordings = db.session.execute(stmt).scalars().all()
+
+ # 计算分页元数据
+ total_pages = (total_count + per_page - 1) // per_page
+ has_next = page < total_pages
+ has_prev = page > 1
+
+ return jsonify({
+ 'recordings': [recording.to_dict() for recording in recordings],
+ 'pagination': {
+ 'page': page,
+ 'per_page': per_page,
+ 'total': total_count,
+ 'total_pages': total_pages,
+ 'has_next': has_next,
+ 'has_prev': has_prev
+ }
+ })
+
+ except Exception as e:
+ app_logger.error(f"获取分页录音时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+# ==================== 录音上传相关路由 ====================
+
+@recording_bp.route('/upload', methods=['POST'])
+@login_required
+def upload_file():
+ """上传音频文件"""
+ try:
+ if 'file' not in request.files:
+ return jsonify({'error': '未提供文件'}), 400
+
+ file = request.files['file']
+ if file.filename == '':
+ return jsonify({'error': '未选择文件'}), 400
+
+ original_filename = file.filename
+ safe_filename = secure_filename(original_filename)
+ filepath = os.path.join(flask_app.config['UPLOAD_FOLDER'], f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{safe_filename}")
+
+ # 获取原始文件大小
+ file.seek(0, os.SEEK_END)
+ original_file_size = file.tell()
+ file.seek(0)
+ app_logger.info(f"上传文件大小: {original_file_size} 字节 ({original_file_size / (1024 * 1024):.2f} MB)")
+
+ # 在保存前检查大小限制 - 仅在禁用分块或使用 ASR 端点时强制执行
+ max_content_length = flask_app.config.get('MAX_CONTENT_LENGTH')
+
+ # 如果启用了分块且使用 OpenAI Whisper API,跳过大小检查
+ should_enforce_size_limit = True
+ if ENABLE_CHUNKING and chunking_service and not USE_ASR_ENDPOINT:
+ should_enforce_size_limit = False
+ # 获取分块模式以便更好地记录日志
+ mode, limit_value = chunking_service.parse_chunk_limit()
+ if mode == 'size':
+ app_logger.info(f"已启用基于大小的分块({limit_value}MB 限制) - 跳过 {original_file_size/1024/1024:.1f}MB 大小限制检查")
+ else:
+ app_logger.info(f"已启用基于时长的分块({limit_value}s 限制) - 跳过 {original_file_size/1024/1024:.1f}MB 大小限制检查")
+
+ if should_enforce_size_limit and max_content_length and original_file_size > max_content_length:
+ raise RequestEntityTooLarge()
+
+ file.save(filepath)
+ app_logger.info(f"文件已保存到 {filepath}")
+
+ # ==================== 原始文件强制备份 ====================
+ try:
+ # 1. 定义备份目录(在 upload 目录下创建一个独立的 backup 文件夹)
+ backup_dir = os.path.join(flask_app.config['UPLOAD_FOLDER'], 'original_backups')
+ os.makedirs(backup_dir, exist_ok=True)
+
+ # 2. 生成备份文件名(增加时间戳前缀,防止重名覆盖)
+ # 注意:这里使用 copy2 保留文件元数据
+ backup_filename = f"RAW_{datetime.now().strftime('%Y%m%d%H%M%S')}_{safe_filename}"
+ backup_filepath = os.path.join(backup_dir, backup_filename)
+
+ # 3. 执行物理复制
+ shutil.copy2(filepath, backup_filepath)
+ app_logger.info(f"【安全备份】原始文件已备份至: {backup_filepath}")
+
+ except Exception as backup_error:
+ # 即使备份出错,也只打印日志,不阻断主流程(因为主文件已经存在了)
+ app_logger.error(f"【安全备份失败】无法备份原始文件: {str(backup_error)}")
+
+ # --- 仅在需要分块时转换文件 ---
+ filename_lower = original_filename.lower()
+
+ # 检查此文件是否需要分块
+ needs_chunking_for_processing = (chunking_service and
+ ENABLE_CHUNKING and
+ not USE_ASR_ENDPOINT and
+ chunking_service.needs_chunking(filepath, USE_ASR_ENDPOINT))
+
+ # 根据是否需要分块定义支持的格式
+ if needs_chunking_for_processing:
+ # 对于分块:仅支持与分块兼容的格式
+ supported_formats = ('.wav', '.mp3', '.flac')
+ convertible_formats = ('.amr', '.3gp', '.3gpp', '.m4a', '.aac', '.ogg', '.wma', '.webm')
+ else:
+ # 对于直接转录:直接支持 WebM 和其他格式
+ supported_formats = ('.wav', '.mp3', '.flac', '.webm', '.m4a', '.aac', '.ogg')
+ convertible_formats = ('.amr', '.3gp', '.3gpp', '.wma')
+
+ # 使用 ASR 端点时对有问题的 AAC 文件的特殊处理
+ is_problematic_aac = (USE_ASR_ENDPOINT and
+ (filename_lower.endswith('.aac') or
+ 'aac' in filename_lower.lower()))
+
+ # 如果文件不在支持的格式中,或者是有问题的 AAC 文件用于 ASR,则转换
+ should_convert = ((not filename_lower.endswith(supported_formats) and needs_chunking_for_processing) or
+ is_problematic_aac)
+
+ if should_convert:
+ if is_problematic_aac:
+ app_logger.info(f"将 AAC 编码文件 {filename_lower} 转换为高质量 MP3 以兼容 ASR 端点")
+ elif filename_lower.endswith(convertible_formats):
+ app_logger.info(f"将 {filename_lower} 格式转换为高质量 MP3 以进行分块处理")
+ else:
+ app_logger.info(f"尝试将未知格式({filename_lower})转换为高质量 MP3 以进行分块")
+
+ base_filepath, _ = os.path.splitext(filepath)
+ temp_mp3_filepath = f"{base_filepath}_temp.mp3"
+ mp3_filepath = f"{base_filepath}.mp3"
+
+ try:
+ # 转换为高质量 MP3(128kbps, 44.1kHz)以提高转录准确率
+ subprocess.run(
+ ['ffmpeg', '-i', filepath, '-y', '-acodec', 'libmp3lame', '-b:a', '128k', '-ar', '44100', '-ac', '1', temp_mp3_filepath],
+ check=True, capture_output=True, text=True
+ )
+ app_logger.info(f"成功将 {filepath} 转换为 {temp_mp3_filepath} (128kbps MP3)")
+
+ # 如果原始文件与最终 mp3 文件不同,删除它
+ if filepath.lower() != mp3_filepath.lower():
+ os.remove(filepath)
+
+ # 将临时文件重命名为最终文件名
+ os.rename(temp_mp3_filepath, mp3_filepath)
+
+ filepath = mp3_filepath
+ except FileNotFoundError:
+ app_logger.error("未找到 ffmpeg 命令。请确保 ffmpeg 已安装并在系统 PATH 中")
+ return jsonify({'error': '服务器上未找到音频转换工具(ffmpeg)'}), 500
+ except subprocess.CalledProcessError as e:
+ app_logger.error(f"ffmpeg 转换失败 {filepath}: {e.stderr}")
+ return jsonify({'error': f'音频文件转换失败: {e.stderr}'}), 500
+ elif not filename_lower.endswith(supported_formats):
+ # 文件不受支持且不需要分块 - 记录日志但不转换
+ app_logger.info(f"文件格式 {filename_lower} 将直接处理,无需转换(不需要分块)")
+
+ # 获取最终文件大小(原始或转换后的文件)
+ final_file_size = os.path.getsize(filepath)
+
+ # 确定最终文件的 MIME 类型
+ mime_type, _ = mimetypes.guess_type(filepath)
+ app_logger.info(f"最终 MIME 类型: {mime_type} 文件 {filepath}")
+
+ # 从表单获取笔记
+ notes = request.form.get('notes')
+
+ # 如果提供了选定的标签(支持多标签)
+ selected_tags = []
+ tag_index = 0
+ while True:
+ tag_id_key = f'tag_ids[{tag_index}]'
+ tag_id = request.form.get(tag_id_key)
+ if not tag_id:
+ break
+
+ tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first()
+ if tag:
+ selected_tags.append(tag)
+ tag_index += 1
+
+ # 向后兼容单标签上传
+ if not selected_tags:
+ single_tag_id = request.form.get('tag_id')
+ if single_tag_id:
+ tag = Tag.query.filter_by(id=single_tag_id, user_id=current_user.id).first()
+ if tag:
+ selected_tags.append(tag)
+
+ # 如果提供了 ASR 高级选项
+ language = request.form.get('language', '')
+ min_speakers = request.form.get('min_speakers') or None
+ max_speakers = request.form.get('max_speakers') or None
+
+ # 如果提供了则转换为 int
+ if min_speakers:
+ try:
+ min_speakers = int(min_speakers)
+ except (ValueError, TypeError):
+ min_speakers = None
+ if max_speakers:
+ try:
+ max_speakers = int(max_speakers)
+ except (ValueError, TypeError):
+ max_speakers = None
+
+ # 应用优先级层次:用户输入 > 标签默认值 > 环境变量 > 自动检测
+
+ # 如果选择了标签且用户未明确提供值,应用标签默认值
+ # 使用第一个标签的默认值(最高优先级)
+ if selected_tags:
+ first_tag = selected_tags[0]
+ if not language and first_tag.default_language:
+ language = first_tag.default_language
+ if min_speakers is None and first_tag.default_min_speakers:
+ min_speakers = first_tag.default_min_speakers
+ if max_speakers is None and first_tag.default_max_speakers:
+ max_speakers = first_tag.default_max_speakers
+
+ # 如果仍未设置值,应用环境变量默认值
+ if min_speakers is None and ASR_MIN_SPEAKERS:
+ try:
+ min_speakers = int(ASR_MIN_SPEAKERS)
+ except (ValueError, TypeError):
+ min_speakers = None
+ if max_speakers is None and ASR_MAX_SPEAKERS:
+ try:
+ max_speakers = int(ASR_MAX_SPEAKERS)
+ except (ValueError, TypeError):
+ max_speakers = None
+
+ recording = create_recording_from_existing_file(
+ filepath=filepath,
+ original_filename=original_filename,
+ notes=notes,
+ selected_tags=selected_tags,
+ language=language,
+ min_speakers=min_speakers,
+ max_speakers=max_speakers,
+ mime_type=mime_type,
+ processing_source='upload'
+ )
+
+ return jsonify(recording.to_dict()), 202
+
+ except RequestEntityTooLarge:
+ max_size_mb = flask_app.config['MAX_CONTENT_LENGTH'] / (1024 * 1024)
+ app_logger.warning(f"上传失败:文件太大(>{max_size_mb}MB)")
+ return jsonify({
+ 'error': f'文件太大。最大大小为 {max_size_mb:.0f} MB。',
+ 'max_size_mb': max_size_mb
+ }), 413
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"文件上传时出错: {e}", exc_info=True)
+ return jsonify({'error': '上传过程中发生意外错误'}), 500
+
+
+# ==================== 录音状态和音频相关路由 ====================
+
+@recording_bp.route('/status/', methods=['GET'])
+@login_required
+def get_status(recording_id):
+ """检查转录/摘要状态"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ # 检查录音是否属于当前用户
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限查看此录音'}), 403
+
+ # 确保事件已加载(刷新录音以获取最新关系)
+ db.session.refresh(recording)
+
+ return jsonify(recording.to_dict())
+ except Exception as e:
+ app_logger.error(f"获取录音 {recording_id} 状态时出错: {e}", exc_info=True)
+ return jsonify({'error': '发生意外错误'}), 500
+
+
+@recording_bp.route('/audio/')
+@login_required
+def get_audio(recording_id):
+ """获取音频文件"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording or not recording.audio_path:
+ return jsonify({'error': '录音或音频文件未找到'}), 404
+
+ # 检查录音是否属于当前用户
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限访问此音频文件'}), 403
+ if not os.path.exists(recording.audio_path):
+ app_logger.error(f"音频文件在服务器上丢失: {recording.audio_path}")
+ return jsonify({'error': '音频文件在服务器上丢失'}), 404
+ return send_file(recording.audio_path)
+ except Exception as e:
+ app_logger.error(f"为录音 {recording_id} 提供音频时出错: {e}", exc_info=True)
+ return jsonify({'error': '发生意外错误'}), 500
+
+
+# ==================== 录音更新相关路由 ====================
+
+@recording_bp.route('/recording//update_transcription', methods=['POST'])
+@login_required
+def update_transcription(recording_id):
+ """更新录音的转录内容"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限编辑此录音'}), 403
+
+ data = request.json
+ new_transcription = data.get('transcription')
+
+ if new_transcription is None:
+ return jsonify({'error': '未提供转录数据'}), 400
+
+ # 传入的数据可能是 JSON 字符串(来自 ASR 编辑)或纯文本
+ recording.transcription = new_transcription
+
+ # 可选:如果转录改变,我们可能想要指示摘要已过时
+ # 目前,我们只保存转录。"重新生成摘要"按钮可能是一个好的后续功能
+
+ db.session.commit()
+ app_logger.info(f"录音 {recording_id} 的转录已更新")
+
+ return jsonify({'success': True, 'message': '转录更新成功', 'recording': recording.to_dict()})
+
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"更新录音 {recording_id} 转录时出错: {e}", exc_info=True)
+ return jsonify({'error': '更新转录时发生意外错误'}), 500
+
+
+@recording_bp.route('/recording//toggle_inbox', methods=['POST'])
+@login_required
+def toggle_inbox(recording_id):
+ """切换收件箱状态"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ # 检查录音是否属于当前用户
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限修改此录音'}), 403
+
+ # 切换收件箱状态
+ recording.is_inbox = not recording.is_inbox
+ db.session.commit()
+
+ return jsonify({'success': True, 'is_inbox': recording.is_inbox})
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"切换录音 {recording_id} 收件箱状态时出错: {e}", exc_info=True)
+ return jsonify({'error': '发生意外错误'}), 500
+
+
+@recording_bp.route('/recording//toggle_highlight', methods=['POST'])
+@login_required
+def toggle_highlight(recording_id):
+ """切换高亮状态"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ # 检查录音是否属于当前用户
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限修改此录音'}), 403
+
+ # 切换高亮状态
+ recording.is_highlighted = not recording.is_highlighted
+ db.session.commit()
+
+ return jsonify({'success': True, 'is_highlighted': recording.is_highlighted})
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"切换录音 {recording_id} 高亮状态时出错: {e}", exc_info=True)
+ return jsonify({'error': '发生意外错误'}), 500
+
+
+# ==================== 录音重新处理相关路由 ====================
+
+@recording_bp.route('/recording//reprocess_transcription', methods=['POST'])
+@login_required
+def reprocess_transcription(recording_id):
+ """重新处理录音的转录"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限重新处理此录音'}), 403
+
+ if not recording.audio_path or not os.path.exists(recording.audio_path):
+ return jsonify({'error': '未找到用于重新处理的音频文件'}), 404
+
+ if recording.status in ['PROCESSING', 'SUMMARIZING']:
+ return jsonify({'error': '录音正在处理中'}), 400
+
+ # --- 在重新处理前转换文件(如果需要) ---
+ filepath = recording.audio_path
+ filename_for_asr = recording.original_filename or os.path.basename(filepath)
+ filename_lower = filename_for_asr.lower()
+
+ supported_formats = ('.wav', '.mp3', '.flac')
+ if not filename_lower.endswith(supported_formats):
+ app_logger.info(f"重新处理:将 {filename_lower} 格式转换为高质量 MP3")
+ base_filepath, file_ext = os.path.splitext(filepath)
+ temp_mp3_filepath = f"{base_filepath}_temp.mp3"
+ final_mp3_filepath = f"{base_filepath}.mp3"
+
+ try:
+ # 转换为高质量 MP3(128kbps, 44.1kHz)
+ subprocess.run(
+ ['ffmpeg', '-i', filepath, '-y', '-acodec', 'libmp3lame', '-b:a', '128k', '-ar', '44100', temp_mp3_filepath],
+ check=True, capture_output=True, text=True
+ )
+ app_logger.info(f"成功将 {filepath} 转换为 {temp_mp3_filepath} (128kbps MP3)")
+
+ # 如果原始文件与最终 mp3 文件不同,删除它
+ if filepath.lower() != final_mp3_filepath.lower():
+ os.remove(filepath)
+
+ # 覆盖已有最终文件,避免 Windows 下 os.rename() 因目标存在而失败
+ if os.path.exists(final_mp3_filepath):
+ app_logger.info(
+ f"重新处理录音 {recording_id} 时发现旧文件已存在,将覆盖: {final_mp3_filepath}"
+ )
+ os.replace(temp_mp3_filepath, final_mp3_filepath)
+
+ filepath = final_mp3_filepath
+ filename_for_asr = os.path.basename(filepath)
+
+ # 使用新路径和 MIME 类型更新数据库
+ recording.audio_path = filepath
+ recording.mime_type, _ = mimetypes.guess_type(filepath)
+ db.session.commit()
+
+ except FileNotFoundError:
+ app_logger.error("未找到 ffmpeg 命令。请确保 ffmpeg 已安装并在系统 PATH 中")
+ return jsonify({'error': '服务器上未找到音频转换工具(ffmpeg)'}), 500
+ except subprocess.CalledProcessError as e:
+ app_logger.error(f"ffmpeg 转换失败 {filepath}: {e.stderr}")
+ return jsonify({'error': f'音频文件转换失败: {e.stderr}'}), 500
+
+ # --- 继续重新处理 ---
+ recording.transcription = None
+ recording.summary = None
+ recording.status = 'PROCESSING'
+
+ # 清除现有事件,因为它们依赖于转录
+ Event.query.filter_by(recording_id=recording_id).delete()
+
+ db.session.commit()
+
+ # 刷新录音对象以确保它具有最新的提交数据
+ db.session.refresh(recording)
+
+ app_logger.info(f"开始重新处理录音 {recording_id} 的转录")
+
+ # 决定使用哪种转录方法
+ if USE_ASR_ENDPOINT:
+ app_logger.info(f"使用 ASR 端点重新处理录音 {recording_id}")
+
+ data = request.json or {}
+ language = data.get('language') or (recording.owner.transcription_language if recording.owner else None)
+ min_speakers = data.get('min_speakers') or None
+ max_speakers = data.get('max_speakers') or None
+
+ # 如果提供了则转换为 int
+ if min_speakers:
+ try:
+ min_speakers = int(min_speakers)
+ except (ValueError, TypeError):
+ min_speakers = None
+ if max_speakers:
+ try:
+ max_speakers = int(max_speakers)
+ except (ValueError, TypeError):
+ max_speakers = None
+
+ # 如果没有用户提供输入,应用标签默认值(从此录音的所有标签合并默认值)
+ if (min_speakers is None or max_speakers is None) and recording.tags:
+ # 获取标签默认值(使用按顺序排列的标签的第一个非 None 值)
+ for tag_association in sorted(recording.tag_associations, key=lambda x: x.order):
+ tag = tag_association.tag
+ if min_speakers is None and tag.default_min_speakers:
+ min_speakers = tag.default_min_speakers
+ if max_speakers is None and tag.default_max_speakers:
+ max_speakers = tag.default_max_speakers
+ # 一旦我们有两个值就停止
+ if min_speakers is not None and max_speakers is not None:
+ break
+
+ # 如果仍未有值,应用环境变量默认值(重新处理层次:用户输入 > 标签默认值 > 环境变量 > 自动检测)
+ if min_speakers is None and ASR_MIN_SPEAKERS:
+ try:
+ min_speakers = int(ASR_MIN_SPEAKERS)
+ except (ValueError, TypeError):
+ min_speakers = None
+ if max_speakers is None and ASR_MAX_SPEAKERS:
+ try:
+ max_speakers = int(ASR_MAX_SPEAKERS)
+ except (ValueError, TypeError):
+ max_speakers = None
+ if 'ASR_DIARIZE' in os.environ:
+ diarize_setting = ASR_DIARIZE
+ elif USE_ASR_ENDPOINT:
+ # 使用 ASR 端点时,使用配置的 ASR_DIARIZE 值
+ diarize_setting = ASR_DIARIZE
+ else:
+ diarize_setting = recording.owner.diarize if recording.owner else False
+
+ start_time = datetime.utcnow()
+ thread = threading.Thread(
+ target=transcribe_audio_asr,
+ args=(flask_app.app_context(), recording.id, filepath, filename_for_asr, start_time),
+ kwargs={
+ 'mime_type': recording.mime_type,
+ 'language': language,
+ 'diarize': diarize_setting,
+ 'min_speakers': min_speakers,
+ 'max_speakers': max_speakers,
+ 'ASR_BASE_URL': ASR_BASE_URL,
+ 'USE_ASR_ENDPOINT': USE_ASR_ENDPOINT,
+ 'ASR_DIARIZE': ASR_DIARIZE,
+ 'generate_title_task': generate_title_task,
+ 'generate_summary_only_task': generate_summary_only_task,
+ 'app_logger': app_logger,
+ 'UPLOAD_FOLDER': flask_app.config.get('UPLOAD_FOLDER')
+ }
+ )
+ else:
+ app_logger.info(f"使用标准转录 API 重新处理录音 {recording_id}")
+ start_time = datetime.utcnow()
+ thread = threading.Thread(
+ target=transcribe_audio_task,
+ args=(flask_app.app_context(), recording.id, filepath, filename_for_asr, start_time),
+ kwargs={
+ 'ASR_BASE_URL': ASR_BASE_URL,
+ 'USE_ASR_ENDPOINT': USE_ASR_ENDPOINT,
+ 'ASR_DIARIZE': ASR_DIARIZE,
+ 'generate_title_task': generate_title_task,
+ 'generate_summary_only_task': generate_summary_only_task,
+ 'app_logger': app_logger,
+ 'UPLOAD_FOLDER': flask_app.config.get('UPLOAD_FOLDER')
+ }
+ )
+
+ thread.start()
+
+ return jsonify({
+ 'success': True,
+ 'message': '转录重新处理已启动',
+ 'recording': recording.to_dict()
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"重新处理录音 {recording_id} 转录时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@recording_bp.route('/recording//reprocess_summary', methods=['POST'])
+@login_required
+def reprocess_summary(recording_id):
+ """重新处理录音的摘要(需要现有转录)"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ # 检查录音是否属于当前用户
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限重新处理此录音'}), 403
+
+ # 检查转录是否存在
+ if not recording.transcription or len(recording.transcription.strip()) < 10:
+ return jsonify({'error': '没有可用的有效转录用于生成摘要'}), 400
+
+ # 检查是否正在处理
+ if recording.status in ['PROCESSING', 'SUMMARIZING']:
+ return jsonify({'error': '录音正在处理中'}), 400
+
+ # 检查 OpenRouter 客户端是否可用
+ if client is None:
+ return jsonify({'error': '摘要服务不可用(OpenRouter 客户端未配置)'}), 503
+
+ # 将状态设置为 SUMMARIZING 并清除现有摘要
+ recording.summary = None
+ recording.status = 'SUMMARIZING'
+
+ # 清除现有事件,因为它们可能在摘要生成期间重新提取
+ Event.query.filter_by(recording_id=recording_id).delete()
+
+ db.session.commit()
+
+ # 刷新录音对象以确保它具有最新的提交数据
+ db.session.refresh(recording)
+
+ app_logger.info(f"开始重新处理录音 {recording_id} 的摘要")
+
+ # 在后台线程中启动摘要生成
+ def reprocess_summary_task(app_context, recording_id):
+ app_logger.info(f"使用 generate_summary_only_task 开始重新处理录音 {recording_id} 的摘要")
+ generate_summary_only_task(app_context, recording_id)
+
+ thread = threading.Thread(
+ target=reprocess_summary_task,
+ args=(flask_app.app_context(), recording.id)
+ )
+ thread.start()
+
+ return jsonify({
+ 'success': True,
+ 'message': '摘要重新处理已启动',
+ 'recording': recording.to_dict()
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"重新处理录音 {recording_id} 摘要时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@recording_bp.route('/recording//reset_status', methods=['POST'])
+@login_required
+def reset_status(recording_id):
+ """重置卡住或失败的录音状态"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限修改此录音'}), 403
+
+ # 允许重置如果它卡住或失败
+ if recording.status in ['PROCESSING', 'SUMMARIZING', 'FAILED']:
+ recording.status = 'FAILED'
+ recording.error_message = "从卡住或失败状态手动重置"
+ db.session.commit()
+ app_logger.info(f"手动重置录音 {recording_id} 状态为 FAILED")
+ return jsonify({'success': True, 'message': '录音状态已重置', 'recording': recording.to_dict()})
+ else:
+ return jsonify({'error': f'录音处于无法重置的状态。当前状态: {recording.status}'}), 400
+
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"重置录音 {recording_id} 状态时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+# ==================== 说话人识别相关路由 ====================
+
+@recording_bp.route('/recording//update_speakers', methods=['POST'])
+@login_required
+def update_speakers(recording_id):
+ """使用提供的名称更新转录中的说话人标签"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限编辑此录音'}), 403
+
+ data = request.json
+ speaker_map = data.get('speaker_map')
+ regenerate_summary = data.get('regenerate_summary', False)
+
+ if not speaker_map:
+ return jsonify({'error': '未提供说话人映射'}), 400
+
+ transcription_text = recording.transcription
+ is_json = False
+ try:
+ transcription_data = json.loads(transcription_text)
+ # 检查新的简化 JSON 格式(段对象列表)
+ is_json = isinstance(transcription_data, list)
+ except (json.JSONDecodeError, TypeError):
+ is_json = False
+
+ speaker_names_used = []
+
+ if is_json:
+ # 处理新的简化 JSON 转录(段列表)
+ for segment in transcription_data:
+ original_speaker_label = segment.get('speaker')
+ if original_speaker_label in speaker_map:
+ new_name_info = speaker_map[original_speaker_label]
+ new_name = new_name_info.get('name', '').strip()
+ if new_name_info.get('isMe'):
+ new_name = current_user.name or 'Me'
+
+ if new_name:
+ segment['speaker'] = new_name
+ if new_name not in speaker_names_used:
+ speaker_names_used.append(new_name)
+
+ recording.transcription = json.dumps(transcription_data)
+
+ # 仅从实际给出名称的说话人更新参与者(不是默认标签)
+ final_speakers = set()
+ for seg in transcription_data:
+ speaker = seg.get('speaker')
+ if speaker and str(speaker).strip():
+ # 仅包括已给出实际名称的说话人(不是默认标签如 "SPEAKER_01", "SPEAKER_09" 等)
+ # 检查此说话人是否已用真实名称更新(不是默认标签)
+ if not re.match(r'^SPEAKER_\d+$', str(speaker), re.IGNORECASE):
+ final_speakers.add(speaker)
+ recording.participants = ', '.join(sorted(list(final_speakers)))
+
+ else:
+ # 处理纯文本转录
+ new_participants = []
+ for speaker_label, new_name_info in speaker_map.items():
+ new_name = new_name_info.get('name', '').strip()
+ if new_name_info.get('isMe'):
+ new_name = current_user.name or 'Me'
+
+ if new_name:
+ transcription_text = re.sub(r'\[\s*' + re.escape(speaker_label) + r'\s*\]', f'[{new_name}]', transcription_text, flags=re.IGNORECASE)
+ if new_name not in new_participants:
+ new_participants.append(new_name)
+
+ recording.transcription = transcription_text
+ if new_participants:
+ recording.participants = ', '.join(new_participants)
+ speaker_names_used = new_participants
+
+ # 更新说话人使用统计
+ if speaker_names_used:
+ update_speaker_usage(speaker_names_used)
+
+ db.session.commit()
+
+ if regenerate_summary:
+ app_logger.info(f"在说话人更新后为录音 {recording_id} 重新生成摘要")
+ thread = threading.Thread(
+ target=generate_summary_only_task,
+ args=(flask_app.app_context(), recording.id)
+ )
+ thread.start()
+
+ return jsonify({
+ 'success': True,
+ 'message': '说话人更新成功',
+ 'recording': recording.to_dict()
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"更新录音 {recording_id} 说话人时出错: {e}", exc_info=True)
+ return jsonify({'error': str(e)}), 500
+
+
+@recording_bp.route('/recording//auto_identify_speakers', methods=['POST'])
+@login_required
+def auto_identify_speakers(recording_id):
+ """
+ 使用 LLM 自动识别转录中的说话人
+ 仅识别尚未被识别的说话人
+ """
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限修改此录音'}), 403
+
+ if not recording.transcription:
+ return jsonify({'error': '没有可用的转录用于说话人识别'}), 400
+
+ # 从请求获取当前说话人映射
+ data = request.json or {}
+ current_speaker_map = data.get('current_speaker_map', {})
+
+ # 从转录中提取所有说话人标签
+ formatted_transcription = format_transcription_for_llm(recording.transcription)
+ all_labels = re.findall(r'\[(SPEAKER_\d+)\]', formatted_transcription)
+ seen = set()
+ speaker_labels = [x for x in all_labels if not (x in seen or seen.add(x))]
+
+ # 过滤掉已分配名称的说话人
+ unidentified_speakers = []
+ for speaker_label in speaker_labels:
+ speaker_info = current_speaker_map.get(speaker_label, {})
+ speaker_name = speaker_info.get('name', '').strip() if isinstance(speaker_info, dict) else str(speaker_info).strip()
+
+ # 仅包括没有名称或名称为空的说话人
+ if not speaker_name:
+ unidentified_speakers.append(speaker_label)
+
+ if not unidentified_speakers:
+ return jsonify({'success': True, 'speaker_map': {}, 'message': '所有说话人已被识别'})
+
+ # 仅使用未识别的说话人调用辅助函数
+ speaker_map = identify_unidentified_speakers_from_text(recording.transcription, unidentified_speakers)
+
+ return jsonify({'success': True, 'speaker_map': speaker_map})
+
+ except ValueError as ve:
+ # 处理 API 密钥未设置的情况
+ return jsonify({'error': str(ve)}), 503
+ except Exception as e:
+ app_logger.error(f"录音 {recording_id} 自动说话人识别时出错: {e}", exc_info=True)
+ return jsonify({'error': f'发生意外错误: {e}'}), 500
+
+
+# ==================== 摘要生成相关路由 ====================
+
+@recording_bp.route('/recording//generate_summary', methods=['POST'])
+@login_required
+def generate_summary_endpoint(recording_id):
+ """为没有摘要的录音生成摘要"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ # 检查录音是否属于当前用户
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限为此录音生成摘要'}), 403
+
+ # 检查转录是否存在
+ if not recording.transcription or len(recording.transcription.strip()) < 10:
+ return jsonify({'error': '没有可用的有效转录用于生成摘要'}), 400
+
+ # 检查是否正在处理
+ if recording.status in ['PROCESSING', 'SUMMARIZING']:
+ return jsonify({'error': '录音正在处理中'}), 400
+
+ # 检查 OpenRouter 客户端是否可用
+ if client is None:
+ return jsonify({'error': '摘要服务不可用(OpenRouter 客户端未配置)'}), 503
+
+ app_logger.info(f"开始为录音 {recording_id} 生成摘要")
+
+ # 在后台线程中生成摘要
+ thread = threading.Thread(
+ target=generate_summary_only_task,
+ args=(flask_app.app_context(), recording_id)
+ )
+ thread.start()
+
+ return jsonify({
+ 'success': True,
+ 'message': '摘要生成已启动'
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"为录音 {recording_id} 启动摘要生成时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+# ==================== 录音删除相关路由 ====================
+
+@recording_bp.route('/recording/', methods=['DELETE'])
+@login_required
+def delete_recording(recording_id):
+ """删除录音"""
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ # 检查录音是否属于当前用户
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限删除此录音'}), 403
+
+ # 首先删除音频文件
+ try:
+ if recording.audio_path and os.path.exists(recording.audio_path):
+ os.remove(recording.audio_path)
+ app_logger.info(f"已删除音频文件: {recording.audio_path}")
+ except Exception as e:
+ app_logger.error(f"删除音频文件 {recording.audio_path} 时出错: {e}")
+
+ # 如果启用了 Inquire 模式,记录嵌入清理日志
+ if ENABLE_INQUIRE_MODE:
+ chunk_count = TranscriptChunk.query.filter_by(recording_id=recording_id).count()
+ if chunk_count > 0:
+ app_logger.info(f"正在删除录音 {recording_id} 的 {chunk_count} 个转录块和嵌入")
+
+ # 删除数据库记录(级联将处理块/嵌入)
+ db.session.delete(recording)
+ db.session.commit()
+ app_logger.info(f"已删除录音记录 ID: {recording_id}")
+
+ if ENABLE_INQUIRE_MODE and chunk_count > 0:
+ app_logger.info(f"成功删除录音 {recording_id} 的嵌入和块")
+
+ return jsonify({'success': True})
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"删除录音 {recording_id} 时出错: {e}", exc_info=True)
+ return jsonify({'error': '删除时发生意外错误'}), 500
+
+
+# ==================== 录音元数据保存相关路由 ====================
+
+@recording_bp.route('/save', methods=['POST'])
+@login_required
+def save_metadata():
+ """保存录音元数据"""
+ try:
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供数据'}), 400
+ recording_id = data.get('id')
+ if not recording_id:
+ return jsonify({'error': '未提供录音ID'}), 400
+
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ # 检查录音是否属于当前用户
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限编辑此录音'}), 403
+
+ # 更新字段(如果提供)
+ if 'title' in data:
+ recording.title = data['title']
+ if 'participants' in data:
+ recording.participants = data['participants']
+ if 'notes' in data:
+ recording.notes = data['notes']
+ if 'summary' in data:
+ recording.summary = data['summary']
+ if 'is_inbox' in data:
+ recording.is_inbox = data['is_inbox']
+ if 'is_highlighted' in data:
+ recording.is_highlighted = data['is_highlighted']
+ if 'meeting_date' in data:
+ try:
+ # 尝试解析日期字符串(例如:"YYYY-MM-DD")
+ date_str = data['meeting_date']
+ if date_str:
+ recording.meeting_date = datetime.strptime(date_str, '%Y-%m-%d').date()
+ else:
+ recording.meeting_date = None # 允许清除日期
+ except (ValueError, TypeError) as e:
+ app_logger.warning(f"无法解析会议日期 '{data.get('meeting_date')}': {e}")
+
+ # 不在此处更新转录或状态
+ db.session.commit()
+ return jsonify({'success': True, 'recording': recording.to_dict()})
+
+ except Exception as e:
+ db.session.rollback()
+ app_logger.error(f"保存录音 {recording_id} 元数据时出错: {e}", exc_info=True)
+ return jsonify({'error': '保存时发生意外错误'}), 500
+
+
+# ==================== ASR 文本矫正相关路由 ====================
+
+@recording_bp.route('/api/asr/correct', methods=['POST'])
+@login_required
+def correct_asr_text():
+ """使用 LLM 矫正 ASR 识别结果"""
+ try:
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供数据'}), 400
+
+ asr_text = data.get('text')
+ if not asr_text:
+ return jsonify({'error': '未提供文本'}), 400
+
+ scene = _normalize_asr_scene(data.get('scene') or request.args.get('scene'))
+
+ # 使用 LLM 矫正 ASR 文本
+ system_prompt = SystemSetting.get_setting(
+ 'correct_system_prompt',
+ '请严格纠正以下ASR文本中的错误(如同音字、标点符号、语法错误等),并保持其原意、格式与结构不变。'
+ )
+ user_prompt = f"请转换以下口语转录文本:\n\n{asr_text}"
+
+ completion = call_llm_completion(
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt}
+ ],
+ temperature=0.3, # 较低温度保证准确性
+ max_tokens=2000,
+ stream=False
+ )
+
+ corrected_text = completion.choices[0].message.content.strip()
+ final_corrected_text = extract_corrected_text(corrected_text, asr_text)
+ standard_word_mapping_config = SystemConfig.query.filter_by(
+ config_key=_standard_word_mappings_key_for_scene(scene)
+ ).first()
+ mapped_corrected_text, applied_standard_word_mappings = apply_standard_word_mappings_with_details(
+ final_corrected_text,
+ standard_word_mapping_config.config_value if standard_word_mapping_config else None
+ )
+ log_message = (
+ f"结果: {mapped_corrected_text}, ASR: {asr_text}, 矫正: {corrected_text[:50]}"
+ )
+ if applied_standard_word_mappings:
+ mapping_summary = ", ".join(
+ f"{mapping['from']}->{mapping['to']}"
+ for mapping in applied_standard_word_mappings
+ )
+ log_message = f"{log_message}, 映射: [{mapping_summary}]"
+ app_logger.info(log_message)
+
+ return jsonify({
+ 'corrected_text': mapped_corrected_text,
+ 'original_text': asr_text,
+ 'llm_corrected_text': final_corrected_text
+ })
+
+ except Exception as e:
+ app_logger.error(f"矫正 ASR 文本时出错: {str(e)}")
+ return jsonify({'error': str(e)}), 500
+
+
+# ==================== 外部摘要查询相关路由 ====================
+
+@recording_bp.route('/api/external/summaries', methods=['GET'])
+def get_completed_summaries_by_date():
+ """
+ 根据 user_id 和创建日期获取已完成的录音摘要列表
+ 返回格式包含 summary 和 created_at 的字典
+ """
+ try:
+ # 1. 获取入参
+ user_id = request.args.get('user_id')
+ created_at_str = request.args.get('created_at')
+
+ # 参数校验
+ if not user_id or not created_at_str:
+ return jsonify({'error': '缺少必需参数: user_id 或 created_at'}), 400
+
+ try:
+ # 解析日期 YYYY-MM-DD
+ target_date = datetime.strptime(created_at_str, '%Y-%m-%d').date()
+ except ValueError:
+ return jsonify({'error': '日期格式无效。请使用 YYYY-MM-DD'}), 400
+
+ # 2. 筛选数据
+ stmt = select(Recording.summary, Recording.created_at).where(
+ Recording.user_id == user_id,
+ Recording.status == 'COMPLETED',
+ func.date(Recording.created_at) == target_date
+ ).order_by(Recording.created_at.desc())
+
+ # 执行查询,获取结果列表
+ results = db.session.execute(stmt).all()
+
+ # 3. 组装返回结果
+ summary_list = []
+ for row in results:
+ summary_text = row[0]
+ created_at_val = row[1]
+
+ if summary_text: # 确保摘要不为空
+ summary_list.append({
+ "summary": summary_text,
+ "created_at": created_at_val.strftime('%Y-%m-%d %H:%M:%S') if created_at_val else ""
+ })
+
+ return jsonify({
+ 'success': True,
+ 'count': len(summary_list),
+ 'summaries': summary_list
+ })
+
+ except Exception as e:
+ app_logger.error(f"获取外部摘要时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
diff --git a/speakr/src/api/recording_download.py b/speakr/src/api/recording_download.py
new file mode 100644
index 0000000..99757b7
--- /dev/null
+++ b/speakr/src/api/recording_download.py
@@ -0,0 +1,254 @@
+# 录音管理蓝图 - 下载相关路由
+# 处理转录稿、摘要、聊天对话、笔记的 Word 文档下载
+
+from flask import Blueprint, request, jsonify
+from flask_login import login_required, current_user
+from datetime import datetime, timedelta
+import json
+import re
+
+from src.clients import db
+from src.models import Recording, TranscriptTemplate
+from src.services import (
+ add_unicode_paragraph,
+ create_docx_with_unicode_title,
+ save_and_send_docx,
+ process_markdown_to_docx,
+ get_recording_display_title,
+ build_docx_download_filename,
+)
+
+recording_download_bp = Blueprint('recording_download', __name__)
+
+
+@recording_download_bp.route('/recording//download/transcript')
+@login_required
+def download_transcript_with_template(recording_id):
+ """下载转录稿(使用自定义模板格式)为 Word 文档"""
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限访问此录音'}), 403
+
+ if not recording.transcription:
+ return jsonify({'error': '此录音没有可用的转录内容'}), 400
+
+ # 从查询参数获取模板 ID
+ template_id = request.args.get('template_id')
+
+ use_template = True
+ template = None
+ if template_id == 'none':
+ use_template = False
+ elif template_id:
+ try:
+ template_id = int(template_id)
+ template = TranscriptTemplate.query.filter_by(
+ id=template_id,
+ user_id=current_user.id
+ ).first()
+ except (ValueError, TypeError):
+ pass
+
+ if use_template and not template:
+ template = TranscriptTemplate.query.filter_by(
+ user_id=current_user.id,
+ is_default=True
+ ).first()
+
+ if not template or not use_template:
+ template_format = "[{{speaker}}]: {{text}}"
+ else:
+ template_format = template.template
+
+ def format_time(seconds):
+ if seconds is None:
+ return "00:00:00"
+ td = timedelta(seconds=seconds)
+ hours = int(td.total_seconds() // 3600)
+ minutes = int((td.total_seconds() % 3600) // 60)
+ secs = int(td.total_seconds() % 60)
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
+
+ def format_srt_time(seconds):
+ if seconds is None:
+ return "00:00:00,000"
+ td = timedelta(seconds=seconds)
+ hours = int(td.total_seconds() // 3600)
+ minutes = int((td.total_seconds() % 3600) // 60)
+ secs = int(td.total_seconds() % 60)
+ millis = int((td.total_seconds() % 1) * 1000)
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
+
+ try:
+ transcription_data = json.loads(recording.transcription)
+ except (json.JSONDecodeError, TypeError):
+ transcription_data = [{'speaker': '说话人', 'sentence': recording.transcription}]
+
+ from docx import Document
+ doc = Document()
+
+ title_text = f'转录稿:{get_recording_display_title(recording)}'
+ create_docx_with_unicode_title(doc, title_text)
+
+ template_label = template.name if template else '原文'
+
+ add_unicode_paragraph(doc, f'录音时间:{recording.created_at.strftime("%Y-%m-%d %H:%M")}')
+ add_unicode_paragraph(doc, f'导出模板:{template_label}')
+ doc.add_paragraph('')
+
+ if type(transcription_data) is list:
+ for index, segment in enumerate(transcription_data, 1):
+ line = template_format
+ replacements = {
+ '{{index}}': str(index),
+ '{{speaker}}': segment.get('speaker', '未知说话人'),
+ '{{text}}': segment.get('sentence', ''),
+ '{{start_time}}': format_time(segment.get('start_time')),
+ '{{end_time}}': format_time(segment.get('end_time')),
+ }
+ for key, value in replacements.items():
+ line = line.replace(key, value)
+ line = re.sub(r'{{(.*?)\|upper}}', lambda m: replacements.get('{{' + m.group(1) + '}}', '').upper(), line)
+ line = re.sub(r'{{start_time\|srt}}', format_srt_time(segment.get('start_time')), line)
+ line = re.sub(r'{{end_time\|srt}}', format_srt_time(segment.get('end_time')), line)
+ add_unicode_paragraph(doc, line)
+ else:
+ add_unicode_paragraph(doc, str(transcription_data))
+
+ filename = build_docx_download_filename('转录稿', recording, recording_id, [template_label])
+ return save_and_send_docx(doc, filename, f'transcript_{recording_id}.docx')
+
+
+@recording_download_bp.route('/recording//download/summary')
+@login_required
+def download_summary_word(recording_id):
+ """下载录音摘要为 Word 文档"""
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限访问此录音'}), 403
+
+ if not recording.summary:
+ return jsonify({'error': '此录音没有可用的摘要'}), 400
+
+ from docx import Document
+ doc = Document()
+
+ title_text = f'会议摘要:{get_recording_display_title(recording)}'
+ create_docx_with_unicode_title(doc, title_text)
+
+ add_unicode_paragraph(doc, f'上传时间:{recording.created_at.strftime("%Y-%m-%d %H:%M")}')
+ if recording.meeting_date:
+ add_unicode_paragraph(doc, f'会议日期:{recording.meeting_date.strftime("%Y-%m-%d")}')
+ if recording.participants:
+ add_unicode_paragraph(doc, f'参会人员:{recording.participants}')
+ if recording.tags:
+ tags_str = ', '.join([tag.name for tag in recording.tags])
+ add_unicode_paragraph(doc, f'标签:{tags_str}')
+ doc.add_paragraph('')
+
+ process_markdown_to_docx(doc, recording.summary)
+
+ filename = build_docx_download_filename('会议摘要', recording, recording_id)
+ return save_and_send_docx(doc, filename, f'summary_{recording_id}.docx')
+
+
+@recording_download_bp.route('/recording//download/chat', methods=['POST'])
+@login_required
+def download_chat_word(recording_id):
+ """下载聊天对话为 Word 文档"""
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限访问此录音'}), 403
+
+ data = request.json
+ if not data or 'messages' not in data:
+ return jsonify({'error': '未提供消息'}), 400
+
+ messages = data['messages']
+ if not messages:
+ return jsonify({'error': '没有可下载的消息'}), 400
+
+ from docx import Document
+ doc = Document()
+
+ title_text = f'对话记录:{get_recording_display_title(recording)}'
+ create_docx_with_unicode_title(doc, title_text)
+
+ add_unicode_paragraph(doc, f'录音时间:{recording.created_at.strftime("%Y-%m-%d %H:%M")}')
+ add_unicode_paragraph(doc, f'导出时间:{datetime.utcnow().strftime("%Y-%m-%d %H:%M")}')
+ doc.add_paragraph('')
+
+ for message in messages:
+ role = message.get('role', 'unknown')
+ content = message.get('content', '')
+ thinking = message.get('thinking', '')
+
+ if role == 'user':
+ p = doc.add_paragraph()
+ run = p.add_run('你:')
+ run.bold = True
+ elif role == 'assistant':
+ p = doc.add_paragraph()
+ run = p.add_run('智能助手:')
+ run.bold = True
+ else:
+ p = doc.add_paragraph()
+ run = p.add_run(f'{role.title()}:')
+ run.bold = True
+
+ if thinking and role == 'assistant':
+ p = doc.add_paragraph()
+ p.add_run('[模型思考]\n').italic = True
+ p.add_run(thinking).italic = True
+ doc.add_paragraph('')
+
+ process_markdown_to_docx(doc, content)
+ doc.add_paragraph('')
+
+ filename = build_docx_download_filename('对话记录', recording, recording_id)
+ return save_and_send_docx(doc, filename, f'chat_{recording_id}.docx')
+
+
+@recording_download_bp.route('/recording//download/notes')
+@login_required
+def download_notes_word(recording_id):
+ """下载录音笔记为 Word 文档"""
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ return jsonify({'error': '录音未找到'}), 404
+
+ if recording.user_id and recording.user_id != current_user.id:
+ return jsonify({'error': '您没有权限访问此录音'}), 403
+
+ if not recording.notes:
+ return jsonify({'error': '此录音没有可用的笔记'}), 400
+
+ from docx import Document
+ doc = Document()
+
+ title_text = f'会议笔记:{get_recording_display_title(recording)}'
+ create_docx_with_unicode_title(doc, title_text)
+
+ add_unicode_paragraph(doc, f'上传时间:{recording.created_at.strftime("%Y-%m-%d %H:%M")}')
+ if recording.meeting_date:
+ add_unicode_paragraph(doc, f'会议日期:{recording.meeting_date.strftime("%Y-%m-%d")}')
+ if recording.participants:
+ add_unicode_paragraph(doc, f'参会人员:{recording.participants}')
+ if recording.tags:
+ tags_str = ', '.join([tag.name for tag in recording.tags])
+ add_unicode_paragraph(doc, f'标签:{tags_str}')
+ doc.add_paragraph('')
+
+ process_markdown_to_docx(doc, recording.notes)
+
+ filename = build_docx_download_filename('会议笔记', recording, recording_id)
+ return save_and_send_docx(doc, filename, f'notes_{recording_id}.docx')
diff --git a/speakr/src/api/search.py b/speakr/src/api/search.py
new file mode 100644
index 0000000..13df015
--- /dev/null
+++ b/speakr/src/api/search.py
@@ -0,0 +1,793 @@
+# 搜索和 Inquire 蓝图
+# 处理搜索、Inquire 会话、语义搜索、聊天等相关功能
+
+from flask import Blueprint, request, jsonify, render_template, redirect, flash, Response, current_app
+from flask_login import login_required, current_user
+import os
+import json
+import re
+from datetime import datetime, timedelta
+
+from src.clients import db
+from src.models import (
+ User, Tag, RecordingTag, Recording, TranscriptChunk, InquireSession,
+ SystemSetting
+)
+from src.services import (
+ call_llm_completion,
+ process_streaming_with_thinking,
+ semantic_search_chunks,
+ auto_login,
+)
+
+# 创建搜索蓝图
+search_bp = Blueprint('search', __name__)
+
+# 全局变量(从 app.py 导入)
+limiter = None
+csrf = None
+ENABLE_INQUIRE_MODE = None
+USE_ASR_ENDPOINT = None
+
+def init_search_bp(limiter_instance, csrf_instance, enable_inquire, use_asr_endpoint):
+ """初始化蓝图所需的全局变量"""
+ global limiter, csrf, ENABLE_INQUIRE_MODE, USE_ASR_ENDPOINT
+ limiter = limiter_instance
+ csrf = csrf_instance
+ ENABLE_INQUIRE_MODE = enable_inquire
+ USE_ASR_ENDPOINT = use_asr_endpoint
+
+ # 应用路由级限流(在 init 中应用,避免模块导入时 limiter 为 None)
+ # 收件箱查询限流:每分钟最多30次
+ search_bp.view_functions['get_inbox_recordings'] = limiter.limit("30 per minute")(get_inbox_recordings)
+
+
+@search_bp.route('/inquire')
+def inquire():
+ """Inquire 页面路由"""
+ # 如果尚未认证,自动登录
+ if not current_user.is_authenticated:
+ auto_login()
+
+ # 检查询问模式是否启用
+ if not ENABLE_INQUIRE_MODE:
+ flash('询问模式未在此服务器上启用。', 'warning')
+ return redirect('/tool/speakr')
+ # 使用用户上下文渲染询问页面以支持主题
+ return render_template('inquire.html', use_asr_endpoint=USE_ASR_ENDPOINT, current_user=current_user)
+
+
+@search_bp.route('/api/inbox_recordings', methods=['GET'])
+@login_required
+def get_inbox_recordings():
+ """获取收件箱中正在处理的录音"""
+ try:
+ stmt = db.select(Recording).where(
+ Recording.user_id == current_user.id,
+ Recording.is_inbox == True,
+ Recording.status.in_(['PENDING', 'PROCESSING', 'SUMMARIZING'])
+ ).order_by(Recording.created_at.desc())
+
+ recordings = db.session.execute(stmt).scalars().all()
+ return jsonify([recording.to_dict() for recording in recordings])
+ except Exception as e:
+ current_app.logger.error(f"获取收件箱录音时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@search_bp.route('/api/inquire/sessions', methods=['GET'])
+@login_required
+def get_inquire_sessions():
+ """获取当前用户的所有 Inquire 会话"""
+ if not ENABLE_INQUIRE_MODE:
+ return jsonify({'error': 'Inquire 模式未启用'}), 403
+ try:
+ sessions = InquireSession.query.filter_by(user_id=current_user.id).order_by(InquireSession.last_used.desc()).all()
+ return jsonify([session.to_dict() for session in sessions])
+ except Exception as e:
+ current_app.logger.error(f"获取 Inquire 会话时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@search_bp.route('/api/inquire/sessions', methods=['POST'])
+@login_required
+def create_inquire_session():
+ """创建新的 Inquire 会话并附带筛选条件"""
+ if not ENABLE_INQUIRE_MODE:
+ return jsonify({'error': 'Inquire 模式未启用'}), 403
+ try:
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供数据'}), 400
+
+ session = InquireSession(
+ user_id=current_user.id,
+ session_name=data.get('session_name'),
+ filter_tags=json.dumps(data.get('filter_tags', [])),
+ filter_speakers=json.dumps(data.get('filter_speakers', [])),
+ filter_date_from=datetime.fromisoformat(data['filter_date_from']).date() if data.get('filter_date_from') else None,
+ filter_date_to=datetime.fromisoformat(data['filter_date_to']).date() if data.get('filter_date_to') else None,
+ filter_recording_ids=json.dumps(data.get('filter_recording_ids', []))
+ )
+
+ db.session.add(session)
+ db.session.commit()
+
+ return jsonify(session.to_dict()), 201
+
+ except Exception as e:
+ current_app.logger.error(f"创建 Inquire 会话时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@search_bp.route('/api/inquire/search', methods=['POST'])
+@login_required
+def inquire_search():
+ """在筛选后的转录内容中执行语义搜索"""
+ if not ENABLE_INQUIRE_MODE:
+ return jsonify({'error': 'Inquire 模式未启用'}), 403
+ try:
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供数据'}), 400
+
+ query = data.get('query')
+ if not query:
+ return jsonify({'error': '未提供查询'}), 400
+
+ # 从请求中构建筛选条件
+ filters = {}
+ if data.get('filter_tags'):
+ filters['tag_ids'] = data['filter_tags']
+ if data.get('filter_speakers'):
+ filters['speaker_names'] = data['filter_speakers']
+ if data.get('filter_recording_ids'):
+ filters['recording_ids'] = data['filter_recording_ids']
+ if data.get('filter_date_from'):
+ filters['date_from'] = datetime.fromisoformat(data['filter_date_from']).date()
+ if data.get('filter_date_to'):
+ filters['date_to'] = datetime.fromisoformat(data['filter_date_to']).date()
+
+ # 执行语义搜索
+ top_k = data.get('top_k', 5)
+ chunk_results = semantic_search_chunks(current_user.id, query, filters, top_k)
+
+ # 格式化结果
+ results = []
+ for chunk, similarity in chunk_results:
+ result = chunk.to_dict()
+ result['similarity'] = similarity
+ result['recording_title'] = chunk.recording.title
+ result['recording_meeting_date'] = f"{chunk.recording.meeting_date.isoformat()}T00:00:00" if chunk.recording.meeting_date else None
+ results.append(result)
+
+ return jsonify({'results': results})
+
+ except Exception as e:
+ current_app.logger.error(f"Inquire 搜索时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@search_bp.route('/api/inquire/chat', methods=['POST'])
+@login_required
+def inquire_chat():
+ """使用 RAG 与筛选后的转录内容进行聊天"""
+ if not ENABLE_INQUIRE_MODE:
+ return jsonify({'error': 'Inquire 模式未启用'}), 403
+ try:
+ data = request.json
+ if not data:
+ return jsonify({'error': '未提供数据'}), 400
+
+ user_message = data.get('message')
+ message_history = data.get('message_history', [])
+
+ if not user_message:
+ return jsonify({'error': '未提供消息'}), 400
+
+ # 检查 OpenRouter 客户端是否可用
+ from src.clients import client
+ if client is None:
+ return jsonify({'error': '聊天服务不可用(OpenRouter 客户端未配置)'}), 503
+
+ # 从请求中构建筛选条件
+ filters = {}
+ if data.get('filter_tags'):
+ filters['tag_ids'] = data['filter_tags']
+ if data.get('filter_speakers'):
+ filters['speaker_names'] = data['filter_speakers']
+ if data.get('filter_recording_ids'):
+ filters['recording_ids'] = data['filter_recording_ids']
+ if data.get('filter_date_from'):
+ filters['date_from'] = datetime.fromisoformat(data['filter_date_from']).date()
+ if data.get('filter_date_to'):
+ filters['date_to'] = datetime.fromisoformat(data['filter_date_to']).date()
+
+ # 调试日志
+ current_app.logger.info(f"Inquire 聊天 - 用户: {current_user.username}, 查询: '{user_message}', 筛选条件: {filters}")
+
+ # 在生成器之前捕获用户上下文,避免 current_user 为 None
+ user_id = current_user.id
+ user_name = current_user.name if current_user.name else "用户"
+ user_title = current_user.job_title if current_user.job_title else "专业人士"
+ user_company = current_user.company if current_user.company else "其组织"
+ user_output_language = current_user.output_language if current_user.output_language else None
+
+ # 增强的查询处理与丰富化和调试
+ def create_status_response(status, message):
+ """辅助函数,用于创建 SSE 状态更新"""
+ return f"data: {json.dumps({'status': status, 'message': message})}\n\n"
+
+ def generate_enhanced_chat():
+ # 显式引用外部作用域变量
+ nonlocal user_id, user_name, user_title, user_company, user_output_language, data, filters
+
+ try:
+ # 发送初始状态
+ yield create_status_response('processing', '正在分析您的查询...')
+
+ # 步骤 1:路由 - 判断是否需要 RAG 查找
+ router_prompt = f"""分析此用户查询,判断是否需要搜索转录内容,还是简单的格式化/澄清请求。
+
+用户查询: "{user_message}"
+
+如果查询需要搜索转录内容(询问对话、录音中的特定信息),仅回复 "RAG"。
+如果是格式化请求、关于之前回复的澄清,或不需要搜索转录内容,仅回复 "DIRECT"。
+
+示例:
+- "Beth 对预算有什么看法?" → RAG
+- "你能用不同的标题格式化吗?" → DIRECT
+- "谁提到了时间表?" → RAG
+- "让它更有条理一些" → DIRECT"""
+
+ try:
+ router_response = call_llm_completion(
+ messages=[
+ {"role": "system", "content": "你是查询路由器。仅回复 'RAG' 或 'DIRECT'。"},
+ {"role": "user", "content": router_prompt}
+ ],
+ temperature=0.1,
+ max_tokens=10
+ )
+
+ route_decision = router_response.choices[0].message.content.strip().upper()
+ current_app.logger.info(f"路由决策: {route_decision}")
+
+ if route_decision == "DIRECT":
+ # 直接回复,无需 RAG 查找
+ yield create_status_response('responding', '正在生成直接回复...')
+
+ direct_prompt = f"""你正在协助 {user_name}。使用适当的 markdown 格式回复他们的请求。
+
+用户请求: "{user_message}"
+
+之前的对话上下文(如果相关):
+{json.dumps(message_history[-2:] if message_history else [])}
+
+使用适当的 markdown 格式,包括标题 (##)、粗体 (**文本**)、列表 (-) 等。"""
+
+ stream = call_llm_completion(
+ messages=[
+ {"role": "system", "content": direct_prompt},
+ {"role": "user", "content": user_message}
+ ],
+ temperature=0.7,
+ max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")),
+ stream=True
+ )
+
+ # 使用辅助函数处理流式输出,支持思考标签
+ for response in process_streaming_with_thinking(stream):
+ yield response
+ return
+
+ except Exception as e:
+ current_app.logger.warning(f"路由失败,默认使用 RAG: {e}")
+
+ # 步骤 2:查询丰富化 - 根据用户意图生成更好的搜索词
+ yield create_status_response('enriching', '正在丰富化搜索查询...')
+
+ # 使用捕获的用户上下文生成个性化搜索词
+ enrichment_prompt = f"""你是查询增强助手。根据用户关于转录会议/录音的问题,生成 3-5 个替代搜索词或短语,以帮助在语义搜索系统中找到相关内容。
+
+用户上下文:
+- 姓名: {user_name}
+- 职位: {user_title}
+- 公司: {user_company}
+
+用户问题: "{user_message}"
+可用上下文: 转录的会议和录音,说话人: {', '.join(data.get('filter_speakers', []))}。
+
+生成能够找到相关内容的搜索词。重点关注:
+1. 使用用户实际姓名的关键概念和主题,而不是"我"等通用词
+2. 其专业上下文中可能使用的特定术语
+3. 使用正确姓名的问题替代措辞
+4. 可能出现在其会议转录中的相关术语
+
+示例:
+- 不使用"Beth 告诉我什么",而是使用"Beth 告诉 {user_name} 什么"
+- 不使用"我上次对话",而是使用"{user_name} 的对话"
+- 在相关时使用其职位和公司上下文
+
+仅回复 JSON 数组字符串: ["term1", "term2", "term3", ...]"""
+
+ try:
+ enrichment_response = call_llm_completion(
+ messages=[
+ {"role": "system", "content": "你是查询增强助手。仅回复有效的 JSON 字符串数组。"},
+ {"role": "user", "content": enrichment_prompt}
+ ],
+ temperature=0.3,
+ max_tokens=200
+ )
+
+ enriched_terms = json.loads(enrichment_response.choices[0].message.content.strip())
+ current_app.logger.info(f"丰富化的搜索词: {enriched_terms}")
+
+ # 将原始查询与丰富化词结合用于搜索
+ search_queries = [user_message] + enriched_terms[:3] # 使用原始 + 前 3 个丰富化词
+
+ except Exception as e:
+ current_app.logger.warning(f"查询丰富化失败,使用原始查询: {e}")
+ search_queries = [user_message]
+
+ # 步骤 2:使用多个查询进行语义搜索
+ yield create_status_response('searching', '正在搜索转录内容...')
+
+ all_chunks = []
+ seen_chunk_ids = set()
+
+ for query in search_queries:
+ with current_app.app_context():
+ chunk_results = semantic_search_chunks(user_id, query, filters, 8)
+ current_app.logger.info(f"搜索查询 '{query}' 返回 {len(chunk_results)} 个片段")
+
+ for chunk, similarity in chunk_results:
+ if chunk and chunk.id not in seen_chunk_ids:
+ all_chunks.append((chunk, similarity))
+ seen_chunk_ids.add(chunk.id)
+
+ # 按相似度排序并取前 N 个结果
+ all_chunks.sort(key=lambda x: x[1], reverse=True)
+ chunk_results = all_chunks[:data.get('context_chunks', 8)]
+
+ current_app.logger.info(f"最终片段结果: {len(chunk_results)} 个片段,相似度: {[f'{s:.3f}' for _, s in chunk_results]}")
+
+ # 步骤 2.5:自动检测提及的说话人并应用筛选条件(如需要)
+ with current_app.app_context():
+ # 获取可用说话人
+ recordings_with_participants = Recording.query.filter_by(user_id=user_id).filter(
+ Recording.participants.isnot(None),
+ Recording.participants != ''
+ ).all()
+
+ available_speakers = set()
+ for recording in recordings_with_participants:
+ if recording.participants:
+ participants = [p.strip() for p in recording.participants.split(',') if p.strip()]
+ available_speakers.update(participants)
+
+ # 检查是否有说话人在用户查询中被提及但未出现在结果中
+ mentioned_speakers = []
+ for speaker in available_speakers:
+ if speaker.lower() in user_message.lower():
+ # 检查此说话人是否出现在当前片段结果中
+ speaker_in_results = False
+ for chunk, _ in chunk_results:
+ if chunk and (
+ (chunk.speaker_name and speaker.lower() in chunk.speaker_name.lower()) or
+ (chunk.recording and chunk.recording.participants and speaker.lower() in chunk.recording.participants.lower())
+ ):
+ speaker_in_results = True
+ break
+
+ if not speaker_in_results:
+ mentioned_speakers.append(speaker)
+
+ # 如果发现提及的说话人不在结果中,自动应用说话人筛选条件
+ if mentioned_speakers and not data.get('filter_speakers'): # 仅在未应用说话人筛选条件时
+ current_app.logger.info(f"自动检测到提及的说话人不在结果中: {mentioned_speakers}")
+ yield create_status_response('filtering', f'检测到提及 {", ".join(mentioned_speakers)},正在应用说话人筛选条件...')
+
+ # 应用自动说话人筛选条件
+ auto_filters = filters.copy()
+ auto_filters['speaker_names'] = mentioned_speakers
+
+ # 使用说话人筛选条件重新运行语义搜索
+ auto_filtered_chunks = []
+ auto_filtered_seen_ids = set()
+
+ for query in search_queries:
+ with current_app.app_context():
+ auto_filtered_results = semantic_search_chunks(user_id, query, auto_filters, data.get('context_chunks', 8))
+ current_app.logger.info(f"使用说话人 {mentioned_speakers} 的自动筛选搜索 '{query}' 返回 {len(auto_filtered_results)} 个片段")
+
+ for chunk, similarity in auto_filtered_results:
+ if chunk and chunk.id not in auto_filtered_seen_ids:
+ auto_filtered_chunks.append((chunk, similarity))
+ auto_filtered_seen_ids.add(chunk.id)
+
+ # 如果自动筛选找到更好的结果,使用它们
+ if len(auto_filtered_chunks) > 0:
+ auto_filtered_chunks.sort(key=lambda x: x[1], reverse=True)
+ chunk_results = auto_filtered_chunks[:data.get('context_chunks', 8)]
+ current_app.logger.info(f"自动说话人筛选找到 {len(chunk_results)} 个相关片段,使用筛选结果")
+ filters = auto_filters # 更新上下文筛选条件
+
+ # 步骤 3:评估结果,如需要则重新查询
+ if len(chunk_results) < 2: # 如果结果很少,尝试更广泛的搜索
+ yield create_status_response('requerying', '正在扩大搜索范围...')
+
+ # 如果应用了说话人筛选条件,尝试不使用
+ broader_filters = filters.copy()
+ if 'speaker_names' in broader_filters:
+ del broader_filters['speaker_names']
+ current_app.logger.info("正在重试搜索,不使用说话人筛选条件...")
+
+ for query in search_queries:
+ with current_app.app_context():
+ chunk_results_broader = semantic_search_chunks(user_id, query, broader_filters, 6)
+ for chunk, similarity in chunk_results_broader:
+ if chunk and chunk.id not in seen_chunk_ids:
+ all_chunks.append((chunk, similarity))
+ seen_chunk_ids.add(chunk.id)
+
+ # 重新排序并限制
+ all_chunks.sort(key=lambda x: x[1], reverse=True)
+ chunk_results = all_chunks[:data.get('context_chunks', 8)]
+ current_app.logger.info(f"更广泛的搜索返回 {len(chunk_results)} 个片段")
+
+ # 从检索到的片段构建上下文
+ yield create_status_response('contextualizing', '正在构建上下文...')
+
+ # 按录音分组片段并合理组织
+ recording_chunks = {}
+ recording_ids_in_context = set()
+
+ for chunk, similarity in chunk_results:
+ if not chunk or not chunk.recording:
+ continue
+ recording_id = chunk.recording.id
+ recording_ids_in_context.add(recording_id)
+
+ if recording_id not in recording_chunks:
+ recording_chunks[recording_id] = {
+ 'recording': chunk.recording,
+ 'chunks': []
+ }
+
+ recording_chunks[recording_id]['chunks'].append({
+ 'chunk': chunk,
+ 'similarity': similarity
+ })
+
+ # 构建有组织的上下文片段
+ context_pieces = []
+
+ for recording_id, data in recording_chunks.items():
+ recording = data['recording']
+ chunks = data['chunks']
+
+ # 按索引排序片段以保持时间顺序
+ chunks.sort(key=lambda x: x['chunk'].chunk_index)
+
+ # 使用完整元数据构建录音标题
+ header = f"=== {recording.title} [录音 ID: {recording_id}] ==="
+ if recording.meeting_date:
+ header += f" ({recording.meeting_date})"
+
+ # 添加参与者信息
+ if recording.participants:
+ participants_list = [p.strip() for p in recording.participants.split(',') if p.strip()]
+ header += f"\n参与者: {', '.join(participants_list)}"
+
+ context_piece = header + "\n\n"
+
+ # 处理片段并检测非连续性
+ prev_chunk_index = None
+ for chunk_data in chunks:
+ chunk = chunk_data['chunk']
+ similarity = chunk_data['similarity']
+
+ # 检查非连续性
+ if prev_chunk_index is not None and chunk.chunk_index != prev_chunk_index + 1:
+ context_piece += "\n[... 转录中的间隔 - 非连续片段 ...]\n\n"
+
+ # 如果可用,添加说话人信息
+ speaker_info = ""
+ if chunk.speaker_name:
+ speaker_info = f"{chunk.speaker_name}: "
+ elif chunk.start_time is not None:
+ speaker_info = f"[{chunk.start_time:.1f}s]: "
+
+ # 如果可用,添加时间信息
+ timing_info = ""
+ if chunk.start_time is not None and chunk.end_time is not None:
+ timing_info = f" [{chunk.start_time:.1f}s-{chunk.end_time:.1f}s]"
+
+ context_piece += f"{speaker_info}{chunk.content}{timing_info} (相似度: {similarity:.3f})\n\n"
+ prev_chunk_index = chunk.chunk_index
+
+ context_pieces.append(context_piece)
+
+ current_app.logger.info(f"从 {len(chunk_results)} 个片段(跨 {len(recording_chunks)} 个录音)构建上下文")
+
+ # 生成回复
+ yield create_status_response('responding', '正在生成回复...')
+
+ # 准备系统提示
+ language_instruction = f"请使用 {user_output_language} 提供所有回复。" if user_output_language else ""
+
+ # 构建筛选条件描述以提供上下文
+ filter_description = []
+ with current_app.app_context():
+ if data.get('filter_tags'):
+ tag_names = [tag.name for tag in Tag.query.filter(Tag.id.in_(data['filter_tags'])).all()]
+ filter_description.append(f"标签: {', '.join(tag_names)}")
+ if data.get('filter_speakers'):
+ filter_description.append(f"说话人: {', '.join(data['filter_speakers'])}")
+ if data.get('filter_date_from') or data.get('filter_date_to'):
+ date_range = []
+ if data.get('filter_date_from'):
+ date_range.append(f"从 {data['filter_date_from']}")
+ if data.get('filter_date_to'):
+ date_range.append(f"到 {data['filter_date_to']}")
+ filter_description.append(f"日期: {' '.join(date_range)}")
+
+ filter_text = f" (按 {'; '.join(filter_description)} 筛选)" if filter_description else ""
+
+ context_text = "\n\n".join(context_pieces) if context_pieces else "未找到相关上下文。"
+
+ # 获取转录长度限制设置和可用说话人
+ with current_app.app_context():
+ transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000)
+
+ # 获取此用户的所有可用说话人
+ recordings_with_participants = Recording.query.filter_by(user_id=user_id).filter(
+ Recording.participants.isnot(None),
+ Recording.participants != ''
+ ).all()
+
+ available_speakers = set()
+ for recording in recordings_with_participants:
+ if recording.participants:
+ participants = [p.strip() for p in recording.participants.split(',') if p.strip()]
+ available_speakers.update(participants)
+
+ available_speakers = sorted(list(available_speakers))
+
+ system_prompt = f"""你是一名专业的会议和音频转录分析师,协助 {user_name},他是 {user_company} 的 {user_title}。{language_instruction}
+
+你正在分析来自多个录音的转录内容{filter_text}。以下上下文已根据与用户问题的语义相似性检索到:
+
+<>
+{context_text}
+<>
+
+系统已自动分析你的查询并从转录内容中检索到最相关的上下文。搜索从 {len(recording_ids_in_context)} 个录音返回了 {len(chunk_results)} 个片段。
+
+**录音中的可用说话人**: {', '.join(available_speakers) if available_speakers else '无可用'}
+
+**上下文中的录音 ID**: {list(recording_ids_in_context)}
+
+重要格式说明:
+你必须在回复中使用适当的 markdown 格式。按以下方式组织回复:
+
+1. **始终使用 markdown 语法** - 使用 `#`、`##`、`###` 表示标题,`**粗体**`、`*斜体*`、`-` 表示列表等。
+2. 以简短的摘要或前言开始(如果有帮助)
+3. 使用清晰的 markdown 标题按来源转录组织信息
+4. 使用格式: `## [录音标题] - [日期(如果可用)]`
+5. 在每个标题下,使用列表和格式提供来自该特定录音的相关信息
+6. 在提及特定陈述时使用 **粗体** 格式包含说话人姓名
+7. 使用列表 (`-`) 和子列表清晰地组织信息
+
+**所需结构示例:**
+带有 **关键点** 高亮的简短摘要...
+
+## 项目实施会议讨论 - 2024-06-18
+- **说话人 A** 提到"实施需要大量支持"
+- **说话人 B** 确认了与技术团队的即将举行的会议
+- 讨论的关键主题:
+ - 预算规划注意事项
+ - 时间表协调需求
+
+## 预算规划会议 - 2024-05-30
+- **说话人 A** 审查了预算文档
+- **说话人 C** 将批准提交的最终版本
+- 重要细节:
+ - 预算约占项目总额的 1/3
+ - 需要协调即将发生的里程碑
+
+按会议从最近到最早的顺序组织回复。始终使用适当的 markdown 格式,并按来源录音组织以获得最大清晰度和可读性。"""
+
+ # 准备消息数组
+ messages = [{"role": "system", "content": system_prompt}]
+ if message_history:
+ messages.extend(message_history)
+ messages.append({"role": "user", "content": user_message})
+
+ # 启用流式输出
+ stream = call_llm_completion(
+ messages=messages,
+ temperature=0.7,
+ max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")),
+ stream=True
+ )
+
+ # 缓冲内容以检测完整转录请求
+ response_buffer = ""
+ content_buffer = ""
+ in_thinking = False
+ thinking_buffer = ""
+
+ for chunk in stream:
+ content = chunk.choices[0].delta.content
+ if content:
+ response_buffer += content
+ content_buffer += content
+
+ # 检查是否是完整转录请求
+ if response_buffer.strip().startswith("REQUEST_FULL_TRANSCRIPT:"):
+ lines = response_buffer.split('\n')
+ request_line = lines[0].strip()
+
+ if ':' in request_line:
+ try:
+ recording_id = int(request_line.split(':')[1])
+ current_app.logger.info(f"代理请求录音 {recording_id} 的完整转录")
+
+ # 获取完整转录
+ yield create_status_response('fetching', f'正在获取录音 {recording_id} 的完整转录...')
+
+ with current_app.app_context():
+ recording = db.session.get(Recording, recording_id)
+ if recording and recording.user_id == user_id and recording.transcription:
+ # 应用转录长度限制
+ if transcript_limit == -1:
+ full_transcript = recording.transcription
+ else:
+ full_transcript = recording.transcription[:transcript_limit]
+
+ # 将完整转录添加到上下文
+ full_context = f"{context_text}\n\n<<完整转录 - {recording.title}>>\n{full_transcript}\n<<结束完整转录>>"
+
+ # 使用完整转录更新系统提示
+ updated_system_prompt = system_prompt.replace(
+ f"<>\n{context_text}\n<>",
+ f"<>\n{full_context}\n<>"
+ )
+
+ # 创建带有更新上下文的新消息数组
+ updated_messages = [{"role": "system", "content": updated_system_prompt}]
+ if message_history:
+ updated_messages.extend(message_history)
+ updated_messages.append({"role": "user", "content": user_message})
+
+ # 使用完整上下文生成新回复
+ yield create_status_response('responding', '正在分析完整转录...')
+
+ new_stream = call_llm_completion(
+ messages=updated_messages,
+ temperature=0.7,
+ max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")),
+ stream=True
+ )
+
+ # 使用辅助函数处理流式输出,支持思考标签
+ for response in process_streaming_with_thinking(new_stream):
+ yield response
+ return
+ else:
+ # 录音未找到或无权限
+ error_msg = f"\n\n错误: 无法访问录音 {recording_id} 的完整转录。录音可能不存在或你可能没有权限。"
+ yield f"data: {json.dumps({'delta': error_msg})}\n\n"
+ yield f"data: {json.dumps({'end_of_stream': True})}\n\n"
+ return
+
+ except (ValueError, IndexError):
+ current_app.logger.warning(f"无效的转录请求格式: {request_line}")
+ # 继续正常流式输出
+ pass
+
+ # 处理缓冲以检测和处理思考标签
+ while True:
+ if not in_thinking:
+ # 寻找开始思考标签
+ think_start = re.search(r'', content_buffer, re.IGNORECASE)
+ if think_start:
+ # 发送思考标签之前的任何内容
+ before_thinking = content_buffer[:think_start.start()]
+ if before_thinking:
+ yield f"data: {json.dumps({'delta': before_thinking})}\n\n"
+
+ # 开始捕获思考内容
+ in_thinking = True
+ content_buffer = content_buffer[think_start.end():]
+ thinking_buffer = ""
+ else:
+ # 未找到思考标签,发送累积内容
+ if content_buffer:
+ yield f"data: {json.dumps({'delta': content_buffer})}\n\n"
+ content_buffer = ""
+ break
+ else:
+ # 我们在思考标签内,寻找结束标签
+ think_end = re.search(r' ', content_buffer, re.IGNORECASE)
+ if think_end:
+ # 捕获思考内容直到结束标签
+ thinking_buffer += content_buffer[:think_end.start()]
+
+ # 将思考内容作为特殊类型发送
+ if thinking_buffer.strip():
+ yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n"
+
+ # 在结束标签后继续处理
+ in_thinking = False
+ content_buffer = content_buffer[think_end.end():]
+ thinking_buffer = ""
+ else:
+ # 仍在思考标签内,累积内容
+ thinking_buffer += content_buffer
+ content_buffer = ""
+ break
+
+ # 处理任何剩余内容
+ if in_thinking and thinking_buffer:
+ # 未关闭的思考标签 - 作为思考内容发送
+ yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n"
+ elif content_buffer:
+ # 常规内容
+ yield f"data: {json.dumps({'delta': content_buffer})}\n\n"
+
+ yield f"data: {json.dumps({'end_of_stream': True})}\n\n"
+
+ except Exception as e:
+ current_app.logger.error(f"增强聊天生成时出错: {e}")
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
+
+ return Response(generate_enhanced_chat(), mimetype='text/event-stream')
+
+ except Exception as e:
+ current_app.logger.error(f"Inquire 聊天端点出错: {str(e)}")
+ return jsonify({'error': str(e)}), 500
+
+
+@search_bp.route('/api/inquire/available_filters', methods=['GET'])
+@login_required
+def get_available_filters():
+ """获取用户的可用筛选选项"""
+ if not ENABLE_INQUIRE_MODE:
+ return jsonify({'error': 'Inquire 模式未启用'}), 403
+ try:
+ # 获取用户的标签
+ tags = Tag.query.filter_by(user_id=current_user.id).all()
+
+ # 从用户的录音参与者字段获取唯一说话人
+ recordings_with_participants = Recording.query.filter_by(user_id=current_user.id).filter(
+ Recording.participants.isnot(None),
+ Recording.participants != ''
+ ).all()
+
+ speaker_names = set()
+ for recording in recordings_with_participants:
+ if recording.participants:
+ # 按逗号分割参与者并清理
+ participants = [p.strip() for p in recording.participants.split(',') if p.strip()]
+ speaker_names.update(participants)
+
+ speaker_names = sorted(list(speaker_names))
+
+ # 获取用户的录音用于录音特定筛选
+ recordings = Recording.query.filter_by(user_id=current_user.id).filter(
+ Recording.status == 'COMPLETED'
+ ).order_by(Recording.created_at.desc()).all()
+
+ return jsonify({
+ 'tags': [tag.to_dict() for tag in tags],
+ 'speakers': speaker_names,
+ 'recordings': [{'id': r.id, 'title': r.title, 'meeting_date': f"{r.meeting_date.isoformat()}T00:00:00" if r.meeting_date else None} for r in recordings]
+ })
+
+ except Exception as e:
+ current_app.logger.error(f"获取可用筛选条件时出错: {e}")
+ return jsonify({'error': str(e)}), 500
diff --git a/speakr/src/api/share.py b/speakr/src/api/share.py
new file mode 100644
index 0000000..4b970b5
--- /dev/null
+++ b/speakr/src/api/share.py
@@ -0,0 +1,194 @@
+# 分享蓝图
+# 处理分享链接的创建、查看和管理
+
+import os
+from flask import Blueprint, render_template, request, jsonify, send_file, url_for, current_app
+from flask_login import login_required, current_user
+from src.clients import db
+from src.models import Share, Recording
+from src.utils.markdown import md_to_html
+
+# 创建分享蓝图
+share_bp = Blueprint('share', __name__)
+
+# 全局变量
+csrf = None
+
+def init_share_bp(csrf_instance):
+ """初始化蓝图所需的全局变量"""
+ global csrf
+ csrf = csrf_instance
+
+
+@share_bp.route('/share/', methods=['GET'])
+def view_shared_recording(public_id):
+ """查看分享的录音"""
+ share = Share.query.filter_by(public_id=public_id).first_or_404()
+ recording = share.recording
+
+ # 为公共视图创建有限的字典
+ recording_data = {
+ 'id': recording.id,
+ 'public_id': share.public_id,
+ 'title': recording.title,
+ 'participants': recording.participants,
+ 'transcription': recording.transcription,
+ 'summary': md_to_html(recording.summary) if share.share_summary else None,
+ 'notes': md_to_html(recording.notes) if share.share_notes else None,
+ 'meeting_date': f"{recording.meeting_date.isoformat()}T00:00:00" if recording.meeting_date else None,
+ 'mime_type': recording.mime_type
+ }
+
+ return render_template('share.html', recording=recording_data)
+
+
+@share_bp.route('/api/recording//share', methods=['GET'])
+@login_required
+def get_existing_share(recording_id):
+ """检查录音是否已存在分享链接"""
+ recording = db.session.get(Recording, recording_id)
+ if not recording or recording.user_id != current_user.id:
+ return jsonify({'error': '录音未找到或您没有权限查看'}), 404
+
+ existing_share = Share.query.filter_by(
+ recording_id=recording.id,
+ user_id=current_user.id
+ ).order_by(Share.created_at.desc()).first()
+
+ if existing_share:
+ relative_path = url_for('share.view_shared_recording', public_id=existing_share.public_id)
+ # 获取纯主机名 (去掉 request.host 可能自带的端口)
+ config_port = os.environ.get('EXTERNAL_PORT', '8083')
+ hostname = request.host.split(':')[0]
+ # 拼接完整链接
+ share_url = f"{request.scheme}://{hostname}:{config_port}/tool/speakr{relative_path}"
+ return jsonify({
+ 'success': True,
+ 'exists': True,
+ 'share_url': share_url,
+ 'share': existing_share.to_dict()
+ }), 200
+ else:
+ return jsonify({
+ 'success': True,
+ 'exists': False
+ }), 200
+
+
+@share_bp.route('/api/recording//share', methods=['POST'])
+@login_required
+def create_share(recording_id):
+ """创建新的分享链接"""
+ if not request.is_secure:
+ return jsonify({'error': '分享功能仅在安全连接 (HTTPS) 下可用'}), 403
+
+ recording = db.session.get(Recording, recording_id)
+ if not recording or recording.user_id != current_user.id:
+ return jsonify({'error': '录音未找到或您没有权限分享'}), 404
+
+ data = request.json
+ share_summary = data.get('share_summary', True)
+ share_notes = data.get('share_notes', True)
+ force_new = data.get('force_new', False)
+
+ # 检查此录音是否已存在任何分享
+ # 如果有多个,获取最近的一个
+ existing_share = Share.query.filter_by(
+ recording_id=recording.id,
+ user_id=current_user.id
+ ).order_by(Share.created_at.desc()).first()
+
+ if existing_share and not force_new:
+ # 如果分享权限发生变化,更新它们
+ if existing_share.share_summary != share_summary or existing_share.share_notes != share_notes:
+ existing_share.share_summary = share_summary
+ existing_share.share_notes = share_notes
+ db.session.commit()
+
+ # 返回现有分享信息
+ relative_path = url_for('share.view_shared_recording', public_id=existing_share.public_id)
+ config_port = os.environ.get('EXTERNAL_PORT', '8083')
+ hostname = request.host.split(':')[0]
+ # 拼接完整链接
+ share_url = f"{request.scheme}://{hostname}:{config_port}/tool/speakr{relative_path}"
+ return jsonify({
+ 'success': True,
+ 'share_url': share_url,
+ 'share': existing_share.to_dict(),
+ 'existing': True,
+ 'message': '使用此录音的现有分享链接'
+ }), 200
+
+ # 创建新分享(不存在现有分享或 force_new 为 True)
+ share = Share(
+ recording_id=recording.id,
+ user_id=current_user.id,
+ share_summary=share_summary,
+ share_notes=share_notes
+ )
+ db.session.add(share)
+ db.session.commit()
+
+ relative_path = url_for('share.view_shared_recording', public_id=share.public_id)
+ config_port = os.environ.get('EXTERNAL_PORT', '8083')
+ hostname = request.host.split(':')[0]
+ # 拼接完整链接
+ share_url = f"{request.scheme}://{hostname}:{config_port}/tool/speakr{relative_path}"
+
+ return jsonify({
+ 'success': True,
+ 'share_url': share_url,
+ 'share': share.to_dict(),
+ 'existing': False
+ }), 201
+
+
+@share_bp.route('/api/shares', methods=['GET'])
+@login_required
+def get_shares():
+ """获取当前用户的所有分享"""
+ shares = Share.query.filter_by(user_id=current_user.id).order_by(Share.created_at.desc()).all()
+ return jsonify([share.to_dict() for share in shares])
+
+
+@share_bp.route('/api/share/', methods=['PUT'])
+@login_required
+def update_share(share_id):
+ """更新分享权限"""
+ share = Share.query.filter_by(id=share_id, user_id=current_user.id).first_or_404()
+ data = request.json
+
+ if 'share_summary' in data:
+ share.share_summary = data['share_summary']
+ if 'share_notes' in data:
+ share.share_notes = data['share_notes']
+
+ db.session.commit()
+ return jsonify({'success': True, 'share': share.to_dict()})
+
+
+@share_bp.route('/api/share/', methods=['DELETE'])
+@login_required
+def delete_share(share_id):
+ """删除分享"""
+ share = Share.query.filter_by(id=share_id, user_id=current_user.id).first_or_404()
+ db.session.delete(share)
+ db.session.commit()
+ return jsonify({'success': True})
+
+
+@share_bp.route('/share/audio/')
+def get_shared_audio(public_id):
+ """获取分享的音频文件"""
+ try:
+ share = Share.query.filter_by(public_id=public_id).first_or_404()
+ recording = share.recording
+ if not recording or not recording.audio_path:
+ return jsonify({'error': '录音或音频文件未找到'}), 404
+ if not os.path.exists(recording.audio_path):
+ current_app.logger.error(f"音频文件在服务器上缺失:{recording.audio_path}")
+ return jsonify({'error': '音频文件在服务器上缺失'}), 404
+ return send_file(recording.audio_path)
+ except Exception as e:
+ current_app.logger.error(f"为 public_id {public_id} 提供分享音频时出错:{e}", exc_info=True)
+ return jsonify({'error': '发生意外错误'}), 500
diff --git a/speakr/src/api/speaker.py b/speakr/src/api/speaker.py
new file mode 100644
index 0000000..bc686c9
--- /dev/null
+++ b/speakr/src/api/speaker.py
@@ -0,0 +1,123 @@
+# 说话人蓝图
+# 处理说话人的增删改查和搜索
+
+from flask import Blueprint, request, jsonify, current_app
+from flask_login import login_required, current_user
+from datetime import datetime
+
+from src.clients import db
+from src.models import Speaker
+
+# 创建说话人蓝图
+speaker_bp = Blueprint('speaker', __name__)
+
+# 全局变量
+csrf = None
+
+def init_speaker_bp(csrf_instance):
+ """初始化蓝图所需的全局变量"""
+ global csrf
+ csrf = csrf_instance
+
+@speaker_bp.route('/speakers', methods=['GET'])
+@login_required
+def get_speakers():
+ """获取当前用户的所有说话人,按使用频率和最近使用时间排序"""
+ try:
+ speakers = Speaker.query.filter_by(user_id=current_user.id)\
+ .order_by(Speaker.use_count.desc(), Speaker.last_used.desc())\
+ .all()
+ return jsonify([speaker.to_dict() for speaker in speakers])
+ except Exception as e:
+ current_app.logger.error(f"获取说话人列表时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+@speaker_bp.route('/speakers/search', methods=['GET'])
+@login_required
+def search_speakers():
+ """按名称搜索说话人,用于自动补全功能"""
+ try:
+ query = request.args.get('q', '').strip()
+ if not query:
+ return jsonify([])
+
+ speakers = Speaker.query.filter_by(user_id=current_user.id)\
+ .filter(Speaker.name.ilike(f'%{query}%'))\
+ .order_by(Speaker.use_count.desc(), Speaker.last_used.desc())\
+ .limit(10)\
+ .all()
+
+ return jsonify([speaker.to_dict() for speaker in speakers])
+ except Exception as e:
+ current_app.logger.error(f"搜索说话人时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+@speaker_bp.route('/speakers', methods=['POST'])
+@login_required
+def create_speaker():
+ """创建新说话人或更新现有说话人"""
+ try:
+ data = request.json
+ name = data.get('name', '').strip()
+
+ if not name:
+ return jsonify({'error': '说话人名称为必填项'}), 400
+
+ # 检查当前用户是否已存在同名说话人
+ existing_speaker = Speaker.query.filter_by(user_id=current_user.id, name=name).first()
+
+ if existing_speaker:
+ # 更新使用统计
+ existing_speaker.use_count += 1
+ existing_speaker.last_used = datetime.utcnow()
+ db.session.commit()
+ return jsonify(existing_speaker.to_dict())
+ else:
+ # 创建新说话人
+ speaker = Speaker(
+ name=name,
+ user_id=current_user.id,
+ use_count=1,
+ created_at=datetime.utcnow(),
+ last_used=datetime.utcnow()
+ )
+ db.session.add(speaker)
+ db.session.commit()
+ return jsonify(speaker.to_dict()), 201
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"创建说话人时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+@speaker_bp.route('/speakers/', methods=['DELETE'])
+@login_required
+def delete_speaker(speaker_id):
+ """删除说话人"""
+ try:
+ speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first()
+ if not speaker:
+ return jsonify({'error': '说话人不存在'}), 404
+
+ db.session.delete(speaker)
+ db.session.commit()
+ return jsonify({'success': True})
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"删除说话人时出错: {e}")
+ return jsonify({'error': str(e)}), 500
+
+@speaker_bp.route('/speakers/delete_all', methods=['DELETE'])
+@login_required
+def delete_all_speakers():
+ """删除当前用户的所有说话人"""
+ try:
+ deleted_count = Speaker.query.filter_by(user_id=current_user.id).delete()
+ db.session.commit()
+ return jsonify({'success': True, 'deleted_count': deleted_count})
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"删除所有说话人时出错: {e}")
+ return jsonify({'error': str(e)}), 500
diff --git a/speakr/src/api/tag.py b/speakr/src/api/tag.py
new file mode 100644
index 0000000..05dd5ce
--- /dev/null
+++ b/speakr/src/api/tag.py
@@ -0,0 +1,247 @@
+# 标签蓝图
+# 处理标签的增删改查以及录音与标签的关联管理
+
+from flask import Blueprint, request, jsonify
+from flask_login import login_required, current_user
+from datetime import datetime
+
+from src.clients import db
+from src.models import Tag, RecordingTag, Recording
+
+# 创建标签蓝图
+tag_bp = Blueprint('tag', __name__)
+
+# 全局对象(从 app.py 导入)
+limiter = None
+csrf = None
+
+def init_tag_bp(limiter_instance, csrf_instance):
+ """初始化蓝图所需的全局对象"""
+ global limiter, csrf
+ limiter = limiter_instance
+ csrf = csrf_instance
+
+
+@tag_bp.route('/api/tags', methods=['GET'])
+@login_required
+def get_tags():
+ """获取当前用户的所有标签,包括全局标签和私有标签"""
+ try:
+ # 先查询全局标签(visibility='global')
+ global_tags = Tag.query.filter_by(visibility='global').order_by(Tag.name).all()
+
+ # 再查询当前用户的私有标签(visibility='private' 且 user_id=current_user.id)
+ private_tags = Tag.query.filter_by(
+ user_id=current_user.id,
+ visibility='private'
+ ).order_by(Tag.name).all()
+
+ # 合并结果
+ all_tags = global_tags + private_tags
+
+ return jsonify([tag.to_dict() for tag in all_tags])
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@tag_bp.route('/api/tags', methods=['POST'])
+@login_required
+def create_tag():
+ """创建新标签"""
+ data = request.get_json()
+
+ if not data or not data.get('name'):
+ return jsonify({'error': '标签名称是必需的'}), 400
+
+ visibility = data.get('visibility', 'private')
+
+ # 检查是否尝试创建全局标签
+ if visibility == 'global':
+ # 只有管理员可以创建全局标签
+ if not current_user.is_admin:
+ return jsonify({'error': '只有管理员可以创建全局标签'}), 403
+
+ # 检查全局标签名称是否已存在(全局标签名称必须唯一)
+ existing_global_tag = Tag.query.filter_by(
+ name=data['name'],
+ visibility='global'
+ ).first()
+ if existing_global_tag:
+ return jsonify({'error': '全局标签名称已存在'}), 400
+ else:
+ # 私有标签:检查同一用户下是否已存在相同名称的私有标签
+ existing_private_tag = Tag.query.filter_by(
+ name=data['name'],
+ user_id=current_user.id,
+ visibility='private'
+ ).first()
+ if existing_private_tag:
+ return jsonify({'error': '您已存在同名的私有标签'}), 400
+
+ tag = Tag(
+ name=data['name'],
+ user_id=current_user.id,
+ visibility=visibility,
+ color=data.get('color', '#3B82F6'),
+ custom_prompt=data.get('custom_prompt'),
+ default_language=data.get('default_language'),
+ default_min_speakers=data.get('default_min_speakers'),
+ default_max_speakers=data.get('default_max_speakers')
+ )
+
+ db.session.add(tag)
+ db.session.commit()
+
+ return jsonify(tag.to_dict()), 201
+
+
+@tag_bp.route('/api/tags/', methods=['PUT'])
+@login_required
+def update_tag(tag_id):
+ """更新标签"""
+ tag = Tag.query.filter_by(id=tag_id).first_or_404()
+ data = request.get_json()
+
+ # 权限检查
+ if tag.visibility == 'global':
+ # 只有管理员可以修改全局标签
+ if not current_user.is_admin:
+ return jsonify({'error': '只有管理员可以修改全局标签'}), 403
+ else:
+ # 私有标签:只有标签所有者可以修改
+ if tag.user_id != current_user.id:
+ return jsonify({'error': '您没有权限修改此标签'}), 403
+
+ if 'name' in data:
+ # 检查新名称是否冲突
+ if tag.visibility == 'global':
+ # 全局标签名称必须全局唯一
+ existing_tag = Tag.query.filter_by(
+ name=data['name'],
+ visibility='global'
+ ).filter(Tag.id != tag_id).first()
+ if existing_tag:
+ return jsonify({'error': '全局标签名称已存在'}), 400
+ else:
+ # 私有标签名称在用户内必须唯一
+ existing_tag = Tag.query.filter_by(
+ name=data['name'],
+ user_id=current_user.id,
+ visibility='private'
+ ).filter(Tag.id != tag_id).first()
+ if existing_tag:
+ return jsonify({'error': '您已存在同名的私有标签'}), 400
+ tag.name = data['name']
+
+ if 'visibility' in data:
+ new_visibility = data['visibility']
+ if new_visibility != tag.visibility:
+ # 只有管理员可以修改标签的可见性
+ if not current_user.is_admin:
+ return jsonify({'error': '只有管理员可以修改标签可见性'}), 403
+
+ if new_visibility == 'global':
+ # 检查全局标签名称是否已存在
+ existing_global_tag = Tag.query.filter_by(
+ name=tag.name,
+ visibility='global'
+ ).first()
+ if existing_global_tag:
+ return jsonify({'error': '全局标签名称已存在'}), 400
+ else:
+ # 全局改个人:检查个人标签名称是否冲突
+ # 注意:这里使用 current_user.id 作为目标用户ID
+ existing_private_tag = Tag.query.filter_by(
+ name=tag.name,
+ user_id=current_user.id,
+ visibility='private'
+ ).first()
+ if existing_private_tag:
+ return jsonify({'error': '您已存在同名的私有标签'}), 400
+
+ tag.visibility = new_visibility
+
+ if 'color' in data:
+ tag.color = data['color']
+ if 'custom_prompt' in data:
+ tag.custom_prompt = data['custom_prompt']
+ if 'default_language' in data:
+ tag.default_language = data['default_language']
+ if 'default_min_speakers' in data:
+ tag.default_min_speakers = data['default_min_speakers']
+ if 'default_max_speakers' in data:
+ tag.default_max_speakers = data['default_max_speakers']
+
+ tag.updated_at = datetime.utcnow()
+ db.session.commit()
+
+ return jsonify(tag.to_dict())
+
+
+@tag_bp.route('/api/tags/', methods=['DELETE'])
+@login_required
+def delete_tag(tag_id):
+ """删除标签"""
+ tag = Tag.query.filter_by(id=tag_id).first_or_404()
+
+ # 权限检查
+ if tag.visibility == 'global':
+ # 只有管理员可以删除全局标签
+ if not current_user.is_admin:
+ return jsonify({'error': '只有管理员可以删除全局标签'}), 403
+ else:
+ # 私有标签:只有标签所有者可以删除
+ if tag.user_id != current_user.id:
+ return jsonify({'error': '您没有权限删除此标签'}), 403
+
+ db.session.delete(tag)
+ db.session.commit()
+ return jsonify({'success': True})
+
+
+@tag_bp.route('/api/recordings//tags', methods=['POST'])
+@login_required
+def add_tag_to_recording(recording_id):
+ """为录音添加标签"""
+ recording = Recording.query.filter_by(id=recording_id, user_id=current_user.id).first_or_404()
+ data = request.get_json()
+
+ tag_id = data.get('tag_id')
+ if not tag_id:
+ return jsonify({'error': 'tag_id 是必需的'}), 400
+
+ tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first_or_404()
+
+ # 检查标签是否已与录音关联
+ existing_association = RecordingTag.query.filter_by(recording_id=recording_id, tag_id=tag_id).first()
+ if not existing_association:
+ # 获取当前录音的最大排序号
+ max_order = db.session.query(db.func.max(RecordingTag.order)).filter_by(recording_id=recording_id).scalar() or 0
+
+ # 创建新关联,设置正确的排序号
+ new_association = RecordingTag(
+ recording_id=recording_id,
+ tag_id=tag_id,
+ order=max_order + 1,
+ added_at=datetime.utcnow()
+ )
+ db.session.add(new_association)
+ db.session.commit()
+
+ return jsonify({'success': True, 'tags': [t.to_dict() for t in recording.tags]})
+
+
+@tag_bp.route('/api/recordings//tags/', methods=['DELETE'])
+@login_required
+def remove_tag_from_recording(recording_id, tag_id):
+ """从录音中移除标签"""
+ recording = Recording.query.filter_by(id=recording_id, user_id=current_user.id).first_or_404()
+ tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first_or_404()
+
+ # 查找并删除关联
+ association = RecordingTag.query.filter_by(recording_id=recording_id, tag_id=tag_id).first()
+ if association:
+ db.session.delete(association)
+ db.session.commit()
+
+ return jsonify({'success': True, 'tags': [t.to_dict() for t in recording.tags]})
diff --git a/speakr/src/api/template.py b/speakr/src/api/template.py
new file mode 100644
index 0000000..e403000
--- /dev/null
+++ b/speakr/src/api/template.py
@@ -0,0 +1,131 @@
+# 转录模板蓝图
+# 处理转录模板的增删改查
+
+from flask import Blueprint, request, jsonify, current_app
+from flask_login import login_required, current_user
+from src.clients import db
+from src.models import TranscriptTemplate
+
+# 创建蓝图
+template_bp = Blueprint('template', __name__)
+
+# 全局变量
+csrf = None
+
+def init_template_bp(csrf_instance):
+ """初始化蓝图所需的全局变量"""
+ global csrf
+ csrf = csrf_instance
+
+@template_bp.route('/api/transcript-templates', methods=['GET'])
+@login_required
+def get_transcript_templates():
+ """获取所有转录模板"""
+ templates = TranscriptTemplate.query.filter_by(user_id=current_user.id).all()
+ return jsonify([{
+ 'id': t.id,
+ 'name': t.name,
+ 'description': t.description,
+ 'template_content': t.template_content,
+ 'created_at': t.created_at.isoformat() if t.created_at else None,
+ 'updated_at': t.updated_at.isoformat() if t.updated_at else None
+ } for t in templates])
+
+@template_bp.route('/api/transcript-templates', methods=['POST'])
+@login_required
+def create_transcript_template():
+ """创建新的转录模板"""
+ data = request.json
+ if not data or not data.get('name') or not data.get('template_content'):
+ return jsonify({'error': '缺少必填字段'}), 400
+
+ template = TranscriptTemplate(
+ user_id=current_user.id,
+ name=data['name'],
+ description=data.get('description', ''),
+ template_content=data['template_content']
+ )
+ db.session.add(template)
+ db.session.commit()
+
+ return jsonify({
+ 'id': template.id,
+ 'name': template.name,
+ 'description': template.description,
+ 'template_content': template.template_content,
+ 'created_at': template.created_at.isoformat() if template.created_at else None
+ }), 201
+
+@template_bp.route('/api/transcript-templates/', methods=['PUT'])
+@login_required
+def update_transcript_template(template_id):
+ """更新转录模板"""
+ template = TranscriptTemplate.query.filter_by(id=template_id, user_id=current_user.id).first()
+ if not template:
+ return jsonify({'error': '模板不存在'}), 404
+
+ data = request.json
+ if data.get('name'):
+ template.name = data['name']
+ if 'description' in data:
+ template.description = data['description']
+ if data.get('template_content'):
+ template.template_content = data['template_content']
+
+ db.session.commit()
+
+ return jsonify({
+ 'id': template.id,
+ 'name': template.name,
+ 'description': template.description,
+ 'template_content': template.template_content,
+ 'updated_at': template.updated_at.isoformat() if template.updated_at else None
+ })
+
+@template_bp.route('/api/transcript-templates/', methods=['DELETE'])
+@login_required
+def delete_transcript_template(template_id):
+ """删除转录模板"""
+ template = TranscriptTemplate.query.filter_by(id=template_id, user_id=current_user.id).first()
+ if not template:
+ return jsonify({'error': '模板不存在'}), 404
+
+ db.session.delete(template)
+ db.session.commit()
+
+ return jsonify({'message': '模板已删除'})
+
+@template_bp.route('/api/transcript-templates/create-defaults', methods=['POST'])
+@login_required
+def create_default_templates():
+ """创建默认模板"""
+ # 检查是否已有模板
+ existing = TranscriptTemplate.query.filter_by(user_id=current_user.id).first()
+ if existing:
+ return jsonify({'message': '已有模板,跳过创建默认模板'})
+
+ # 创建默认模板
+ defaults = [
+ {
+ 'name': '会议记录',
+ 'description': '标准会议记录模板',
+ 'template_content': '# 会议记录\n\n## 参会人员\n- \n\n## 议题\n1. \n\n## 讨论内容\n\n## 决议事项\n- \n\n## 下一步行动\n- '
+ },
+ {
+ 'name': '访谈记录',
+ 'description': '访谈或采访记录模板',
+ 'template_content': '# 访谈记录\n\n## 受访者信息\n- 姓名:\n- 职位:\n- 日期:\n\n## 访谈主题\n\n## 问答记录\n### Q1:\nA:\n\n### Q2:\nA:\n\n## 关键要点\n- \n\n## 后续跟进\n- '
+ }
+ ]
+
+ for d in defaults:
+ template = TranscriptTemplate(
+ user_id=current_user.id,
+ name=d['name'],
+ description=d['description'],
+ template_content=d['template_content']
+ )
+ db.session.add(template)
+
+ db.session.commit()
+ return jsonify({'message': f'已创建 {len(defaults)} 个默认模板'})
diff --git a/speakr/src/api/template_filters.py b/speakr/src/api/template_filters.py
new file mode 100644
index 0000000..541aac4
--- /dev/null
+++ b/speakr/src/api/template_filters.py
@@ -0,0 +1,37 @@
+"""
+模板过滤器注册模块
+
+负责注册 Jinja2 模板过滤器和上下文处理器,包括:
+- now: 当前时间(供所有模板使用)
+- localdatetime: 时区转换过滤器
+"""
+from datetime import datetime
+from flask import current_app
+from src.utils.datetime import local_datetime_filter
+
+
+def register_template_filters(app):
+ """
+ 注册模板过滤器和上下文处理器
+
+ Args:
+ app: Flask 应用实例
+ """
+
+ # 注册上下文处理器:注入所有模板都需要的公共变量
+ @app.context_processor
+ def inject_template_globals():
+ """Inject common template variables."""
+ return {
+ 'now': datetime.now(),
+ 'html_title': current_app.config.get('HTML_TITLE', '智能(会议)文秘'),
+ }
+ # 注册时区转换过滤器
+ @app.template_filter('localdatetime')
+ def local_datetime_filter_wrapper(dt):
+ """
+ 将 UTC 时间转换为本地时区时间
+
+ 用法:{{ record.created_at|localdatetime }}
+ """
+ return local_datetime_filter(dt)
diff --git a/speakr/src/api/voiceprint.py b/speakr/src/api/voiceprint.py
new file mode 100644
index 0000000..ea4b2ff
--- /dev/null
+++ b/speakr/src/api/voiceprint.py
@@ -0,0 +1,470 @@
+# 声纹蓝图
+# 处理声纹的增删改查、音频获取及平台同步
+
+import os
+import tempfile
+import mimetypes
+import subprocess
+from pathlib import Path
+from datetime import datetime
+
+from flask import Blueprint, request, jsonify, send_file, current_app
+from flask_login import login_required, current_user
+from werkzeug.utils import secure_filename
+
+from src.clients import db, voiceprint_client, ASR_BASE_URL
+from src.models import Voiceprint, Recording
+from src.services import sync_voiceprint_to_platform
+
+# 创建声纹蓝图
+voiceprint_bp = Blueprint('voiceprint', __name__)
+
+# 全局对象(从 app.py 导入)
+limiter = None
+csrf = None
+bcrypt = None
+
+def init_voiceprint_bp(limiter_instance, csrf_instance, bcrypt_instance):
+ """初始化蓝图所需的全局对象"""
+ global limiter, csrf, bcrypt
+ limiter = limiter_instance
+ csrf = csrf_instance
+ bcrypt = bcrypt_instance
+
+
+def convert_to_wav(input_path):
+ """
+ 将音频文件转换为WAV格式
+ Args:
+ input_path: 输入音频文件路径
+ Returns:
+ str: 转换后的WAV文件路径
+ Raises:
+ Exception: 转换失败时抛出异常
+ """
+ # 检查文件是否存在
+ if not os.path.exists(input_path):
+ raise FileNotFoundError(f"音频文件不存在: {input_path}")
+
+ # 获取文件MIME类型
+ mime_type, _ = mimetypes.guess_type(input_path)
+ current_app.logger.info(f"mime_type: {mime_type}, input_path:{input_path}")
+ # 如果是WAV格式,直接返回
+ if mime_type == 'audio/wav' or input_path.lower().endswith('.wav'):
+ return input_path
+
+ # 创建临时文件用于存储转换后的WAV
+ temp_dir = tempfile.gettempdir()
+ output_filename = Path(input_path).stem + '_converted.wav'
+ output_path = os.path.join(temp_dir, output_filename)
+
+ try:
+ # 使用ffmpeg进行格式转换
+ # 参数说明:
+ # -y: 覆盖输出文件
+ # -i: 输入文件
+ # -acodec pcm_s16le: 使用PCM 16位编码
+ # -ar 16000: 采样率16kHz(可根据需要调整)
+ # -ac 1: 单声道
+ cmd = [
+ 'ffmpeg', '-y', '-i', input_path,
+ '-acodec', 'pcm_s16le',
+ '-ar', '16000',
+ '-ac', '1',
+ output_path
+ ]
+
+ # 执行转换命令
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+
+ if result.returncode != 0:
+ raise Exception(f"音频转换失败: {result.stderr}")
+
+ # 检查输出文件是否生成成功
+ if not os.path.exists(output_path):
+ raise Exception("转换后的文件未生成")
+
+ return output_path
+
+ except subprocess.TimeoutExpired:
+ raise Exception("音频转换超时")
+ except Exception as e:
+ # 清理可能生成的临时文件
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ raise Exception(f"音频转换错误: {str(e)}")
+
+
+@voiceprint_bp.route('/api/voiceprints', methods=['GET'])
+@login_required
+def get_voiceprints():
+ """获取声纹列表"""
+ try:
+ voiceprints = Voiceprint.query.all()
+ return jsonify({
+ 'success': True,
+ 'voiceprints': [vp.to_dict() for vp in voiceprints]
+ })
+ except Exception as e:
+ current_app.logger.error(f"获取声纹列表失败: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@voiceprint_bp.route('/api/voiceprints', methods=['POST'])
+@login_required
+def create_voiceprint():
+ """创建声纹"""
+ converted_file_path = None
+ original_file_path = None
+
+ try:
+ # 检查是否有文件上传
+ if 'voice_data' not in request.files:
+ return jsonify({'error': '没有上传音频文件'}), 400
+
+ file = request.files['voice_data']
+ if file.filename == '':
+ return jsonify({'error': '没有选择文件'}), 400
+
+ # 获取表单数据
+ name = request.form.get('name')
+ if not name:
+ return jsonify({'error': '声纹名称不能为空'}), 400
+
+ # 检查名称是否已存在
+ existing_voiceprint = Voiceprint.query.filter_by(name=name).first()
+ if existing_voiceprint:
+ return jsonify({'error': '声纹名称已存在'}), 400
+
+ # 创建上传目录
+ voiceprint_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'voiceprint')
+ os.makedirs(voiceprint_dir, exist_ok=True)
+
+ # 保存原始音频文件到临时位置
+ temp_filename = secure_filename(f"temp_{datetime.now().strftime('%Y%m%d%H%M%S')}_{file.filename}")
+ temp_filepath = os.path.join(voiceprint_dir, temp_filename)
+ file.save(temp_filepath)
+ original_file_path = temp_filepath
+
+ # 转换为WAV格式
+ converted_file_path = convert_to_wav(temp_filepath)
+
+ # 保存转换后的文件
+ final_filename = secure_filename(f"{name}_{datetime.now().strftime('%Y%m%d%H%M%S')}.wav")
+ final_filepath = os.path.join(voiceprint_dir, final_filename)
+
+ # 如果转换后的文件不是原始文件,则移动
+ if converted_file_path != temp_filepath:
+ import shutil
+ shutil.move(converted_file_path, final_filepath)
+ converted_file_path = final_filepath
+ else:
+ # 如果是原始WAV文件,重命名
+ os.rename(temp_filepath, final_filepath)
+ converted_file_path = final_filepath
+
+ # 创建声纹记录
+ voiceprint = Voiceprint(
+ name=name,
+ audio_path=final_filepath
+ )
+ db.session.add(voiceprint)
+ db.session.commit()
+
+ # 同步到平台
+ try:
+ sync_voiceprint_to_platform(voiceprint, 'add')
+ except Exception as sync_error:
+ # 如果同步失败,删除本地记录
+ db.session.delete(voiceprint)
+ db.session.commit()
+ # 删除已保存的文件
+ if os.path.exists(final_filepath):
+ os.remove(final_filepath)
+ return jsonify({'error': f'创建声纹成功但同步到平台失败: {str(sync_error)}'}), 500
+
+ return jsonify({
+ 'success': True,
+ 'message': '声纹创建成功',
+ 'voiceprint': voiceprint.to_dict()
+ }), 201
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"创建声纹失败: {e}")
+
+ # 清理临时文件
+ if original_file_path and os.path.exists(original_file_path):
+ try:
+ os.remove(original_file_path)
+ except:
+ pass
+ if converted_file_path and converted_file_path != original_file_path and os.path.exists(converted_file_path):
+ try:
+ os.remove(converted_file_path)
+ except:
+ pass
+
+ return jsonify({'error': str(e)}), 500
+
+
+@voiceprint_bp.route('/api/voiceprints/', methods=['PUT'])
+@login_required
+def update_voiceprint(voiceprint_id):
+ """更新声纹"""
+ converted_file_path = None
+ original_file_path = None
+
+ try:
+ voiceprint = Voiceprint.query.get_or_404(voiceprint_id)
+
+ # 获取表单数据
+ name = request.form.get('name')
+ if not name or not name.strip():
+ return jsonify({'error': '声纹名称不能为空'}), 400
+
+ # 检查名称是否被其他声纹使用
+ existing_voiceprint = Voiceprint.query.filter(
+ Voiceprint.name == name,
+ Voiceprint.id != voiceprint_id
+ ).first()
+ if existing_voiceprint:
+ return jsonify({'error': '声纹名称已存在'}), 400
+
+ # 保存旧名称用于平台同步
+ old_name = voiceprint.name
+
+ # 处理文件上传(如果有)
+ file = request.files.get('voice_data')
+ if file and file.filename != '':
+ # 创建上传目录
+ voiceprint_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'voiceprint')
+ os.makedirs(voiceprint_dir, exist_ok=True)
+
+ # 保存原始音频文件到临时位置
+ temp_filename = secure_filename(f"temp_{datetime.now().strftime('%Y%m%d%H%M%S')}_{file.filename}")
+ temp_filepath = os.path.join(voiceprint_dir, temp_filename)
+ file.save(temp_filepath)
+ original_file_path = temp_filepath
+
+ # 转换为WAV格式
+ converted_file_path = convert_to_wav(temp_filepath)
+
+ # 保存转换后的文件
+ final_filename = secure_filename(f"{name}_{datetime.now().strftime('%Y%m%d%H%M%S')}.wav")
+ final_filepath = os.path.join(voiceprint_dir, final_filename)
+
+ # 如果转换后的文件不是原始文件,则移动
+ if converted_file_path != temp_filepath:
+ import shutil
+ shutil.move(converted_file_path, final_filepath)
+ converted_file_path = final_filepath
+ else:
+ # 如果是原始WAV文件,重命名
+ os.rename(temp_filepath, final_filepath)
+ converted_file_path = final_filepath
+
+ # 删除旧文件
+ if os.path.exists(voiceprint.audio_path):
+ os.remove(voiceprint.audio_path)
+
+ voiceprint.audio_path = final_filepath
+
+ # 更新名称
+ voiceprint.name = name
+ db.session.commit()
+
+ # 同步到平台:先删除旧记录,再添加新记录
+ try:
+ # 创建临时对象用于删除
+ temp_voiceprint = Voiceprint(name=old_name, audio_path='')
+ sync_voiceprint_to_platform(temp_voiceprint, 'delete')
+
+ # 添加更新后的记录
+ sync_voiceprint_to_platform(voiceprint, 'add')
+
+ except Exception as sync_error:
+ current_app.logger.error(f"同步声纹更新到平台失败: {str(sync_error)}")
+ return jsonify({'error': f'更新声纹成功但同步到平台失败: {str(sync_error)}'}), 500
+
+ return jsonify({
+ 'success': True,
+ 'message': '声纹更新成功',
+ 'voiceprint': voiceprint.to_dict()
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"更新声纹失败: {e}")
+
+ # 清理临时文件
+ if original_file_path and os.path.exists(original_file_path):
+ try:
+ os.remove(original_file_path)
+ except:
+ pass
+ if converted_file_path and converted_file_path != original_file_path and os.path.exists(converted_file_path):
+ try:
+ os.remove(converted_file_path)
+ except:
+ pass
+
+ return jsonify({'error': str(e)}), 500
+
+
+@voiceprint_bp.route('/api/voiceprints/', methods=['DELETE'])
+@login_required
+def delete_voiceprint(voiceprint_id):
+ """删除声纹"""
+ try:
+ voiceprint = Voiceprint.query.get_or_404(voiceprint_id)
+
+ # 同步到平台
+ try:
+ sync_voiceprint_to_platform(voiceprint, 'delete')
+ except Exception as sync_error:
+ current_app.logger.error(f"同步声纹删除到平台失败: {str(sync_error)}")
+ return jsonify({'error': f'删除声纹失败: 无法从平台删除 - {str(sync_error)}'}), 500
+
+ # 删除本地文件
+ if os.path.exists(voiceprint.audio_path):
+ os.remove(voiceprint.audio_path)
+
+ # 删除数据库记录
+ db.session.delete(voiceprint)
+ db.session.commit()
+
+ return jsonify({
+ 'success': True,
+ 'message': '声纹删除成功'
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"删除声纹失败: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@voiceprint_bp.route('/api/voiceprints/sync-local-to-platform', methods=['POST'])
+@login_required
+def sync_local_to_platform():
+ """将本地比平台多的声纹同步到平台"""
+ try:
+ # 检查用户权限,只有管理员可以执行同步
+ if not current_user.is_admin:
+ return jsonify({'error': '只有管理员可以执行声纹同步操作'}), 403
+
+ # 获取本地声纹列表
+ local_voiceprints = Voiceprint.query.all()
+ local_names = {vp.name for vp in local_voiceprints}
+
+ # 获取平台声纹列表
+ try:
+ platform_data = voiceprint_client.get_voice_names()
+ voice_list = platform_data.get('data', [])
+ platform_names = {item['name'] for item in voice_list if isinstance(item, dict) and 'name' in item}
+ # 根据实际API响应结构调整
+ current_app.logger.info(f"平台声纹列表: {platform_names}")
+ except Exception as e:
+ current_app.logger.error(f"获取平台声纹列表失败: {e}")
+ return jsonify({'error': f'无法连接到声纹平台: {str(e)}'}), 500
+
+ # 计算需要同步的声纹(本地有但平台没有的)
+ to_sync = local_names - platform_names
+
+ if not to_sync:
+ return jsonify({
+ 'success': True,
+ 'message': '本地和平台声纹已完全同步,无需额外操作',
+ 'synced': 0,
+ 'already_synced': len(local_names & platform_names)
+ })
+
+ current_app.logger.info(f"发现 {len(to_sync)} 个需要同步到平台的声纹: {to_sync}")
+
+ # 开始同步
+ synced_count = 0
+ failed_syncs = []
+
+ for voiceprint_name in to_sync:
+ try:
+ # 查找本地声纹对象
+ voiceprint = Voiceprint.query.filter_by(name=voiceprint_name).first()
+ if not voiceprint:
+ current_app.logger.warning(f"本地声纹 '{voiceprint_name}' 在数据库中未找到")
+ failed_syncs.append({
+ 'name': voiceprint_name,
+ 'reason': '本地数据库中未找到该声纹记录'
+ })
+ continue
+
+ # 检查音频文件是否存在
+ if not os.path.exists(voiceprint.audio_path):
+ current_app.logger.warning(f"声纹 '{voiceprint_name}' 的音频文件不存在: {voiceprint.audio_path}")
+ failed_syncs.append({
+ 'name': voiceprint_name,
+ 'reason': f'音频文件不存在: {voiceprint.audio_path}'
+ })
+ continue
+
+ # 同步到平台
+ with open(voiceprint.audio_path, 'rb') as audio_file:
+ voiceprint_client.add_voiceprint(voiceprint.name, audio_file)
+
+ synced_count += 1
+ current_app.logger.info(f"成功同步声纹到平台: {voiceprint_name}")
+
+ except Exception as e:
+ current_app.logger.error(f"同步声纹 '{voiceprint_name}' 失败: {e}")
+ failed_syncs.append({
+ 'name': voiceprint_name,
+ 'reason': str(e)
+ })
+
+ # 统计同步结果
+ already_synced = len(local_names & platform_names)
+ total_local = len(local_names)
+
+ response_data = {
+ 'success': True,
+ 'message': f'同步完成,成功同步 {synced_count} 个声纹到平台',
+ 'sync_summary': {
+ 'total_local_voiceprints': total_local,
+ 'already_synced': already_synced,
+ 'newly_synced': synced_count,
+ 'failed_syncs': len(failed_syncs)
+ }
+ }
+
+ if failed_syncs:
+ response_data['failed_syncs'] = failed_syncs
+ response_data['message'] += f',{len(failed_syncs)} 个同步失败'
+
+ # 如果没有任何需要同步的声纹或所有同步都失败
+ if synced_count == 0:
+ if not to_sync:
+ response_data['message'] = '本地和平台声纹已完全同步,无需额外操作'
+ else:
+ response_data['message'] = f'尝试同步 {len(to_sync)} 个声纹,但所有同步都失败'
+ response_data['success'] = False
+
+ return jsonify(response_data)
+
+ except Exception as e:
+ current_app.logger.error(f"同步本地声纹到平台失败: {e}")
+ return jsonify({'error': str(e)}), 500
+
+
+@voiceprint_bp.route('/api/voiceprints//audio', methods=['GET'])
+@login_required
+def get_voiceprint_audio(voiceprint_id):
+ """获取声纹音频文件"""
+ try:
+ voiceprint = Voiceprint.query.get_or_404(voiceprint_id)
+ if not os.path.exists(voiceprint.audio_path):
+ return jsonify({'error': '音频文件不存在'}), 404
+ return send_file(voiceprint.audio_path)
+
+ except Exception as e:
+ current_app.logger.error(f"获取声纹音频文件失败: {e}")
+ return jsonify({'error': str(e)}), 500
diff --git a/speakr/src/app.py b/speakr/src/app.py
new file mode 100644
index 0000000..91e2f09
--- /dev/null
+++ b/speakr/src/app.py
@@ -0,0 +1,110 @@
+"""
+Speakr 应用主模块
+
+负责创建和配置 Flask 应用实例,初始化各层组件。
+"""
+import os
+import logging
+from flask import Flask, redirect, request
+from werkzeug.middleware.proxy_fix import ProxyFix
+
+from src.config import (
+ LOG_LEVEL,
+ get_app_config,
+)
+from src.logging_config import configure_logging
+from src.flask_ext import init_extensions
+from src.clients import init_database
+from src.api import register_blueprints
+from src.api.template_filters import register_template_filters
+from src.services.register import init_services
+
+logger = logging.getLogger(__name__)
+
+
+class PrefixStripMiddleware:
+ """Allow the app to serve routes hard-coded with a deployment prefix."""
+
+ def __init__(self, app, prefix):
+ self.app = app
+ self.prefix = prefix.rstrip('/')
+
+ def __call__(self, environ, start_response):
+ path = environ.get('PATH_INFO', '')
+ if path == self.prefix:
+ environ['PATH_INFO'] = '/'
+ environ['SCRIPT_NAME'] = self.prefix
+ elif path.startswith(self.prefix + '/'):
+ environ['PATH_INFO'] = path[len(self.prefix):] or '/'
+ environ['SCRIPT_NAME'] = self.prefix
+ return self.app(environ, start_response)
+
+
+def create_app():
+ """
+ 创建并配置 Flask 应用
+
+ Returns:
+ Flask: 配置完成的 Flask 应用实例
+ """
+ # 1. 配置日志
+ configure_logging(LOG_LEVEL)
+
+ # 2. 创建 Flask 应用实例
+ app = Flask(
+ __name__,
+ template_folder='../templates',
+ static_folder='../static'
+ )
+
+ # 3. 加载应用配置(所有配置由 config.py 统一管理,新增配置只需改 config.py)
+ app.config.from_mapping(get_app_config())
+
+ # 4. 配置反向代理支持(Nginx 等)
+ app.wsgi_app = ProxyFix(
+ app.wsgi_app,
+ x_for=1,
+ x_proto=1,
+ x_host=1,
+ x_prefix=1
+ )
+ app.wsgi_app = PrefixStripMiddleware(app.wsgi_app, '/tool/speakr')
+
+ # 5. 初始化 Flask 扩展
+ init_extensions(app)
+
+ # 6. 初始化数据库
+ init_database(app)
+
+ # 7. 初始化分段服务和嵌入依赖检测(需要在蓝图注册前就绪)
+ from src.services.register import init_early_services
+ init_early_services(app)
+
+ # 8. 注册蓝图
+ register_blueprints(app)
+
+ @app.before_request
+ def redirect_root_to_prefixed_app():
+ if request.path == '/' and not request.script_root:
+ return redirect('/tool/speakr/', code=302)
+
+ # 9. 注册模板过滤器
+ register_template_filters(app)
+
+ # 10. 初始化服务层(同步线程、文件监控、数据库迁移等)
+ init_services(app)
+
+ app.logger.info("Speakr 应用启动完成")
+
+ return app
+
+
+# 创建应用实例
+app = create_app()
+
+
+if __name__ == '__main__':
+ # 开发环境启动(端口和 debug 模式从环境变量读取)
+ port = int(os.environ.get('FLASK_PORT', '5000'))
+ debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
+ app.run(host='0.0.0.0', port=port, debug=debug)
diff --git a/speakr/src/clients/__init__.py b/speakr/src/clients/__init__.py
new file mode 100644
index 0000000..9a20a8c
--- /dev/null
+++ b/speakr/src/clients/__init__.py
@@ -0,0 +1,123 @@
+"""
+Clients Package - 外部服务客户端
+
+统一管理与外部服务的连接客户端,如 LLM、声纹识别、数据库等。
+"""
+# 配置项统一从 src.config 导入
+from src.config import (
+ # 基础配置
+ SECRET_KEY,
+ UPLOAD_FOLDER,
+ LOG_LEVEL,
+ MAX_FILE_SIZE_MB,
+ # ASR 配置
+ USE_ASR_ENDPOINT,
+ ASR_BASE_URL,
+ ASR_DIARIZE,
+ ASR_MIN_SPEAKERS,
+ ASR_MAX_SPEAKERS,
+ # LLM 配置
+ TEXT_MODEL_BASE_URL,
+ TEXT_MODEL_NAME,
+ TEXT_MODEL_API_KEY,
+ # 音频分段配置
+ ENABLE_CHUNKING,
+ # Inquire 配置
+ ENABLE_INQUIRE_MODE,
+ MAX_TOKENS_FOR_TRANSCRIPTION,
+ SPEAKR_TOKENS_FOR_TRANSCRIPTION,
+ # 外部 API 配置
+ TRANSCRIPTION_BASE_URL,
+ VALID_TOKEN,
+ # 数据库配置
+ SQLALCHEMY_DATABASE_URI,
+ DB_POOL_SIZE,
+ DB_MAX_OVERFLOW,
+ DB_POOL_TIMEOUT,
+ DB_POOL_RECYCLE,
+)
+
+from .llm_client import (
+ client,
+ http_client_no_proxy,
+)
+
+from .voiceprint_client import (
+ VoiceprintClient,
+ voiceprint_client,
+)
+
+from .database import (
+ db,
+ init_database,
+ get_database_uri,
+ is_postgresql,
+)
+
+# 嵌入功能依赖检测
+try:
+ import numpy as np
+ from sentence_transformers import SentenceTransformer
+ from sklearn.metrics.pairwise import cosine_similarity
+ EMBEDDINGS_AVAILABLE = True
+except ImportError:
+ EMBEDDINGS_AVAILABLE = False
+
+ class SentenceTransformer:
+ def __init__(self, *args, **kwargs):
+ pass
+ def encode(self, *args, **kwargs):
+ return []
+
+ np = None
+ cosine_similarity = None
+
+# 音频分段服务初始化
+from src.services.audio_chunking import AudioChunkingService
+chunking_service = AudioChunkingService() if ENABLE_CHUNKING else None
+
+__all__ = [
+ # 基础配置
+ 'SECRET_KEY',
+ 'UPLOAD_FOLDER',
+ 'LOG_LEVEL',
+ 'MAX_FILE_SIZE_MB',
+ # ASR 配置
+ 'USE_ASR_ENDPOINT',
+ 'ASR_BASE_URL',
+ 'ASR_DIARIZE',
+ 'ASR_MIN_SPEAKERS',
+ 'ASR_MAX_SPEAKERS',
+ # LLM 配置
+ 'TEXT_MODEL_BASE_URL',
+ 'TEXT_MODEL_NAME',
+ 'TEXT_MODEL_API_KEY',
+ # 音频分段配置
+ 'ENABLE_CHUNKING',
+ # Inquire 配置
+ 'ENABLE_INQUIRE_MODE',
+ 'MAX_TOKENS_FOR_TRANSCRIPTION',
+ 'SPEAKR_TOKENS_FOR_TRANSCRIPTION',
+ # 外部 API 配置
+ 'TRANSCRIPTION_BASE_URL',
+ 'VALID_TOKEN',
+ # 数据库配置
+ 'SQLALCHEMY_DATABASE_URI',
+ 'DB_POOL_SIZE',
+ 'DB_MAX_OVERFLOW',
+ 'DB_POOL_TIMEOUT',
+ 'DB_POOL_RECYCLE',
+ # 客户端
+ 'client',
+ 'http_client_no_proxy',
+ 'VoiceprintClient',
+ 'voiceprint_client',
+ # 数据库
+ 'db',
+ 'init_database',
+ 'get_database_uri',
+ 'is_postgresql',
+ # 服务实例
+ 'chunking_service',
+ 'EMBEDDINGS_AVAILABLE',
+]
diff --git a/speakr/src/clients/database.py b/speakr/src/clients/database.py
new file mode 100644
index 0000000..afd3768
--- /dev/null
+++ b/speakr/src/clients/database.py
@@ -0,0 +1,91 @@
+"""
+Database Client - 数据库客户端配置和初始化
+
+统一管理数据库连接配置和实例,支持 SQLite 和 PostgreSQL 等多种数据库后端。
+未来切换数据库只需修改环境变量,无需修改代码。
+"""
+import os
+import logging
+from flask_sqlalchemy import SQLAlchemy
+
+logger = logging.getLogger(__name__)
+
+# --- 数据库实例 ---
+# 全局共享的 SQLAlchemy 实例,供模型层导入使用
+db = SQLAlchemy()
+
+
+def init_database(app):
+ """
+ 初始化数据库连接配置并绑定到 Flask 应用
+
+ 从环境变量读取数据库配置,支持 SQLite 和 PostgreSQL。
+
+ Args:
+ app: Flask 应用实例
+
+ 配置项:
+ SQLALCHEMY_DATABASE_URI: 数据库连接字符串
+ SQLALCHEMY_TRACK_MODIFICATIONS: 是否追踪对象修改(默认关闭以节省内存)
+ SQLALCHEMY_ENGINE_OPTIONS: 引擎选项(连接池、超时等)
+ """
+ # --- 数据库连接配置 ---
+ # 优先从环境变量读取,支持 SQLite 和 PostgreSQL
+ database_uri = os.environ.get(
+ 'SQLALCHEMY_DATABASE_URI',
+ 'sqlite:///data/instance/transcriptions.db'
+ )
+ app.config['SQLALCHEMY_DATABASE_URI'] = database_uri
+
+ # 关闭 SQLAlchemy 的修改追踪(节省内存)
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+
+ # --- 引擎选项(连接池配置)---
+ # 适用于 SQLite 和 PostgreSQL
+ engine_options = {
+ 'pool_pre_ping': True, # 连接前检查连接是否有效
+ }
+
+ # PostgreSQL 特有的连接池配置
+ if database_uri.startswith('postgresql'):
+ engine_options.update({
+ 'pool_size': int(os.environ.get('DB_POOL_SIZE', '10')), # 连接池大小
+ 'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', '20')), # 最大溢出连接数
+ 'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', '30')), # 获取连接超时时间(秒)
+ 'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', '3600')), # 连接回收时间(秒)
+ })
+ logger.info(f"数据库已配置为 PostgreSQL,连接池大小:{engine_options['pool_size']}")
+ else:
+ # SQLite 使用静态池(单线程)
+ engine_options['poolclass'] = None # 让 SQLAlchemy 自动选择
+ logger.info(f"数据库已配置为 SQLite:{database_uri}")
+
+ app.config['SQLALCHEMY_ENGINE_OPTIONS'] = engine_options
+
+ # --- 绑定到 Flask 应用 ---
+ db.init_app(app)
+
+ logger.info("数据库配置初始化完成")
+
+
+def get_database_uri():
+ """
+ 获取当前数据库连接字符串
+
+ Returns:
+ str: 数据库连接 URI
+ """
+ return os.environ.get(
+ 'SQLALCHEMY_DATABASE_URI',
+ 'sqlite:///data/instance/transcriptions.db'
+ )
+
+
+def is_postgresql():
+ """
+ 检查当前是否使用 PostgreSQL 数据库
+
+ Returns:
+ bool: 如果是 PostgreSQL 返回 True
+ """
+ return get_database_uri().startswith('postgresql')
diff --git a/speakr/src/clients/llm_client.py b/speakr/src/clients/llm_client.py
new file mode 100644
index 0000000..0549caa
--- /dev/null
+++ b/speakr/src/clients/llm_client.py
@@ -0,0 +1,49 @@
+"""
+LLM Client - 大语言模型客户端初始化
+
+统一初始化 LLM 客户端和相关配置常量,供服务层直接导入使用,
+避免在每个函数调用时层层传递 client 参数。
+"""
+import logging
+import httpx
+from openai import OpenAI
+
+from src.config import TEXT_MODEL_API_KEY, TEXT_MODEL_BASE_URL, TEXT_MODEL_NAME
+
+logger = logging.getLogger(__name__)
+
+# --- OpenRouter 应用标识头 ---
+app_headers = {
+ "HTTP-Referer": "https://github.com/murtaza-nasir/speakr",
+ "X-Title": "Speakr - AI Audio Transcription",
+ "User-Agent": "Speakr/1.0 (https://github.com/murtaza-nasir/speakr)"
+}
+
+# --- HTTP 客户端(带超时配置)---
+# LLM 生成可能耗时较长,read 超时设为 5 分钟
+http_client_no_proxy = httpx.Client(
+ verify=True,
+ headers=app_headers,
+ timeout=httpx.Timeout(
+ connect=10.0, # 连接超时 10 秒
+ read=300.0, # 读取超时 5 分钟(LLM 生成可能很慢)
+ write=60.0, # 写入超时 60 秒
+ pool=30.0 # 连接池等待 30 秒
+ )
+)
+
+# --- OpenAI 客户端初始化 ---
+try:
+ # 始终尝试创建客户端 - 如果提供了 API 密钥则使用,否则使用占位符
+ api_key = TEXT_MODEL_API_KEY or "not-needed"
+ client = OpenAI(
+ api_key=api_key,
+ base_url=TEXT_MODEL_BASE_URL,
+ http_client=http_client_no_proxy
+ )
+ logger.info(f"LLM 客户端初始化完成,端点:{TEXT_MODEL_BASE_URL},模型:{TEXT_MODEL_NAME}")
+ if "openrouter" in TEXT_MODEL_BASE_URL.lower():
+ logger.info("OpenRouter 集成:已添加应用标识头")
+except Exception as client_init_e:
+ logger.error(f"LLM 客户端初始化失败:{client_init_e}")
+ client = None
diff --git a/speakr/src/clients/voiceprint_client.py b/speakr/src/clients/voiceprint_client.py
new file mode 100644
index 0000000..58b8242
--- /dev/null
+++ b/speakr/src/clients/voiceprint_client.py
@@ -0,0 +1,163 @@
+"""
+Voiceprint Client - 声纹识别平台客户端
+
+用于与外部声纹识别平台进行交互,支持声纹人的增删查操作。
+"""
+import logging
+import time
+import httpx
+
+from src.config import ASR_BASE_URL
+
+logger = logging.getLogger(__name__)
+
+
+class VoiceprintClient:
+ """
+ 声纹识别平台客户端
+
+ 用于与外部声纹识别平台进行交互,支持声纹人的增删查操作
+ """
+
+ def __init__(self, database_name='NB', collection_name='voice'):
+ """
+ 初始化声纹客户端
+
+ Args:
+ database_name: 数据库名称,默认为 'NB'
+ collection_name: 集合名称,默认为 'voice'
+ """
+ self.base_url = ASR_BASE_URL
+ self.database_name = database_name
+ self.collection_name = collection_name
+ self.timeout = httpx.Timeout(
+ connect=10.0,
+ read=30.0,
+ write=60.0,
+ pool=10.0
+ )
+ # 使用持久化 httpx.Client 复用连接池
+ self._client = httpx.Client(
+ base_url=self.base_url,
+ timeout=self.timeout,
+ verify=True
+ )
+ self._max_retries = 3
+ logger.info(f"声纹客户端初始化完成,目标平台:{self.base_url}")
+
+ def _make_request(self, method, endpoint, **kwargs):
+ """
+ 发送 HTTP 请求到声纹平台(带重试机制)
+
+ Args:
+ method: HTTP 方法 (GET, POST, DELETE 等)
+ endpoint: API 端点路径
+ **kwargs: 传递给 httpx.request 的其他参数
+
+ Returns:
+ dict: API 响应的 JSON 数据
+
+ Raises:
+ Exception: 重试失败后抛出异常
+ """
+ url = f"{self.base_url}{endpoint}"
+ last_exception = None
+
+ for attempt in range(1, self._max_retries + 1):
+ try:
+ response = self._client.request(method, url, **kwargs)
+ response.raise_for_status()
+ return response.json()
+ except httpx.HTTPStatusError as e:
+ logger.error(f"声纹 API 错误(第 {attempt}/{self._max_retries} 次尝试):{e.response.status_code} - {e.response.text}")
+ last_exception = e
+ break # HTTP 状态错误不重试
+ except httpx.ConnectError as e:
+ logger.warning(f"声纹连接失败(第 {attempt}/{self._max_retries} 次尝试):{e}")
+ last_exception = e
+ except httpx.TimeoutException as e:
+ logger.warning(f"声纹请求超时(第 {attempt}/{self._max_retries} 次尝试):{e}")
+ last_exception = e
+ except httpx.RequestError as e:
+ logger.warning(f"声纹请求错误(第 {attempt}/{self._max_retries} 次尝试):{e}")
+ last_exception = e
+
+ if attempt < self._max_retries:
+ wait_time = 2 ** (attempt - 1) # 指数退避:1s, 2s, 4s
+ logger.info(f"等待 {wait_time} 秒后重试...")
+ time.sleep(wait_time)
+
+ # 所有重试耗尽
+ if isinstance(last_exception, httpx.ConnectError):
+ raise Exception(f"无法连接到声纹平台(已重试 {self._max_retries} 次)")
+ elif isinstance(last_exception, httpx.TimeoutException):
+ raise Exception(f"声纹平台请求超时(已重试 {self._max_retries} 次)")
+ elif isinstance(last_exception, httpx.HTTPStatusError):
+ raise Exception(f"声纹平台请求失败:{last_exception.response.status_code}")
+ else:
+ raise Exception(f"声纹平台请求失败(已重试 {self._max_retries} 次):{last_exception}")
+
+ def get_voice_names(self):
+ """
+ 获取声纹人列表
+
+ Returns:
+ dict: 包含声纹人列表的响应
+ """
+ params = {
+ 'database_name': self.database_name,
+ 'collection_name': self.collection_name
+ }
+ return self._make_request('GET', '/get_voice_name', params=params)
+
+ def add_voiceprint(self, name, audio_file):
+ """
+ 添加声纹人
+
+ Args:
+ name: 声纹人姓名
+ audio_file: 音频文件对象
+
+ Returns:
+ dict: API 响应
+ """
+ data = {
+ 'voice_name': name,
+ 'database_name': self.database_name,
+ 'collection_name': self.collection_name
+ }
+ files = {'voice_file': audio_file}
+ return self._make_request('POST', '/voice_insert', data=data, files=files)
+
+ def delete_voiceprint(self, name):
+ """
+ 删除声纹人
+
+ Args:
+ name: 要删除的声纹人姓名
+
+ Returns:
+ dict: API 响应
+ """
+ params = {
+ 'database_name': self.database_name,
+ 'collection_name': self.collection_name,
+ 'name': name
+ }
+ return self._make_request('DELETE', '/delete_voice', params=params)
+
+ def close(self):
+ """关闭底层 HTTP 客户端"""
+ if self._client:
+ self._client.close()
+
+
+# 全局声纹客户端实例(延迟初始化)
+_voiceprint_client = None
+
+def voiceprint_client():
+ """获取声纹客户端实例(延迟初始化以避免循环导入)"""
+ global _voiceprint_client
+ if _voiceprint_client is None:
+ _voiceprint_client = VoiceprintClient()
+ return _voiceprint_client
diff --git a/speakr/src/config.py b/speakr/src/config.py
new file mode 100644
index 0000000..b71bb88
--- /dev/null
+++ b/speakr/src/config.py
@@ -0,0 +1,191 @@
+"""
+环境变量配置管理
+
+集中管理所有环境变量,提供统一的配置读取入口。
+所有配置项按功能域分组,支持类型转换和默认值设置。
+"""
+import logging
+import os
+from dotenv import load_dotenv
+
+logger = logging.getLogger(__name__)
+
+# 加载 .env 文件(支持通过 ENV_FILE 环境变量指定不同环境配置)
+import pathlib
+
+# 1. 首先尝试从系统环境变量获取 ENV_FILE
+# 2. 如果没有设置,使用默认的 .env 文件
+_env_file = os.environ.get('ENV_FILE', '.env')
+_env_path = pathlib.Path(__file__).parent.parent / _env_file
+
+# 加载指定的 .env 文件(如果存在)
+if _env_path.exists():
+ load_dotenv(_env_path)
+ logger.info(f"Loaded environment from: {_env_path}")
+else:
+ # 如果指定的文件不存在,回退到默认 .env
+ _default_env_path = pathlib.Path(__file__).parent.parent / '.env'
+ if _default_env_path.exists():
+ load_dotenv(_default_env_path)
+ logger.info(f"ENV_FILE '{_env_file}' not found, loaded default: {_default_env_path}")
+ else:
+ logger.warning(f"No .env file found at {_env_path} or {_default_env_path}")
+
+# ==================== 基础配置 ====================
+# Flask 应用配置
+SECRET_KEY = os.environ.get('SECRET_KEY', 'default-dev-key-change-in-production')
+FLASK_ENV = os.environ.get('FLASK_ENV', 'development')
+LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper()
+HTML_TITLE = os.environ.get('HTML_TITLE', '智能(会议)文秘')
+
+# Session Cookie 配置
+SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
+SESSION_COOKIE_HTTPONLY = os.environ.get('SESSION_COOKIE_HTTPONLY', 'true').lower() == 'true'
+SESSION_COOKIE_SAMESITE = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
+
+# 文件上传配置
+UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/data/uploads')
+MAX_FILE_SIZE_MB = int(os.environ.get('MAX_FILE_SIZE_MB', '250'))
+
+# ==================== ASR 配置 ====================
+# 是否使用外部 ASR 服务
+USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
+
+# 声纹服务配置
+ASR_BASE_URL = os.environ.get('ASR_BASE_URL', '').split('#')[0].strip()
+
+# 说话人分离配置
+ASR_DIARIZE = os.environ.get('ASR_DIARIZE', 'false').lower() == 'true'
+ASR_MIN_SPEAKERS = int(os.environ.get('ASR_MIN_SPEAKERS', '1'))
+ASR_MAX_SPEAKERS = int(os.environ.get('ASR_MAX_SPEAKERS', '10'))
+
+# ==================== LLM 配置 ====================
+# 文本模型配置
+TEXT_MODEL_BASE_URL = os.environ.get('TEXT_MODEL_BASE_URL', 'https://openrouter.ai/api/v1').split('#')[0].strip()
+TEXT_MODEL_NAME = os.environ.get('TEXT_MODEL_NAME', 'openai/gpt-3.5-turbo')
+TEXT_MODEL_API_KEY = os.environ.get('TEXT_MODEL_API_KEY', '')
+
+# Token 限制配置
+MAX_TOKENS_FOR_TRANSCRIPTION = int(os.environ.get('MAX_TOKENS_FOR_TRANSCRIPTION', '10000'))
+SPEAKR_TOKENS_FOR_TRANSCRIPTION = int(os.environ.get('SPEAKR_TOKENS_FOR_TRANSCRIPTION', '1000'))
+
+# ==================== 音频分段配置 ====================
+ENABLE_CHUNKING = os.environ.get('ENABLE_CHUNKING', 'false').lower() == 'true'
+
+# ==================== Inquire 配置 ====================
+ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
+
+# ==================== 数据库配置 ====================
+SQLALCHEMY_DATABASE_URI = os.environ.get(
+ 'SQLALCHEMY_DATABASE_URI',
+ 'sqlite:///data/instance/transcriptions.db'
+)
+
+# 数据库连接池配置(仅 PostgreSQL 生效)
+DB_POOL_SIZE = int(os.environ.get('DB_POOL_SIZE', '10'))
+DB_MAX_OVERFLOW = int(os.environ.get('DB_MAX_OVERFLOW', '20'))
+DB_POOL_TIMEOUT = int(os.environ.get('DB_POOL_TIMEOUT', '30'))
+DB_POOL_RECYCLE = int(os.environ.get('DB_POOL_RECYCLE', '3600'))
+
+# ==================== 外部 API 配置 ====================
+TRANSCRIPTION_BASE_URL = os.environ.get('TRANSCRIPTION_BASE_URL', 'https://api.openai.com/v1')
+TRANSCRIPTION_API_KEY = os.environ.get('TRANSCRIPTION_API_KEY', '')
+VALID_TOKEN = os.environ.get('VALID_TOKEN', 'speakr_auth_token_2024_secret_key_12345')
+
+# ==================== WebSocket 配置 ====================
+# FunASR WebSocket 服务地址(用于实时语音识别)
+FUNASR_WEBSOCKET_IP = os.environ.get('FUNASR_WEBSOCKET_IP', 'localhost:8083')
+
+# 大屏推送服务地址
+SCREEN_PUSH_IP = os.environ.get('SCREEN_PUSH_IP', '172.18.30.221:8083')
+
+# ==================== 自动处理配置 ====================
+ENABLE_AUTO_PROCESSING = os.environ.get('ENABLE_AUTO_PROCESSING', 'false').lower() == 'true'
+AUTO_PROCESS_WATCH_DIR = os.environ.get('AUTO_PROCESS_WATCH_DIR', '/data/auto-process')
+AUTO_PROCESS_CHECK_INTERVAL = int(os.environ.get('AUTO_PROCESS_CHECK_INTERVAL', '30'))
+AUTO_PROCESS_MODE = os.environ.get('AUTO_PROCESS_MODE', 'admin_only')
+AUTO_PROCESS_DEFAULT_USERNAME = os.environ.get('AUTO_PROCESS_DEFAULT_USERNAME')
+
+# ==================== 代理配置 ====================
+HTTP_PROXY = os.environ.get('HTTP_PROXY', '')
+HTTPS_PROXY = os.environ.get('HTTPS_PROXY', '')
+NO_PROXY = os.environ.get('NO_PROXY', '')
+
+# ==================== 配置验证 ====================
+def validate_config():
+ """验证必要的配置项是否已设置"""
+ warnings = []
+
+ if not SECRET_KEY or SECRET_KEY == 'default-dev-key-change-in-production':
+ warnings.append('警告:使用默认 SECRET_KEY,生产环境请务必设置环境变量')
+
+ if USE_ASR_ENDPOINT and not ASR_BASE_URL:
+ warnings.append('警告:已启用 ASR_ENDPOINT 但未设置 ASR_BASE_URL')
+
+ if TEXT_MODEL_BASE_URL and 'openrouter' in TEXT_MODEL_BASE_URL.lower() and not TEXT_MODEL_API_KEY:
+ warnings.append('警告:使用 OpenRouter 但未设置 TEXT_MODEL_API_KEY')
+
+ return warnings
+
+
+def get_app_config():
+ """
+ 获取所有需要写入 Flask app.config 的配置项
+ (不含 chunking_service 和 EMBEDDINGS_AVAILABLE,这两项由 app.py 初始化后手动注入)
+
+ Returns:
+ dict: 配置字典,可直接传给 app.config.from_mapping()
+ """
+ return {
+ # 基础配置
+ 'SECRET_KEY': SECRET_KEY,
+ 'UPLOAD_FOLDER': UPLOAD_FOLDER,
+ 'LOG_LEVEL': LOG_LEVEL,
+ 'HTML_TITLE': HTML_TITLE,
+ 'MAX_FILE_SIZE_MB': MAX_FILE_SIZE_MB,
+ # Session Cookie 配置
+ 'SESSION_COOKIE_SECURE': SESSION_COOKIE_SECURE,
+ 'SESSION_COOKIE_HTTPONLY': SESSION_COOKIE_HTTPONLY,
+ 'SESSION_COOKIE_SAMESITE': SESSION_COOKIE_SAMESITE,
+ # ASR 配置
+ 'USE_ASR_ENDPOINT': USE_ASR_ENDPOINT,
+ 'ASR_BASE_URL': ASR_BASE_URL,
+ 'ASR_DIARIZE': ASR_DIARIZE,
+ 'ASR_MIN_SPEAKERS': ASR_MIN_SPEAKERS,
+ 'ASR_MAX_SPEAKERS': ASR_MAX_SPEAKERS,
+ # LLM 配置
+ 'TEXT_MODEL_BASE_URL': TEXT_MODEL_BASE_URL,
+ 'TEXT_MODEL_NAME': TEXT_MODEL_NAME,
+ 'TEXT_MODEL_API_KEY': TEXT_MODEL_API_KEY,
+ # Token 限制
+ 'MAX_TOKENS_FOR_TRANSCRIPTION': MAX_TOKENS_FOR_TRANSCRIPTION,
+ 'SPEAKR_TOKENS_FOR_TRANSCRIPTION': SPEAKR_TOKENS_FOR_TRANSCRIPTION,
+ # 转录配置
+ 'TRANSCRIPTION_BASE_URL': TRANSCRIPTION_BASE_URL,
+ 'TRANSCRIPTION_API_KEY': TRANSCRIPTION_API_KEY,
+ 'VALID_TOKEN': VALID_TOKEN,
+ # 功能开关
+ 'ENABLE_CHUNKING': ENABLE_CHUNKING,
+ 'ENABLE_INQUIRE_MODE': ENABLE_INQUIRE_MODE,
+ # 自动处理配置
+ 'ENABLE_AUTO_PROCESSING': ENABLE_AUTO_PROCESSING,
+ 'AUTO_PROCESS_WATCH_DIR': AUTO_PROCESS_WATCH_DIR,
+ 'AUTO_PROCESS_CHECK_INTERVAL': AUTO_PROCESS_CHECK_INTERVAL,
+ 'AUTO_PROCESS_MODE': AUTO_PROCESS_MODE,
+ 'AUTO_PROCESS_DEFAULT_USERNAME': AUTO_PROCESS_DEFAULT_USERNAME,
+ # 代理配置
+ 'HTTP_PROXY': HTTP_PROXY,
+ 'HTTPS_PROXY': HTTPS_PROXY,
+ 'NO_PROXY': NO_PROXY,
+ # WebSocket 配置
+ 'FUNASR_WEBSOCKET_IP': FUNASR_WEBSOCKET_IP,
+ 'SCREEN_PUSH_IP': SCREEN_PUSH_IP,
+ # 数据库配置
+ 'SQLALCHEMY_DATABASE_URI': SQLALCHEMY_DATABASE_URI,
+ }
+
+
+# 启动时打印配置警告(使用 logging 以便被日志系统捕获)
+_config_warnings = validate_config()
+for warning in _config_warnings:
+ logger.warning(warning)
diff --git a/speakr/src/flask_ext.py b/speakr/src/flask_ext.py
new file mode 100644
index 0000000..1067bac
--- /dev/null
+++ b/speakr/src/flask_ext.py
@@ -0,0 +1,71 @@
+"""
+Flask 扩展实例集合
+
+集中管理所有 Flask 扩展的实例化与初始化,包括:
+- LoginManager: 用户认证管理
+- Bcrypt: 密码加密
+- Limiter: 请求频率限制
+- CSRFProtect: CSRF 防护
+"""
+import os
+from flask_login import LoginManager
+from flask_bcrypt import Bcrypt
+from flask_wtf.csrf import CSRFProtect
+from flask_limiter import Limiter
+from flask_limiter.util import get_remote_address
+from flask_sock import Sock
+
+# ==================== Flask 扩展实例 ====================
+# 用户认证管理器
+login_manager = LoginManager()
+login_manager.login_view = '/tool/speakr'
+login_manager.login_message_category = 'info'
+
+# 密码加密器
+bcrypt = Bcrypt()
+
+# 请求频率限制器(默认限制:5000次/天,1000次/小时)
+limiter = Limiter(
+ get_remote_address,
+ app=None,
+ default_limits=["5000 per day", "1000 per hour"]
+)
+
+# CSRF 防护
+csrf = CSRFProtect()
+
+# WebSocket 支持
+sock = Sock()
+
+
+def init_extensions(app):
+ """
+ 初始化所有 Flask 扩展
+
+ Args:
+ app: Flask 应用实例
+ """
+ # 初始化用户认证
+ login_manager.init_app(app)
+
+ # 初始化密码加密
+ bcrypt.init_app(app)
+
+ # 初始化频率限制
+ limiter.init_app(app)
+
+ # 初始化 CSRF 防护
+ csrf.init_app(app)
+
+ sock.init_app(app)
+
+ # 创建上传目录(如果不存在)
+ upload_folder = app.config.get('UPLOAD_FOLDER', 'uploads')
+ os.makedirs(upload_folder, exist_ok=True)
+
+ # 注册用户加载器
+ @login_manager.user_loader
+ def load_user(user_id):
+ from src.models import User
+ from src.clients import db
+ return db.session.get(User, user_id)
diff --git a/speakr/src/logging_config.py b/speakr/src/logging_config.py
new file mode 100644
index 0000000..9d15a65
--- /dev/null
+++ b/speakr/src/logging_config.py
@@ -0,0 +1,33 @@
+"""
+日志配置模块
+
+统一管理应用的日志配置,包括格式化、输出目标和日志级别。
+"""
+import sys
+import logging
+
+
+def configure_logging(log_level):
+ """
+ 配置应用日志
+
+ Args:
+ log_level: 日志级别(如 'INFO', 'DEBUG', 'WARNING' 等)
+ """
+ # 创建控制台输出处理器
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setLevel(log_level)
+
+ # 设置日志格式:时间 - 模块名 - 级别 - 消息
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+ handler.setFormatter(formatter)
+
+ # 配置根日志记录器
+ root_logger = logging.getLogger()
+ root_logger.setLevel(log_level)
+ root_logger.addHandler(handler)
+
+ # 配置 Flask/Werkzeug 日志记录器
+ app_logger = logging.getLogger('werkzeug')
+ app_logger.setLevel(log_level)
+ app_logger.addHandler(handler)
diff --git a/speakr/src/services/__init__.py b/speakr/src/services/__init__.py
new file mode 100644
index 0000000..97a07cb
--- /dev/null
+++ b/speakr/src/services/__init__.py
@@ -0,0 +1,158 @@
+# 服务层模块
+# 包含所有业务逻辑服务,按功能域划分
+
+from .llm_service import (
+ call_llm_completion,
+ clean_llm_response,
+ format_transcription_for_llm,
+ process_streaming_with_thinking,
+ extract_thinking_content,
+ preprocess_long_transcription,
+ extract_corrected_text,
+ format_api_error_message
+)
+
+from .transcription_service import (
+ transcribe_audio_asr,
+ transcribe_audio_task,
+ transcribe_single_file,
+ transcribe_with_chunking,
+ extract_audio_from_video,
+ convert_to_wav
+)
+
+from .summary_service import (
+ generate_title_task,
+ generate_summary_only_task
+)
+
+from .embedding_service import (
+ get_embedding_model,
+ chunk_transcription,
+ generate_embeddings,
+ serialize_embedding,
+ deserialize_embedding,
+ process_recording_chunks,
+ basic_text_search_chunks,
+ semantic_search_chunks
+)
+
+from .event_service import (
+ extract_events_from_transcript,
+ generate_ics_content,
+ escape_ical_text
+)
+
+from .document_service import (
+ add_unicode_paragraph,
+ create_docx_with_unicode_title,
+ save_and_send_docx,
+ process_markdown_to_docx,
+ get_recording_display_title,
+ sanitize_download_filename_component,
+ build_docx_download_filename,
+ set_download_filename_header
+)
+
+from .speaker_service import (
+ update_speaker_usage,
+ identify_speakers_from_text,
+ identify_unidentified_speakers_from_text
+)
+
+from .voiceprint_service import (
+ sync_voiceprint_to_platform
+)
+
+from .auto_process_service import (
+ initialize_file_monitor,
+ get_file_monitor_functions
+)
+
+from .auth_service import (
+ is_safe_url,
+ auto_login
+)
+
+from .sync_service import (
+ start_sync_thread,
+ sync_completed_recordings_to_remote
+)
+
+# 导入 LLM 客户端配置(供其他模块使用)
+from src.clients.llm_client import (
+ client,
+ TEXT_MODEL_NAME,
+ TEXT_MODEL_API_KEY,
+ TEXT_MODEL_BASE_URL,
+ http_client_no_proxy,
+)
+
+__all__ = [
+ # LLM 服务 - 大语言模型相关功能
+ 'call_llm_completion',
+ 'clean_llm_response',
+ 'format_transcription_for_llm',
+ 'process_streaming_with_thinking',
+ 'extract_thinking_content',
+ 'preprocess_long_transcription',
+ 'extract_corrected_text',
+ 'format_api_error_message',
+
+ # 转录服务 - 音频转文字相关功能
+ 'transcribe_audio_asr',
+ 'transcribe_audio_task',
+ 'transcribe_single_file',
+ 'transcribe_with_chunking',
+ 'extract_audio_from_video',
+ 'convert_to_wav',
+
+ # 摘要服务 - 标题和摘要生成
+ 'generate_title_task',
+ 'generate_summary_only_task',
+
+ # 嵌入服务 - 语义搜索和文本分块
+ 'get_embedding_model',
+ 'chunk_transcription',
+ 'generate_embeddings',
+ 'serialize_embedding',
+ 'deserialize_embedding',
+ 'process_recording_chunks',
+ 'basic_text_search_chunks',
+ 'semantic_search_chunks',
+
+ # 事件服务 - 日历事件提取
+ 'extract_events_from_transcript',
+ 'generate_ics_content',
+ 'escape_ical_text',
+
+ # 文档服务 - Word 文档导出
+ 'add_unicode_paragraph',
+ 'create_docx_with_unicode_title',
+ 'save_and_send_docx',
+ 'process_markdown_to_docx',
+ 'get_recording_display_title',
+ 'sanitize_download_filename_component',
+ 'build_docx_download_filename',
+ 'set_download_filename_header',
+
+ # 说话人服务 - 说话人识别和管理
+ 'update_speaker_usage',
+ 'identify_speakers_from_text',
+ 'identify_unidentified_speakers_from_text',
+
+ # 声纹服务 - 声纹同步业务逻辑
+ 'sync_voiceprint_to_platform',
+
+ # 自动处理服务 - 文件监控和自动转写
+ 'initialize_file_monitor',
+ 'get_file_monitor_functions',
+
+ # 认证服务 - 登录和安全验证
+ 'is_safe_url',
+ 'auto_login',
+
+ # 远程数据同步服务 - 将完成的录音同步到远程数据库
+ 'start_sync_thread',
+ 'sync_completed_recordings_to_remote'
+]
diff --git a/speakr/src/services/audio_chunking.py b/speakr/src/services/audio_chunking.py
new file mode 100644
index 0000000..0202dcf
--- /dev/null
+++ b/speakr/src/services/audio_chunking.py
@@ -0,0 +1,738 @@
+"""
+音频分块服务 - 用于大文件处理与OpenAI Whisper API集成
+
+该模块提供将大音频文件分割成符合OpenAI 25MB文件大小限制的小块的功能,
+单独处理每个块,并在保持准确性和说话人连续性的同时重新组装转录结果。
+"""
+
+import os
+import json
+import subprocess
+import tempfile
+import logging
+import math
+import re
+from typing import List, Dict, Any, Optional, Tuple
+from datetime import datetime
+import mimetypes
+
+# 配置日志
+logger = logging.getLogger(__name__)
+
+class AudioChunkingService:
+ """用于对大型音频文件进行分块并使用 OpenAI Whisper API 处理的服务。"""
+
+ def __init__(self, max_chunk_size_mb: int = 20, overlap_seconds: int = 3, max_chunk_duration_seconds: int = None):
+ """
+ 初始化分块服务。
+
+ Args:
+ max_chunk_size_mb: 每个分块的最大大小,单位为 MB(默认 20MB,留有余量)
+ overlap_seconds: 分块之间的重叠时长,单位为秒,用于保持上下文连续性
+ max_chunk_duration_seconds: 每个分块的最大时长,单位为秒(可选)
+ """
+ self.max_chunk_size_mb = max_chunk_size_mb
+ self.overlap_seconds = overlap_seconds
+ self.max_chunk_size_bytes = max_chunk_size_mb * 1024 * 1024
+ self.max_chunk_duration_seconds = max_chunk_duration_seconds
+ self.chunk_stats = [] # 跟踪处理统计信息
+
+ def needs_chunking(self, file_path: str, use_asr_endpoint: bool = False) -> bool:
+ """
+ 根据文件大小和使用的端点检查文件是否需要分块。
+
+ 注意:对于基于时长的限制,此方法可能返回 True,即使实际上不需要分块,
+ 因为我们需要先转换文件才能检查时长。实际的分块决策是在转换后通过
+ calculate_optimal_chunking() 方法做出的。
+
+ Args:
+ file_path: 音频文件的路径
+ use_asr_endpoint: 是否使用 ASR 端点(不需要分块)
+
+ Returns:
+ 如果文件可能需要分块则返回 True,否则返回 False
+ """
+ if use_asr_endpoint:
+ return False
+
+ try:
+ file_size = os.path.getsize(file_path)
+ mode, limit_value = self.parse_chunk_limit()
+
+ if mode == 'size':
+ # 对于基于大小的限制,可以立即判断
+ chunk_size_bytes = limit_value * 1024 * 1024
+ needs_it = file_size > chunk_size_bytes
+ logger.info(f"文件大小检查:{file_size/1024/1024:.1f}MB vs 限制 {limit_value}MB - 需要分块:{needs_it}")
+ return needs_it
+ else:
+ # 对于基于时长的限制,需要检查实际时长
+ # 先尝试不转换直接获取时长(快速检查)
+ duration = self.get_audio_duration(file_path)
+ if duration:
+ needs_it = duration > limit_value
+ logger.info(f"文件时长检查:{duration:.1f}s vs 限制 {limit_value}s - 需要分块:{needs_it}")
+ return needs_it
+ else:
+ # 无法在不转换的情况下确定时长,假设可能需要分块
+ logger.info(f"已设置基于时长的限制({limit_value}s),但无法直接获取时长 - 将在转换后检查")
+ return True # 继续转换并检查
+ except OSError:
+ logger.error(f"无法获取文件大小:{file_path}")
+ return False
+
+ def get_audio_duration(self, file_path: str) -> Optional[float]:
+ """
+ 使用 ffprobe 获取音频文件的时长(秒)。
+
+ Args:
+ file_path: 音频文件的路径
+
+ Returns:
+ 时长(秒),如果无法确定则返回 None
+ """
+ try:
+ result = subprocess.run([
+ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
+ '-of', 'default=noprint_wrappers=1:nokey=1', file_path
+ ], capture_output=True, text=True, check=True)
+
+ duration = float(result.stdout.strip())
+ return duration
+ except (subprocess.CalledProcessError, ValueError, FileNotFoundError) as e:
+ logger.error(f"获取音频时长失败:{file_path},错误:{e}")
+ return None
+
+ def convert_to_mp3_and_get_info(self, file_path: str, temp_dir: str) -> Tuple[str, float, float]:
+ """
+ 将输入文件转换为 MP3 格式以保持一致性,并获取其大小和时长信息。
+
+ Args:
+ file_path: 源音频文件的路径
+ temp_dir: 存储临时 MP3 文件的目录
+
+ Returns:
+ 元组 (mp3_file_path, duration_seconds, size_bytes)
+ """
+ try:
+ # 生成 MP3 文件名
+ base_name = os.path.splitext(os.path.basename(file_path))[0]
+ mp3_filename = f"{base_name}_converted.mp3"
+ mp3_path = os.path.join(temp_dir, mp3_filename)
+
+ # 转换为高质量 MP3 以提高转录准确性
+ cmd = [
+ 'ffmpeg', '-i', file_path,
+ '-codec:a', 'libmp3lame', # 明确使用 LAME MP3 编码器
+ '-b:a', '128k', # 128kbps 比特率以保证高质量
+ '-ar', '44100', # 44.1kHz 采样率以获得更好质量
+ '-ac', '1', # 单声道(对语音足够)
+ '-compression_level', '2', # 更好的压缩
+ '-y', # 覆盖输出文件
+ mp3_path
+ ]
+
+ logger.info(f"正在将 {file_path} 转换为 128kbps MP3 格式以便准确分块...")
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ raise ValueError(f"ffmpeg conversion failed: {result.stderr}")
+
+ if not os.path.exists(mp3_path):
+ raise ValueError("MP3 file was not created")
+
+ # 获取转换后 MP3 文件的大小和时长
+ mp3_size = os.path.getsize(mp3_path)
+ mp3_duration = self.get_audio_duration(mp3_path)
+
+ if not mp3_duration:
+ raise ValueError("Could not determine MP3 file duration")
+
+ logger.info(f"MP3 转换完成:{mp3_size/1024/1024:.1f}MB,{mp3_duration:.1f}秒")
+
+ # 可选:保留转换后的文件用于调试(在环境变量中设置 PRESERVE_CHUNK_DEBUG=true)
+ if os.getenv('PRESERVE_CHUNK_DEBUG', 'false').lower() == 'true':
+ import shutil
+ # 将调试文件保存到 /data/uploads/debug/ 目录
+ debug_dir = '/data/uploads/debug'
+ os.makedirs(debug_dir, exist_ok=True)
+ debug_filename = os.path.basename(mp3_path).replace('_converted', '_converted_debug')
+ debug_path = os.path.join(debug_dir, debug_filename)
+ shutil.copy2(mp3_path, debug_path)
+ logger.info(f"调试:已保留转换后的文件:{debug_path}")
+
+ return mp3_path, mp3_duration, mp3_size
+
+ except Exception as e:
+ logger.error(f"音频转 MP3 失败:{e}")
+ raise
+
+ def parse_chunk_limit(self) -> Tuple[str, float]:
+ """
+ 解析 CHUNK_LIMIT 环境变量以确定分块模式和值。
+
+ 支持的格式:
+ - 基于大小:"20MB"、"10MB"
+ - 基于时长:"1200s"、"20m"
+ - 旧版:CHUNK_SIZE_MB 环境变量(向后兼容)
+
+ Returns:
+ 元组 (mode, value),其中 mode 为 'size' 或 'duration'
+ """
+ chunk_limit = os.environ.get('CHUNK_LIMIT', '').strip().upper()
+
+ # 检查新的 CHUNK_LIMIT 格式
+ if chunk_limit:
+ # 基于大小:以 MB 结尾
+ if chunk_limit.endswith('MB'):
+ try:
+ size_mb = float(re.sub(r'[^0-9.]', '', chunk_limit))
+ return 'size', size_mb
+ except ValueError:
+ logger.warning(f"CHUNK_LIMIT 格式无效:{chunk_limit}")
+
+ # 基于时长:以 s 或 m 结尾
+ elif chunk_limit.endswith('S'):
+ try:
+ seconds = float(re.sub(r'[^0-9.]', '', chunk_limit))
+ return 'duration', seconds
+ except ValueError:
+ logger.warning(f"CHUNK_LIMIT 格式无效:{chunk_limit}")
+
+ elif chunk_limit.endswith('M'):
+ try:
+ minutes = float(re.sub(r'[^0-9.]', '', chunk_limit))
+ return 'duration', minutes * 60
+ except ValueError:
+ logger.warning(f"CHUNK_LIMIT 格式无效:{chunk_limit}")
+
+ # 回退到旧版 CHUNK_SIZE_MB 以保持向后兼容
+ legacy_size = os.environ.get('CHUNK_SIZE_MB', '20')
+ try:
+ size_mb = float(legacy_size)
+ logger.info(f"使用旧版 CHUNK_SIZE_MB 配置:{size_mb}MB")
+ return 'size', size_mb
+ except ValueError:
+ logger.warning(f"CHUNK_SIZE_MB 格式无效:{legacy_size}")
+ return 'size', 20.0 # 最终回退值
+
+ def calculate_optimal_chunking(self, converted_size: float, total_duration: float) -> Tuple[int, float]:
+ """
+ 根据配置的限制计算最优的分块数量和分块时长。
+
+ Args:
+ converted_size: 转换后音频文件的大小(字节)
+ total_duration: 音频文件的总时长(秒)
+
+ Returns:
+ 元组 (num_chunks, chunk_duration_seconds)
+ """
+ try:
+ mode, limit_value = self.parse_chunk_limit()
+
+ if mode == 'size':
+ # 基于大小的分块
+ max_size_bytes = limit_value * 1024 * 1024 * 0.95 # 95% 安全系数
+ num_chunks = max(1, math.ceil(converted_size / max_size_bytes))
+
+ logger.info(f"基于大小分块:限制 {limit_value}MB")
+ logger.info(f"文件大小 {converted_size/1024/1024:.1f}MB 需要分成 {num_chunks} 块")
+
+ else: # duration-based
+ # 基于时长的分块,带有 API 安全限制
+ effective_limit = min(limit_value, 1400) # 限制在 OpenAI 安全范围内
+ num_chunks = max(1, math.ceil(total_duration / effective_limit))
+
+ logger.info(f"基于时长分块:限制 {limit_value}秒(有效限制:{effective_limit}秒)")
+ logger.info(f"文件时长 {total_duration:.1f}秒 需要分成 {num_chunks} 块")
+
+ # 计算分块时长
+ chunk_duration = total_duration / num_chunks
+
+ # 应用最小时长(5 分钟),但不超过文件总时长
+ chunk_duration = min(max(300, chunk_duration), total_duration)
+
+ # 记录最终分块计划
+ expected_chunk_size_mb = (converted_size / num_chunks) / (1024 * 1024)
+ logger.info(f"分块计划:{num_chunks} 块,每块约 {chunk_duration:.1f}秒(约 {expected_chunk_size_mb:.1f}MB)")
+
+ return num_chunks, chunk_duration
+
+ except Exception as e:
+ logger.error(f"计算最优分块策略失败:{e}")
+ # 保守回退方案
+ fallback_chunks = max(2, math.ceil(total_duration / 600)) # 10 分钟一块
+ fallback_duration = total_duration / fallback_chunks
+ return fallback_chunks, fallback_duration
+
+ def create_chunks(self, file_path: str, temp_dir: str) -> List[Dict[str, Any]]:
+ """
+ 将音频文件分割成重叠的分块。
+
+ 首先将文件转换为 MP3 格式以获取准确的大小信息,
+ 然后根据实际 MP3 文件大小计算最优分块时长。
+
+ Args:
+ file_path: 源音频文件的路径
+ temp_dir: 存储临时分块文件的目录
+
+ Returns:
+ 分块信息字典列表
+ """
+ chunks = []
+ wav_path = None
+
+ try:
+ # 步骤 1:转换为 MP3 并获取准确的大小/时长信息
+ mp3_path, mp3_duration, mp3_size = self.convert_to_mp3_and_get_info(file_path, temp_dir)
+
+ # 步骤 2:计算最优分块策略
+ num_chunks, chunk_duration = self.calculate_optimal_chunking(mp3_size, mp3_duration)
+
+ # 如果只需要 1 个分块,则无需实际分块
+ if num_chunks == 1:
+ logger.info(f"文件时长 {mp3_duration:.1f}秒在限制范围内 - 无需分块")
+ # 将整个文件作为单个"分块"返回
+ base_name = os.path.splitext(os.path.basename(file_path))[0]
+ chunk_filename = f"{base_name}_chunk_000.mp3"
+ chunk_path = os.path.join(temp_dir, chunk_filename)
+
+ # 将转换后的文件复制为单个分块
+ import shutil
+ shutil.copy2(mp3_path, chunk_path)
+
+ chunk_info = {
+ 'index': 0,
+ 'path': chunk_path,
+ 'filename': chunk_filename,
+ 'start_time': 0,
+ 'end_time': mp3_duration,
+ 'duration': mp3_duration,
+ 'size_bytes': mp3_size,
+ 'size_mb': mp3_size / (1024 * 1024)
+ }
+ chunks.append(chunk_info)
+ logger.info(f"已创建单个分块,时长:{mp3_duration:.1f}秒")
+ return chunks
+
+ # 计算步长以创建恰好 num_chunks 个分块并带有重叠
+ # 需要的总覆盖范围:mp3_duration + (overlap * (num_chunks - 1))
+ # 每个分块覆盖:chunk_duration
+ # 分块之间的步长以恰好得到 num_chunks 个分块
+ if num_chunks > 1:
+ step_duration = (mp3_duration - chunk_duration) / (num_chunks - 1)
+ else:
+ step_duration = mp3_duration
+
+ current_start = 0
+ chunk_index = 0
+
+ logger.info(f"正在将 {file_path} 分割为 {num_chunks} 块,每块约 {chunk_duration:.1f}秒,重叠 {self.overlap_seconds}秒")
+
+ for chunk_index in range(num_chunks):
+ # 计算此分块的起始位置
+ if chunk_index > 0:
+ current_start = chunk_index * step_duration
+
+ # 计算此分块的结束时间
+ chunk_end = min(current_start + chunk_duration, mp3_duration)
+ actual_duration = chunk_end - current_start
+
+ # 跳过末尾过短的分块(正确计算下不应发生)
+ if actual_duration < 10: # 小于 10 秒
+ logger.warning(f"跳过过短的分块 {chunk_index}:{actual_duration:.1f}秒")
+ break
+
+ # 生成分块文件名
+ base_name = os.path.splitext(os.path.basename(file_path))[0]
+ chunk_filename = f"{base_name}_chunk_{chunk_index:03d}.mp3"
+ chunk_path = os.path.join(temp_dir, chunk_filename)
+
+ # 从转换后的 MP3 文件中提取分块(比重新转换更高效)
+ cmd = [
+ 'ffmpeg', '-i', mp3_path,
+ '-ss', str(current_start),
+ '-t', str(actual_duration),
+ '-acodec', 'copy', # 复制编解码器,因为已经是正确的格式
+ '-y', # 覆盖输出文件
+ chunk_path
+ ]
+
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ logger.error(f"FFmpeg 分块失败(分块 {chunk_index}):{result.stderr}")
+ continue
+
+ # 验证分块已创建并获取其大小
+ if os.path.exists(chunk_path):
+ chunk_size = os.path.getsize(chunk_path)
+
+ # 验证分块大小是否在限制范围内
+ if chunk_size > self.max_chunk_size_bytes:
+ logger.warning(f"分块 {chunk_index} 大小为 {chunk_size/1024/1024:.1f}MB,超过限制 {self.max_chunk_size_mb}MB")
+
+ chunk_info = {
+ 'index': chunk_index,
+ 'path': chunk_path,
+ 'filename': chunk_filename,
+ 'start_time': current_start,
+ 'end_time': chunk_end,
+ 'duration': actual_duration,
+ 'size_bytes': chunk_size,
+ 'size_mb': chunk_size / (1024 * 1024)
+ }
+
+ chunks.append(chunk_info)
+ logger.info(f"已创建分块 {chunk_index}:{current_start:.1f}秒-{chunk_end:.1f}秒({chunk_size/1024/1024:.1f}MB)")
+
+ # 可选:保留分块用于调试(在环境变量中设置 PRESERVE_CHUNK_DEBUG=true)
+ if os.getenv('PRESERVE_CHUNK_DEBUG', 'false').lower() == 'true':
+ import shutil
+ # 将调试分块保存到 /data/uploads/debug/ 目录
+ debug_dir = '/data/uploads/debug'
+ os.makedirs(debug_dir, exist_ok=True)
+ debug_filename = os.path.basename(chunk_path).replace('.mp3', '_debug.mp3')
+ debug_path = os.path.join(debug_dir, debug_filename)
+ shutil.copy2(chunk_path, debug_path)
+ logger.info(f"调试:已保留分块文件:{debug_path}")
+ else:
+ logger.error(f"分块文件未创建:{chunk_path}")
+
+ logger.info(f"已为 {file_path} 创建 {len(chunks)} 个分块")
+ return chunks
+
+ except Exception as e:
+ logger.error(f"创建分块失败({file_path}):{e}")
+ # 清理任何部分创建的分块
+ for chunk in chunks:
+ try:
+ if os.path.exists(chunk['path']):
+ os.remove(chunk['path'])
+ except Exception:
+ pass
+ raise
+ finally:
+ # 清理临时 WAV 文件
+ if wav_path and os.path.exists(wav_path):
+ try:
+ os.remove(wav_path)
+ logger.debug(f"已清理临时 WAV 文件:{wav_path}")
+ except Exception as e:
+ logger.warning(f"清理临时 WAV 文件失败:{e}")
+
+ def merge_transcriptions(self, chunk_results: List[Dict[str, Any]]) -> str:
+ """
+ 合并多个分块的转录结果,处理重叠部分。
+
+ Args:
+ chunk_results: 分块转录结果列表
+
+ Returns:
+ 合并后的转录文本
+ """
+ if not chunk_results:
+ return ""
+
+ if len(chunk_results) == 1:
+ return chunk_results[0].get('transcription', '')
+
+ # 按开始时间排序分块以确保正确顺序
+ sorted_chunks = sorted(chunk_results, key=lambda x: x.get('start_time', 0))
+
+ merged_text = ""
+
+ for i, chunk in enumerate(sorted_chunks):
+ chunk_text = chunk.get('transcription', '').strip()
+
+ if not chunk_text:
+ continue
+
+ if i == 0:
+ # 第一个分块:使用完整转录
+ merged_text = chunk_text
+ else:
+ # 后续分块:尝试处理重叠
+ merged_text = self._merge_overlapping_text(
+ merged_text,
+ chunk_text,
+ chunk.get('start_time', 0),
+ sorted_chunks[i-1].get('end_time', 0)
+ )
+
+ return merged_text
+
+ def _merge_overlapping_text(self, existing_text: str, new_text: str,
+ new_start_time: float, prev_end_time: float) -> str:
+ """
+ 合并重叠的转录文本,尝试去除重复内容。
+
+ Args:
+ existing_text: 之前合并的文本
+ new_text: 要合并的新分块文本
+ new_start_time: 新分块的开始时间
+ prev_end_time: 上一个分块的结束时间
+
+ Returns:
+ 处理重叠后的合并文本
+ """
+ # 如果没有重叠,直接连接
+ overlap_duration = prev_end_time - new_start_time
+ if overlap_duration <= 0:
+ return f"{existing_text}\n{new_text}"
+
+ # 对于重叠的分块,尝试找到共同文本并智能合并
+ # 这是一种简化的方法 - 实际上可能需要更复杂的文本相似度匹配
+
+ # 将文本分割成句子/短语
+ existing_sentences = self._split_into_sentences(existing_text)
+ new_sentences = self._split_into_sentences(new_text)
+
+ if not existing_sentences or not new_sentences:
+ return f"{existing_text}\n{new_text}"
+
+ # 通过比较现有文本的最后几个句子和新文本的前几个句子来尝试找到重叠
+ overlap_found = False
+ merge_point = len(existing_sentences)
+
+ # 查找共同句子(简单方法)
+ for i in range(min(3, len(existing_sentences))): # 检查最后 3 个句子
+ last_sentence = existing_sentences[-(i+1)].strip().lower()
+
+ for j in range(min(3, len(new_sentences))): # 检查前 3 个句子
+ first_sentence = new_sentences[j].strip().lower()
+
+ # 如果句子足够相似,则认为是重叠
+ if last_sentence and first_sentence and self._sentences_similar(last_sentence, first_sentence):
+ merge_point = len(existing_sentences) - i
+ new_start_index = j + 1
+ overlap_found = True
+ break
+
+ if overlap_found:
+ break
+
+ if overlap_found:
+ # 在找到的重叠点合并
+ merged_sentences = existing_sentences[:merge_point] + new_sentences[new_start_index:]
+ return ' '.join(merged_sentences)
+ else:
+ # 没有找到明确的重叠,用分隔符连接
+ return f"{existing_text}\n{new_text}"
+
+ def _split_into_sentences(self, text: str) -> List[str]:
+ """将文本分割成句子用于重叠检测。"""
+ import re
+ # 简单的句子分割 - 可以使用更复杂的 NLP 方法改进
+ sentences = re.split(r'[.!?]+', text)
+ return [s.strip() for s in sentences if s.strip()]
+
+ def _sentences_similar(self, sent1: str, sent2: str, threshold: float = 0.8) -> bool:
+ """检查两个句子是否足够相似以被视为相同。"""
+ # 基于共同词的简单相似度检查
+ words1 = set(sent1.split())
+ words2 = set(sent2.split())
+
+ if not words1 or not words2:
+ return False
+
+ intersection = len(words1.intersection(words2))
+ union = len(words1.union(words2))
+
+ similarity = intersection / union if union > 0 else 0
+ return similarity >= threshold
+
+ def analyze_chunk_audio_properties(self, chunk_path: str) -> Dict[str, Any]:
+ """
+ 当前未被使用,保留接口。
+
+ 分析分块的音频属性,比如时长、大小、码率、采样率、声道数、编码器、采样位数等,这些属性可能影响处理时间。
+
+ Args:
+ chunk_path: 分块文件的路径
+
+ Returns:
+ 包含音频分析结果的字典
+ """
+ try:
+ # 使用 ffprobe 获取详细的音频信息
+ cmd = [
+ 'ffprobe', '-v', 'quiet', '-print_format', 'json',
+ '-show_format', '-show_streams', chunk_path
+ ]
+
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
+ probe_data = json.loads(result.stdout)
+
+ audio_stream = None
+ for stream in probe_data.get('streams', []):
+ if stream.get('codec_type') == 'audio':
+ audio_stream = stream
+ break
+
+ if not audio_stream:
+ return {'error': 'No audio stream found'}
+
+ format_info = probe_data.get('format', {})
+
+ analysis = {
+ 'duration': float(format_info.get('duration', 0)),
+ 'size_bytes': int(format_info.get('size', 0)),
+ 'bitrate': int(format_info.get('bit_rate', 0)),
+ 'sample_rate': int(audio_stream.get('sample_rate', 0)),
+ 'channels': int(audio_stream.get('channels', 0)),
+ 'codec': audio_stream.get('codec_name', 'unknown'),
+ 'bits_per_sample': int(audio_stream.get('bits_per_raw_sample', 0)),
+ }
+
+ # 计算一些派生指标
+ if analysis['duration'] > 0:
+ analysis['effective_bitrate'] = (analysis['size_bytes'] * 8) / analysis['duration']
+ analysis['compression_ratio'] = analysis['bitrate'] / analysis['effective_bitrate'] if analysis['effective_bitrate'] > 0 else 0
+
+ return analysis
+
+ except Exception as e:
+ logger.warning(f"分析分块音频属性失败:{e}")
+ return {'error': str(e)}
+
+ def log_processing_statistics(self, chunk_results: List[Dict[str, Any]]) -> None:
+ """
+ 记录关于分块处理性能的详细统计信息。
+
+ Args:
+ chunk_results: 包含时间信息的分块处理结果列表
+ """
+ if not chunk_results:
+ return
+
+ logger.info("=== 分块处理统计 ===")
+
+ total_chunks = len(chunk_results)
+ processing_times = []
+ sizes = []
+ durations = []
+
+ for i, result in enumerate(chunk_results):
+ processing_time = result.get('processing_time', 0)
+ chunk_size = result.get('size_mb', 0)
+ chunk_duration = result.get('duration', 0)
+
+ processing_times.append(processing_time)
+ sizes.append(chunk_size)
+ durations.append(chunk_duration)
+
+ # 记录单个分块统计信息
+ rate = chunk_duration / processing_time if processing_time > 0 else 0
+ logger.info(f"分块 {i+1}:处理耗时 {processing_time:.1f}秒,大小 {chunk_size:.1f}MB,音频时长 {chunk_duration:.1f}秒(处理速率:{rate:.2f}倍)")
+
+ # 计算汇总统计信息
+ if processing_times:
+ avg_time = sum(processing_times) / len(processing_times)
+ min_time = min(processing_times)
+ max_time = max(processing_times)
+
+ avg_size = sum(sizes) / len(sizes)
+ avg_duration = sum(durations) / len(durations)
+
+ total_audio_time = sum(durations)
+ total_processing_time = sum(processing_times)
+ overall_rate = total_audio_time / total_processing_time if total_processing_time > 0 else 0
+
+ logger.info(f"汇总:{total_chunks} 个分块,{total_audio_time:.1f}秒音频,处理耗时 {total_processing_time:.1f}秒")
+ logger.info(f"平均:处理耗时 {avg_time:.1f}秒,大小 {avg_size:.1f}MB,音频时长 {avg_duration:.1f}秒")
+ logger.info(f"范围:处理耗时 {min_time:.1f}秒 - {max_time:.1f}秒")
+ logger.info(f"总体处理速率:{overall_rate:.2f}倍实时")
+
+ # 识别性能异常值
+ if max_time > avg_time * 2:
+ slow_chunks = [i for i, t in enumerate(processing_times) if t > avg_time * 1.5]
+ logger.warning(f"检测到性能异常分块:{[i+1 for i in slow_chunks]} 号分块处理耗时显著偏高")
+
+ # 建议可能的原因
+ logger.info("处理缓慢的可能原因:")
+ logger.info("- OpenAI API 服务器负载/性能波动")
+ logger.info("- 网络延迟或连接问题")
+ logger.info("- 音频内容复杂度(静音、噪音、多说话人)")
+ logger.info("- 临时 API 限流或限速")
+
+ logger.info("=== 统计结束 ===")
+
+ def get_performance_recommendations(self, chunk_results: List[Dict[str, Any]]) -> List[str]:
+ """
+ 根据处理结果生成性能建议。
+
+ 给出了建议,但调用者并未做后续调优逻辑,暂不明用途。
+
+ Args:
+ chunk_results: 分块处理结果列表
+
+ Returns:
+ 建议字符串列表
+ """
+ recommendations = []
+
+ if not chunk_results:
+ return recommendations
+
+ processing_times = [r.get('processing_time', 0) for r in chunk_results]
+
+ if processing_times:
+ avg_time = sum(processing_times) / len(processing_times)
+ max_time = max(processing_times)
+
+ # 检查处理时间的高方差
+ if max_time > avg_time * 3:
+ recommendations.append("检测到处理时间波动较大,建议实现指数退避重试逻辑。")
+
+ # 检查整体处理速度过慢
+ total_audio = sum(r.get('duration', 0) for r in chunk_results)
+ total_processing = sum(processing_times)
+ rate = total_audio / total_processing if total_processing > 0 else 0
+
+ if rate < 0.5: # 低于 0.5 倍实时速度
+ recommendations.append("整体处理较慢,建议减小分块大小或考虑更换转录服务。")
+
+ # 检查超时问题
+ if any(t > 300 for t in processing_times): # 超过 5 分钟
+ recommendations.append("部分分块处理超过5分钟,建议实现超时处理和分块重试逻辑。")
+
+ # 检查分块大小优化
+ avg_size = sum(r.get('size_mb', 0) for r in chunk_results) / len(chunk_results)
+ if avg_size < 10:
+ recommendations.append("分块较小,建议增大大分块大小以提高处理效率。")
+ elif avg_size > 22:
+ recommendations.append("分块接近大小限制,建议减小分块大小以提高处理稳定性。")
+
+ return recommendations
+
+ def cleanup_chunks(self, chunks: List[Dict[str, Any]], temp_mp3_path: str = None) -> None:
+ """
+ 清理临时分块文件和 MP3 文件。
+
+ Args:
+ chunks: 分块信息字典列表
+ temp_mp3_path: 要清理的临时 MP3 文件路径(可选)
+ """
+ for chunk in chunks:
+ try:
+ chunk_path = chunk.get('path')
+ if chunk_path and os.path.exists(chunk_path):
+ os.remove(chunk_path)
+ logger.debug(f"已清理分块文件:{chunk_path}")
+ except Exception as e:
+ logger.warning(f"清理分块失败({chunk.get('filename', 'unknown')}):{e}")
+
+ # 清理临时 MP3 文件(如果提供)
+ if temp_mp3_path and os.path.exists(temp_mp3_path):
+ try:
+ os.remove(temp_mp3_path)
+ logger.debug(f"已清理临时 MP3 文件:{temp_mp3_path}")
+ except Exception as e:
+ logger.warning(f"清理临时 MP3 文件失败:{e}")
+
+class ChunkProcessingError(Exception):
+ """分块处理失败时引发的异常。"""
+ pass
+
+class ChunkingNotSupportedError(Exception):
+ """当前配置不支持分块时引发的异常。"""
+ pass
diff --git a/speakr/src/services/auth_service.py b/speakr/src/services/auth_service.py
new file mode 100644
index 0000000..58e5ae1
--- /dev/null
+++ b/speakr/src/services/auth_service.py
@@ -0,0 +1,66 @@
+"""
+认证服务 - 处理用户认证和安全验证相关功能
+"""
+import logging
+from urllib.parse import urlparse, urljoin
+from flask import request
+from flask_login import current_user, login_user
+
+from src.clients import db
+from src.models import User
+
+logger = logging.getLogger(__name__)
+
+
+def is_safe_url(target):
+ """
+ 当前未被使用,保留接口。
+
+ 检查目标 URL 是否安全(同源)
+
+ 用于防止开放重定向攻击
+
+ Args:
+ target: 目标 URL 字符串
+
+ Returns:
+ bool: 如果 URL 安全返回 True,否则返回 False
+ """
+ ref_url = urlparse(request.host_url)
+ test_url = urlparse(urljoin(request.host_url, target))
+ return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
+
+
+def auto_login():
+ """
+ 自动登录默认用户
+
+ 仅当没有其他用户已登录时执行自动登录。
+ 如果默认用户 'admin' 不存在,则自动创建。
+
+ Returns:
+ User: 登录的用户对象
+ """
+ from src.flask_ext import bcrypt
+
+ if current_user.is_authenticated:
+ return current_user
+
+ user = User.query.filter_by(username='admin').first()
+ if not user:
+ # 如果默认用户不存在,则创建
+ hashed_password = bcrypt.generate_password_hash('changeme').decode('utf-8')
+ user = User(
+ id=str(__import__('uuid').uuid4()),
+ username='admin',
+ email='259908888@qq.com',
+ password=hashed_password,
+ is_admin=True # 设置为管理员以获得完全访问权限
+ )
+ db.session.add(user)
+ db.session.commit()
+ logger.info("已创建默认用户 'admin',密码为 'changeme'")
+
+ # 登录用户
+ login_user(user)
+ return user
diff --git a/speakr/src/services/auto_process_service.py b/speakr/src/services/auto_process_service.py
new file mode 100644
index 0000000..317a6a9
--- /dev/null
+++ b/speakr/src/services/auto_process_service.py
@@ -0,0 +1,71 @@
+"""
+自动处理服务 - 处理文件监控和自动转写相关功能
+
+这个服务日常不启用,开启需要在环境变量中设置 ENABLE_AUTO_PROCESSING=true 同时配置相关环境变量 后重启应用
+"""
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def initialize_file_monitor():
+ """
+ 初始化文件监控
+
+ 在应用完全加载后调用此函数以避免循环导入
+
+ Returns:
+ bool: 初始化成功返回 True,失败返回 False
+ """
+ try:
+ from flask import has_app_context, current_app
+ # 延迟导入 file_monitor 以避免循环导入
+ import src.services.file_monitor as file_monitor
+
+ # 获取实际的 app 对象而不是 current_app 代理
+ if has_app_context():
+ app = current_app._get_current_object()
+ file_monitor.start_file_monitor(app=app)
+ else:
+ # 如果不在应用上下文中,尝试无参数调用
+ file_monitor.start_file_monitor()
+
+ if has_app_context():
+ current_app.logger.info("文件监控初始化完成")
+ return True
+ except Exception as e:
+ logger.warning(f"文件监控初始化失败: {e}")
+ return False
+
+
+def get_file_monitor_functions():
+ """
+ 获取文件监控相关函数
+
+ 优雅地处理导入错误,如果 file_monitor 模块不可用,降级为返回存根函数
+
+ Returns:
+ tuple: (start_file_monitor, stop_file_monitor, get_file_monitor_status)
+ """
+ try:
+ from flask import current_app
+ import src.services.file_monitor as file_monitor
+ return (
+ file_monitor.start_file_monitor,
+ file_monitor.stop_file_monitor,
+ file_monitor.get_file_monitor_status
+ )
+ except ImportError as e:
+ logger.warning(f"文件监控模块不可用: {e}")
+
+ # 如果 file_monitor 不可用,返回存根函数
+ def start_file_monitor():
+ pass
+
+ def stop_file_monitor():
+ pass
+
+ def get_file_monitor_status():
+ return {'running': False, 'error': '文件监控模块不可用'}
+
+ return start_file_monitor, stop_file_monitor, get_file_monitor_status
diff --git a/speakr/src/services/document_service.py b/speakr/src/services/document_service.py
new file mode 100644
index 0000000..6d6302a
--- /dev/null
+++ b/speakr/src/services/document_service.py
@@ -0,0 +1,430 @@
+"""
+文档服务 - 处理 Word 文档导出相关功能
+"""
+import re
+import logging
+from urllib.parse import quote
+
+logger = logging.getLogger(__name__)
+
+
+def process_markdown_to_docx(doc, content):
+ """
+ 将 Markdown 内容转换为格式化的 Word 文档元素
+
+ 支持的功能:
+ - 表格 (Markdown 管道表格)
+ - 标题 (# ## ###)
+ - 粗体 (**text**)
+ - 斜体 (*text* 或 _text_)
+ - 粗斜体 (***text***)
+ - 行内代码 (`code`)
+ - 代码块 (```code```)
+ - 删除线 (~~text~~)
+ - 链接 ([text](url))
+ - 无序列表 (- 或 *)
+ - 有序列表 (1. 2. 3.)
+ - 水平线 (--- 或 ***)
+ """
+ from docx.shared import RGBColor, Pt
+ from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
+ from docx.oxml.ns import qn
+
+ def ensure_unicode_font(run, text):
+ """确保 run 使用支持文本字符的字体"""
+ try:
+ text.encode('ascii')
+ # 纯 ASCII 文本,无需特殊字体
+ except UnicodeEncodeError:
+ # 包含非 ASCII 字符,使用支持 Unicode 的字体
+ run.font.name = 'Arial'
+ # 设置东亚字体以支持中日韩字符
+ r = run._element
+ r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial')
+ return run
+
+ def add_formatted_run(paragraph, text):
+ """向段落添加带有行内格式的 run"""
+ if not text:
+ return
+
+ # 行内格式匹配模式(顺序很重要:先检查三个星号,再检查两个/一个)
+ patterns = [
+ (r'\*\*\*(.*?)\*\*\*', lambda p, t: (lambda r: (setattr(r, 'bold', True), setattr(r, 'italic', True), ensure_unicode_font(r, t)))(p.add_run(t))), # 粗斜体
+ (r'\*\*(.*?)\*\*', lambda p, t: (lambda r: (setattr(r, 'bold', True), ensure_unicode_font(r, t)))(p.add_run(t))), # 粗体
+ (r'(? 0:
+ run = paragraph.add_run(remaining_text[:earliest_pos])
+ ensure_unicode_font(run, remaining_text[:earliest_pos])
+
+ # 应用匹配到的格式
+ if '[' in earliest_match.group(0) and '](' in earliest_match.group(0):
+ # 链接特殊处理(两个分组)
+ matched_pattern(paragraph, earliest_match.group(1), earliest_match.group(2))
+ else:
+ matched_pattern(paragraph, earliest_match.group(1))
+
+ # 继续处理剩余文本
+ remaining_text = remaining_text[earliest_match.end():]
+ else:
+ # 没有更多模式,添加剩余文本
+ run = paragraph.add_run(remaining_text)
+ ensure_unicode_font(run, remaining_text)
+ break
+
+ def parse_table(lines, start_idx):
+ """解析从指定索引开始的 Markdown 表格"""
+ if start_idx >= len(lines):
+ return None, start_idx
+
+ if '|' not in lines[start_idx]:
+ return None, start_idx
+
+ table_data = []
+ idx = start_idx
+
+ while idx < len(lines) and '|' in lines[idx]:
+ # 跳过分隔线
+ if re.match(r'^[\s\|\-:]+$', lines[idx]):
+ idx += 1
+ continue
+
+ # 解析单元格
+ cells = [cell.strip() for cell in lines[idx].split('|')]
+ if cells and not cells[0]:
+ cells = cells[1:]
+ if cells and not cells[-1]:
+ cells = cells[:-1]
+
+ if cells:
+ table_data.append(cells)
+ idx += 1
+
+ if table_data:
+ return table_data, idx
+ return None, start_idx
+
+ # 按行分割内容
+ lines = content.split('\n')
+ i = 0
+ in_code_block = False
+ code_block_content = []
+
+ while i < len(lines):
+ line = lines[i]
+
+ # 处理代码块
+ if line.strip().startswith('```'):
+ if not in_code_block:
+ in_code_block = True
+ code_block_content = []
+ else:
+ # 代码块结束 - 添加为预格式化文本
+ in_code_block = False
+ if code_block_content:
+ p = doc.add_paragraph()
+ p.style = 'Normal'
+ code_text = '\n'.join(code_block_content)
+ run = p.add_run(code_text)
+ run.font.name = 'Courier New'
+ run.font.size = Pt(10)
+ run.font.color.rgb = RGBColor(64, 64, 64)
+ try:
+ code_text.encode('ascii')
+ except UnicodeEncodeError:
+ r = run._element
+ r.rPr.rFonts.set(qn('w:eastAsia'), 'Consolas')
+ i += 1
+ continue
+
+ if in_code_block:
+ code_block_content.append(line)
+ i += 1
+ continue
+
+ # 检查表格
+ table_data, end_idx = parse_table(lines, i)
+ if table_data:
+ # 创建 Word 表格
+ table = doc.add_table(rows=len(table_data), cols=len(table_data[0]))
+ table.style = 'Table Grid'
+
+ # 填充表格
+ for row_idx, row_data in enumerate(table_data):
+ for col_idx, cell_text in enumerate(row_data):
+ if col_idx < len(table.rows[row_idx].cells):
+ cell = table.rows[row_idx].cells[col_idx]
+ cell.text = ""
+ p = cell.add_paragraph()
+ add_formatted_run(p, cell_text)
+ # 表头行加粗
+ if row_idx == 0:
+ for run in p.runs:
+ run.bold = True
+
+ doc.add_paragraph('') # 表格后添加空行
+ i = end_idx
+ continue
+
+ line = line.rstrip()
+
+ # 跳过空行
+ if not line:
+ doc.add_paragraph('')
+ i += 1
+ continue
+
+ # 水平线
+ if re.match(r'^(\*{3,}|-{3,}|_{3,})$', line.strip()):
+ p = doc.add_paragraph('─' * 50)
+ p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
+ i += 1
+ continue
+
+ # 标题
+ if line.startswith('# '):
+ doc.add_heading(line[2:], 1)
+ elif line.startswith('## '):
+ doc.add_heading(line[3:], 2)
+ elif line.startswith('### '):
+ doc.add_heading(line[4:], 3)
+ elif line.startswith('#### '):
+ doc.add_heading(line[5:], 4)
+ # 无序列表
+ elif line.lstrip().startswith('- ') or line.lstrip().startswith('* '):
+ indent = len(line) - len(line.lstrip())
+ bullet_text = line.lstrip()[2:]
+ p = doc.add_paragraph(style='List Bullet')
+ if indent > 0:
+ p.paragraph_format.left_indent = Pt(indent * 10)
+ add_formatted_run(p, bullet_text)
+ # 有序列表
+ elif re.match(r'^\s*\d+\.', line):
+ match = re.match(r'^(\s*)(\d+)\.\s*(.*)', line)
+ if match:
+ indent = len(match.group(1))
+ list_text = match.group(3)
+ p = doc.add_paragraph(style='List Number')
+ if indent > 0:
+ p.paragraph_format.left_indent = Pt(indent * 10)
+ add_formatted_run(p, list_text)
+ # 引用块
+ elif line.startswith('> '):
+ p = doc.add_paragraph()
+ p.paragraph_format.left_indent = Pt(30)
+ add_formatted_run(p, line[2:])
+ for run in p.runs:
+ run.font.color.rgb = RGBColor(100, 100, 100)
+ else:
+ # 普通段落
+ p = doc.add_paragraph()
+ add_formatted_run(p, line)
+
+ i += 1
+
+
+def add_unicode_paragraph(doc, text):
+ """
+ 向 Word 文档添加段落,自动处理非 ASCII 字符的字体支持
+
+ Args:
+ doc: Document 对象
+ text: 段落文本
+
+ Returns:
+ p: 段落对象
+ """
+ p = doc.add_paragraph(text)
+ try:
+ text.encode('ascii')
+ except UnicodeEncodeError:
+ from docx.oxml.ns import qn
+ for run in p.runs:
+ run.font.name = 'Arial'
+ r = run._element
+ r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial')
+ return p
+
+
+def create_docx_with_unicode_title(doc, title_text, level=0):
+ """
+ 向 Word 文档添加标题,自动处理非 ASCII 字符的字体支持
+
+ Args:
+ doc: Document 对象
+ title_text: 标题文本
+ level: 标题级别(0-4)
+
+ Returns:
+ title: 标题段落对象
+ """
+ title = doc.add_heading(title_text, level)
+ try:
+ title_text.encode('ascii')
+ except UnicodeEncodeError:
+ from docx.oxml.ns import qn
+ for run in title.runs:
+ run.font.name = 'Arial'
+ r = run._element
+ r.rPr.rFonts.set(qn('w:eastAsia'), 'Arial')
+ return title
+
+
+def save_and_send_docx(doc, filename, fallback_filename, send_file_func=None):
+ """
+ 保存 Word 文档到内存流并返回 Flask 响应
+
+ Args:
+ doc: Document 对象
+ filename: 完整文件名(支持 Unicode)
+ fallback_filename: ASCII 后备文件名
+ send_file_func: send_file 函数(默认从 flask 导入)
+
+ Returns:
+ Flask Response 对象
+ """
+ from io import BytesIO
+ from flask import send_file
+
+ doc_stream = BytesIO()
+ doc.save(doc_stream)
+ doc_stream.seek(0)
+
+ response = send_file(
+ doc_stream,
+ as_attachment=False,
+ mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ )
+
+ set_download_filename_header(response, filename, fallback_filename)
+ return response
+
+
+def get_recording_display_title(recording, fallback='未命名录音'):
+ """
+ 返回用户友好的录音标题,用于文档导出
+
+ Args:
+ recording: 录音对象
+ fallback: 默认标题
+
+ Returns:
+ str: 录音标题
+ """
+ title = (recording.title or '').strip() if recording else ''
+ return title or fallback
+
+
+def sanitize_download_filename_component(value, fallback=''):
+ """
+ 清理下载文件名,保留 Unicode 字符但移除文件名中无效的字符
+
+ Args:
+ value: 原始文件名
+ fallback: 默认值
+
+ Returns:
+ str: 清理后的文件名
+ """
+ safe_value = re.sub(r'[<>:"/\\|?*]', '', str(value or '')).strip()
+ safe_value = re.sub(r'\s+', '_', safe_value)
+ safe_value = safe_value.strip('._')
+ return safe_value or fallback
+
+
+def build_docx_download_filename(prefix, recording, recording_id, extra_parts=None):
+ """
+ 构建本地化的 DOCX 下载文件名,支持安全的 Unicode 标题
+
+ Args:
+ prefix: 文件名前缀
+ recording: 录音对象
+ recording_id: 录音 ID
+ extra_parts: 额外的文件名部分列表
+
+ Returns:
+ str: 完整的下载文件名
+ """
+ parts = [sanitize_download_filename_component(prefix, '文档')]
+ for part in extra_parts or []:
+ safe_part = sanitize_download_filename_component(part)
+ if safe_part:
+ parts.append(safe_part)
+
+ parts.append(
+ sanitize_download_filename_component(
+ get_recording_display_title(recording),
+ f'录音_{recording_id}'
+ )
+ )
+ return '_'.join(parts) + '.docx'
+
+
+def set_download_filename_header(response, filename, fallback_filename):
+ """
+ 设置下载文件名响应头,支持 UTF-8 和 ASCII 后备
+
+ Args:
+ response: Flask 响应对象
+ filename: 完整文件名(支持 Unicode)
+ fallback_filename: ASCII 后备文件名
+
+ Returns:
+ response: 设置了文件名头的响应对象
+ """
+ fallback = (
+ sanitize_download_filename_component(fallback_filename)
+ .encode('ascii', 'ignore')
+ .decode('ascii')
+ or 'download.docx'
+ )
+ encoded_filename = quote(filename)
+ response.headers['Content-Disposition'] = (
+ f'attachment; filename="{fallback}"; filename*=utf-8\'\'{encoded_filename}'
+ )
+ return response
diff --git a/speakr/src/services/embedding_service.py b/speakr/src/services/embedding_service.py
new file mode 100644
index 0000000..94ad479
--- /dev/null
+++ b/speakr/src/services/embedding_service.py
@@ -0,0 +1,335 @@
+"""
+Embedding 服务 - 处理语义搜索和文本分块
+
+当前没启用,启用需要修改环境变量 EMBEDDINGS_AVAILABLE 为 True
+"""
+import logging
+import numpy as np
+from sentence_transformers import SentenceTransformer
+from sklearn.metrics.pairwise import cosine_similarity
+from sqlalchemy.orm import joinedload
+from src.clients import db
+from src.models import Recording, TranscriptChunk, RecordingTag
+
+logger = logging.getLogger(__name__)
+
+# 全局 Embedding 模型实例
+_embedding_model = None
+
+# 检查 Embedding 功能是否可用
+EMBEDDINGS_AVAILABLE = False
+try:
+ import numpy as np
+ from sentence_transformers import SentenceTransformer
+ from sklearn.metrics.pairwise import cosine_similarity
+ EMBEDDINGS_AVAILABLE = True
+except ImportError:
+ pass
+
+
+def get_embedding_model():
+ """获取或初始化 Sentence Transformer 模型"""
+ global _embedding_model
+
+ if not EMBEDDINGS_AVAILABLE:
+ return None
+
+ if _embedding_model is None:
+ try:
+ _embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
+ logger.info("Embedding 模型加载成功")
+ except Exception as e:
+ logger.error(f"Embedding 模型加载失败:{e}")
+ return None
+ return _embedding_model
+
+
+def chunk_transcription(transcription, max_chunk_length=500, overlap=50):
+ """
+ 将转录文本分割为带重叠的 chunk,以便更好地进行上下文检索
+
+ Args:
+ transcription (str): 完整的转录文本
+ max_chunk_length (int): 每个 chunk 的最大字符数
+ overlap (int): chunk 之间的字符重叠量
+
+ Returns:
+ list: 文本 chunk 列表
+ """
+ if not transcription or len(transcription) <= max_chunk_length:
+ return [transcription] if transcription else []
+
+ chunks = []
+ start = 0
+
+ while start < len(transcription):
+ end = start + max_chunk_length
+
+ # 尝试在句子边界处分割
+ if end < len(transcription):
+ # 在最后 100 个字符内查找句子结尾
+ sentence_end = -1
+ for i in range(max(0, end - 100), end):
+ if transcription[i] in '.!?':
+ # 检查是否不是缩写
+ if i + 1 < len(transcription) and transcription[i + 1].isspace():
+ sentence_end = i + 1
+
+ if sentence_end > start:
+ end = sentence_end
+
+ chunk = transcription[start:end].strip()
+ if chunk:
+ chunks.append(chunk)
+
+ # 移动起始位置,包含重叠部分
+ start = max(start + 1, end - overlap)
+
+ # 防止无限循环
+ if start >= len(transcription):
+ break
+
+ return chunks
+
+
+def generate_embeddings(texts):
+ """
+ 为文本列表生成 embedding 向量
+
+ Args:
+ texts (list): 文本字符串列表
+
+ Returns:
+ list: embedding 向量列表(numpy 数组),如果 embedding 不可用则返回空列表
+ """
+ if not EMBEDDINGS_AVAILABLE:
+ logger.warning("Embedding 功能不可用 - 跳过 embedding 生成")
+ return []
+
+ model = get_embedding_model()
+ if not model or not texts:
+ return []
+
+ try:
+ embeddings = model.encode(texts)
+ return [embedding.astype(np.float32) for embedding in embeddings]
+ except Exception as e:
+ logger.error(f"生成 embedding 向量时出错:{e}")
+ return []
+
+
+def serialize_embedding(embedding):
+ """将 numpy 数组转换为二进制以便数据库存储"""
+ if embedding is None or not EMBEDDINGS_AVAILABLE:
+ return None
+ return embedding.tobytes()
+
+
+def deserialize_embedding(binary_data):
+ """将二进制数据转换回 numpy 数组"""
+ if binary_data is None or not EMBEDDINGS_AVAILABLE:
+ return None
+ return np.frombuffer(binary_data, dtype=np.float32)
+
+
+def process_recording_chunks(recording_id):
+ """
+ 处理录音:创建 chunk 并生成 embedding
+ 应在录音转录完成后调用此函数
+ """
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording or not recording.transcription:
+ return False
+
+ # 删除此录音的现有 chunk
+ TranscriptChunk.query.filter_by(recording_id=recording_id).delete()
+
+ # 创建 chunk
+ chunks = chunk_transcription(recording.transcription)
+
+ if not chunks:
+ return True
+
+ # 生成 embedding
+ embeddings = generate_embeddings(chunks)
+
+ # 将 chunk 存储到数据库
+ for i, (chunk_text, embedding) in enumerate(zip(chunks, embeddings)):
+ chunk = TranscriptChunk(
+ recording_id=recording_id,
+ user_id=recording.user_id,
+ chunk_index=i,
+ content=chunk_text,
+ embedding=serialize_embedding(embedding) if embedding is not None else None
+ )
+ db.session.add(chunk)
+
+ db.session.commit()
+ logger.info(f"为录音 {recording_id} 创建了 {len(chunks)} 个 chunk")
+ return True
+
+ except Exception as e:
+ logger.error(f"处理录音 {recording_id} 的 chunk 时出错:{e}")
+ db.session.rollback()
+ return False
+
+
+def basic_text_search_chunks(user_id, query, filters=None, top_k=5):
+ """
+ 当 embedding 不可用时使用的基本文本搜索回退方案
+ 使用简单文本匹配代替语义搜索
+ """
+ try:
+ # 构建 chunk 的基础查询
+ chunks_query = TranscriptChunk.query.filter_by(user_id=user_id)
+
+ # 如果提供了过滤器则应用
+ if filters:
+ if filters.get('tag_ids'):
+ chunks_query = chunks_query.join(Recording).join(
+ RecordingTag, Recording.id == RecordingTag.recording_id
+ ).filter(RecordingTag.tag_id.in_(filters['tag_ids']))
+
+ if filters.get('speaker_names'):
+ # 通过录音的 participants 字段过滤,而不是 chunk 的 speaker_name
+ if not any(hasattr(desc, 'name') and desc.name == 'recording' for desc in chunks_query.column_descriptions):
+ chunks_query = chunks_query.join(Recording)
+
+ # 为每个说话人名称构建 OR 条件
+ speaker_conditions = []
+ for speaker_name in filters['speaker_names']:
+ speaker_conditions.append(
+ Recording.participants.ilike(f'%{speaker_name}%')
+ )
+
+ chunks_query = chunks_query.filter(db.or_(*speaker_conditions))
+ logger.info(f"已应用说话人过滤器:{filters['speaker_names']}")
+
+ if filters.get('recording_ids'):
+ chunks_query = chunks_query.filter(
+ TranscriptChunk.recording_id.in_(filters['recording_ids'])
+ )
+
+ if filters.get('date_from') or filters.get('date_to'):
+ chunks_query = chunks_query.join(Recording)
+ if filters.get('date_from'):
+ chunks_query = chunks_query.filter(Recording.meeting_date >= filters['date_from'])
+ if filters.get('date_to'):
+ chunks_query = chunks_query.filter(Recording.meeting_date <= filters['date_to'])
+
+ # 简单文本搜索 - 将查询拆分为单词并搜索
+ query_words = query.lower().split()
+ if query_words:
+ # 创建一个过滤器,匹配内容中的任意查询单词
+ text_conditions = []
+ for word in query_words:
+ text_conditions.append(TranscriptChunk.content.ilike(f'%{word}%'))
+
+ # 使用 OR 组合条件
+ from sqlalchemy import or_
+ chunks_query = chunks_query.filter(or_(*text_conditions))
+
+ # 获取 chunk 并返回(带有虚拟相似度分数)
+ chunks = chunks_query.limit(top_k).all()
+
+ # 返回 chunk 及虚拟相似度分数(找到的 chunk 为 1.0)
+ return [(chunk, 1.0) for chunk in chunks]
+
+ except Exception as e:
+ logger.error(f"基本文本搜索出错:{e}")
+ return []
+
+
+def semantic_search_chunks(user_id, query, filters=None, top_k=5):
+ """
+ 在转录 chunk 上执行带过滤的语义搜索
+
+ Args:
+ user_id (int): 用于权限过滤的用户 ID
+ query (str): 搜索查询
+ filters (dict): 可选的标签、说话人、日期、录音 ID 过滤器
+ top_k (int): 返回的顶级 chunk 数量
+
+ Returns:
+ list: 相关 chunk 及相似度分数列表
+ """
+ try:
+ # 如果 embedding 不可用,回退到基本文本搜索
+ if not EMBEDDINGS_AVAILABLE:
+ logger.info("Embedding 功能不可用 - 使用基本文本搜索作为回退")
+ return basic_text_search_chunks(user_id, query, filters, top_k)
+
+ # 为查询生成 embedding
+ model = get_embedding_model()
+ if not model:
+ return basic_text_search_chunks(user_id, query, filters, top_k)
+
+ query_embedding = model.encode([query])[0]
+
+ # 构建 chunk 的基础查询,预先加载录音关系
+ chunks_query = TranscriptChunk.query.options(joinedload(TranscriptChunk.recording)).filter_by(user_id=user_id)
+
+ # 如果提供了过滤器则应用
+ if filters:
+ if filters.get('tag_ids'):
+ # 与具有指定标签的录音连接
+ chunks_query = chunks_query.join(Recording).join(
+ RecordingTag, Recording.id == RecordingTag.recording_id
+ ).filter(RecordingTag.tag_id.in_(filters['tag_ids']))
+
+ if filters.get('speaker_names'):
+ # 通过录音的 participants 字段过滤
+ if not any(hasattr(desc, 'name') and desc.name == 'recording' for desc in chunks_query.column_descriptions):
+ chunks_query = chunks_query.join(Recording)
+
+ # 为每个说话人名称构建 OR 条件
+ speaker_conditions = []
+ for speaker_name in filters['speaker_names']:
+ speaker_conditions.append(
+ Recording.participants.ilike(f'%{speaker_name}%')
+ )
+
+ chunks_query = chunks_query.filter(db.or_(*speaker_conditions))
+ logger.info(f"已应用说话人过滤器:{filters['speaker_names']}")
+
+ if filters.get('recording_ids'):
+ chunks_query = chunks_query.filter(
+ TranscriptChunk.recording_id.in_(filters['recording_ids'])
+ )
+
+ if filters.get('date_from') or filters.get('date_to'):
+ chunks_query = chunks_query.join(Recording)
+ if filters.get('date_from'):
+ chunks_query = chunks_query.filter(Recording.meeting_date >= filters['date_from'])
+ if filters.get('date_to'):
+ chunks_query = chunks_query.filter(Recording.meeting_date <= filters['date_to'])
+
+ # 获取具有 embedding 的 chunk
+ chunks = chunks_query.filter(TranscriptChunk.embedding.isnot(None)).all()
+
+ if not chunks:
+ return []
+
+ # 计算相似度
+ chunk_similarities = []
+ for chunk in chunks:
+ try:
+ chunk_embedding = deserialize_embedding(chunk.embedding)
+ if chunk_embedding is not None:
+ similarity = cosine_similarity(
+ query_embedding.reshape(1, -1),
+ chunk_embedding.reshape(1, -1)
+ )[0][0]
+ chunk_similarities.append((chunk, float(similarity)))
+ except Exception as e:
+ logger.warning(f"计算 chunk {chunk.id} 的相似度时出错:{e}")
+ continue
+
+ # 按相似度排序并返回前 k 个
+ chunk_similarities.sort(key=lambda x: x[1], reverse=True)
+ return chunk_similarities[:top_k]
+
+ except Exception as e:
+ logger.error(f"语义搜索出错:{e}")
+ return []
diff --git a/speakr/src/services/event_service.py b/speakr/src/services/event_service.py
new file mode 100644
index 0000000..f3029f4
--- /dev/null
+++ b/speakr/src/services/event_service.py
@@ -0,0 +1,360 @@
+"""
+事件服务模块 - 处理事件提取和日历导出
+
+主要功能:
+- 从转录文本中提取日历事件
+- 生成 ICS 日历文件内容
+- 处理 iCalendar 格式的特殊字符转义
+"""
+import json
+import logging
+from datetime import datetime
+
+from src.clients import db
+from src.models import Recording, Event
+from src.services.llm_service import call_llm_completion
+from src.clients import TEXT_MODEL_NAME
+from src.utils.json_utils import safe_json_loads
+
+logger = logging.getLogger(__name__)
+
+
+def extract_events_from_transcript(recording_id, transcript_text, summary_text):
+ """
+ 从转录文本中使用 LLM 提取日历事件
+
+ Args:
+ recording_id: 录音 ID
+ transcript_text: 格式化后的转录文本
+ summary_text: 生成的摘要文本
+ """
+ try:
+ recording = db.session.get(Recording, recording_id)
+ if not recording or not recording.owner or not recording.owner.extract_events:
+ return # 该用户未启用事件提取功能
+
+ logger.info(f"正在为录音 {recording_id} 提取事件")
+
+ # 构建全面的上下文信息
+ current_date = datetime.now()
+ context_parts = []
+
+ # 关键:确定相对日期计算的参考日期
+ reference_date = None
+ reference_date_source = ""
+
+ if recording.meeting_date:
+ # 优先使用会议日期(如果可用)
+ reference_date = recording.meeting_date
+ reference_date_source = "会议日期"
+ context_parts.append(f"**会议日期(用于相对日期计算): {recording.meeting_date.strftime('%A, %B %d, %Y')}**")
+ elif recording.created_at:
+ # 回退到上传日期
+ reference_date = recording.created_at.date()
+ reference_date_source = "上传日期(无会议日期)"
+ context_parts.append(f"**参考日期(用于相对日期计算): {recording.created_at.strftime('%A, %B %d, %Y')}**")
+
+ context_parts.append(f"今天的实际日期: {current_date.strftime('%A, %B %d, %Y')}")
+ context_parts.append(f"当前时间: {current_date.strftime('%I:%M %p')}")
+
+ # 添加额外的录音上下文
+ if recording.created_at:
+ context_parts.append(f"录音上传时间: {recording.created_at.strftime('%B %d, %Y at %I:%M %p')}")
+ if recording.meeting_date and reference_date_source == "会议日期":
+ # 计算会议与今天之间的天数,提供上下文
+ days_since = (current_date.date() - recording.meeting_date).days
+ if days_since == 0:
+ context_parts.append("这个会议发生在今天")
+ elif days_since == 1:
+ context_parts.append("这个会议发生在昨天")
+ else:
+ context_parts.append(f"这个会议发生在 {days_since} 天前")
+
+ # 添加用户上下文以便更好地理解
+ if recording.owner:
+ user_context = []
+ if recording.owner.name:
+ user_context.append(f"用户姓名: {recording.owner.name}")
+ if recording.owner.job_title:
+ user_context.append(f"职位: {recording.owner.job_title}")
+ if recording.owner.company:
+ user_context.append(f"公司: {recording.owner.company}")
+ if user_context:
+ context_parts.append("用户信息: " + ", ".join(user_context))
+
+ # 添加参与者信息(如果可用)
+ if recording.participants:
+ context_parts.append(f"会议参与者: {recording.participants}")
+
+ context_section = "\n".join(context_parts)
+
+ # 准备事件提取的提示词
+ event_prompt = f"""你正在分析会议转录以提取日历事件。请使用以下上下文正确解释相对日期和时间。
+
+重要上下文:
+{context_section}
+
+说明:
+1. **关键**: 使用上面显示的会议日期作为所有相对日期计算的参考点
+2. 当人们说"下周三"或"明天"或"下周"时,从会议日期计算,而不是从今天计算
+3. 示例: 如果会议日期是 2025年9月13日,有人说"下周三",那就是 2025年9月17日
+4. 如果未提及事件的具体时间,使用 09:00:00(上午9点)作为默认开始时间
+5. 注意提及的时区
+6. 仅提取明确讨论为未来预约、会议或截止日期的事件
+7. 不要为过去的事件或一般讨论创建事件
+
+对于找到的每个事件,提取:
+- 标题: 事件的清晰简洁标题
+- 描述: 包含会议上下文的简要描述
+- 开始日期/时间: 计算出的实际日期/时间(ISO 格式 YYYY-MM-DDTHH:MM:SS,如果未指定时间则使用 09:00:00)
+- 结束日期/时间: 事件结束时间(如果提及,ISO 格式,如果未指定则默认为开始后1小时)
+- 地点: 事件发生的地点(如果提及)
+- 参与者: 应该参加的人员列表(如果提及)
+- 提醒分钟: 提前多少分钟提醒(默认15分钟)
+
+转录摘要:
+{summary_text}
+
+转录摘录(用于额外上下文):
+{transcript_text[:8000]}
+
+响应格式:
+响应一个包含 "events" 数组的 JSON 对象。如果未找到事件,返回一个空 events 数组的 JSON 对象。
+
+示例响应:
+{{
+ "events": [
+ {{
+ "title": "项目评审会议",
+ "description": "季度评审,讨论项目进展和下一步计划,如会议中所讨论",
+ "start_datetime": "2025-07-22T14:00:00",
+ "end_datetime": "2025-07-22T15:30:00",
+ "location": "会议室 A",
+ "attendees": ["张三", "李四", "王五"],
+ "reminder_minutes": 15
+ }}
+ ]
+}}
+
+关键规则:
+1. **基于上面上下文中提供的会议日期进行所有日期计算**
+2. 仅提取相对于会议日期为未来的事件(不是今天的日期)
+3. 使用会议日期作为参考点转换所有相对日期
+4. 示例: 如果会议日期是 2025年9月13日(星期五),有人说:
+ - "下周三" = 2025年9月17日
+ - "明天" = 2025年9月14日
+ - "下周" = 2025年9月15-19日这一周
+5. 重要: 如果未提及时间,始终使用 09:00:00(上午9点)作为开始时间,而不是午夜
+6. 在描述中包含讨论的上下文
+7. 不要发明或假设未明确讨论的事件
+8. 如果对日期/时间不确定,不要包含该事件"""
+
+ completion = call_llm_completion(
+ model_name=TEXT_MODEL_NAME,
+ messages=[
+ {"role": "system", "content": """你是从会议转录中提取日历事件的专家。你擅长:
+1. 理解相对日期引用("下周二"、"明天"、"两周后")并将其转换为绝对日期
+2. 从对话中识别真正的未来预约、会议和截止日期
+3. 区分实际计划的事件与一般讨论
+4. 准确提取参与者姓名和会议详情
+
+你必须仅以有效的 JSON 格式响应。"""},
+ {"role": "user", "content": event_prompt}
+ ],
+ temperature=0.2,
+ response_format={"type": "json_object"},
+ max_tokens=3000
+ )
+
+ response_content = completion.choices[0].message.content
+ events_data = safe_json_loads(response_content, {})
+
+ # 处理 {"events": [...]} 和直接数组格式
+ if isinstance(events_data, dict) and 'events' in events_data:
+ events_list = events_data['events']
+ elif isinstance(events_data, list):
+ events_list = events_data
+ else:
+ events_list = []
+
+ logger.info(f"为录音 {recording_id} 找到 {len(events_list)} 个事件")
+
+ # 将事件保存到数据库
+ for event_data in events_list:
+ try:
+ # 解析日期
+ start_dt = None
+ end_dt = None
+
+ if 'start_datetime' in event_data:
+ try:
+ # 首先尝试 ISO 格式
+ start_dt = datetime.fromisoformat(event_data['start_datetime'].replace('Z', '+00:00'))
+ except:
+ # 尝试其他常见格式
+ from dateutil import parser
+ try:
+ start_dt = parser.parse(event_data['start_datetime'])
+ except:
+ logger.warning(f"无法解析 start_datetime: {event_data['start_datetime']}")
+ continue # 如果无法解析日期,跳过此事件
+
+ if 'end_datetime' in event_data and event_data['end_datetime']:
+ try:
+ end_dt = datetime.fromisoformat(event_data['end_datetime'].replace('Z', '+00:00'))
+ except:
+ from dateutil import parser
+ try:
+ end_dt = parser.parse(event_data['end_datetime'])
+ except:
+ pass # 结束时间是可选的
+
+ # 创建事件记录
+ event = Event(
+ recording_id=recording_id,
+ title=event_data.get('title', '无标题事件')[:200],
+ description=event_data.get('description', ''),
+ start_datetime=start_dt,
+ end_datetime=end_dt,
+ location=event_data.get('location', '')[:500] if event_data.get('location') else None,
+ attendees=json.dumps(event_data.get('attendees', [])) if event_data.get('attendees') else None,
+ reminder_minutes=event_data.get('reminder_minutes', 15)
+ )
+
+ db.session.add(event)
+ logger.info(f"为录音 {recording_id} 添加事件 '{event.title}'")
+
+ except Exception as e:
+ logger.error(f"为录音 {recording_id} 保存事件时出错: {str(e)}")
+ continue
+
+ db.session.commit()
+
+ # 刷新录音以确保加载事件关系
+ recording = db.session.get(Recording, recording_id)
+ if recording:
+ db.session.refresh(recording)
+
+ except Exception as e:
+ logger.error(f"为录音 {recording_id} 提取事件时出错: {str(e)}")
+ db.session.rollback()
+
+
+def generate_ics_content(events):
+ """
+ 为一个或多个事件生成 ICS 日历文件内容
+
+ Args:
+ events: 事件对象或事件对象列表
+
+ Returns:
+ str: ICS 格式的日历内容
+ """
+ import uuid
+ from datetime import datetime, timedelta
+
+ # 统一处理为列表
+ if not isinstance(events, list):
+ events = [events]
+
+ if not events:
+ return ''
+
+ # 开始构建 ICS 内容
+ lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Speakr//Event Export//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ ]
+
+ for event in events:
+ # 为每个事件生成唯一 ID
+ uid = f"{event.id}-{uuid.uuid4()}@speakr.app"
+
+ # 以 iCalendar 格式格式化日期(YYYYMMDDTHHMMSS)
+ def format_ical_date(dt):
+ if dt:
+ return dt.strftime('%Y%m%dT%H%M%S')
+ return None
+
+ lines.extend([
+ 'BEGIN:VEVENT',
+ f'UID:{uid}',
+ f'DTSTAMP:{format_ical_date(datetime.utcnow())}',
+ ])
+
+ # 添加事件详情
+ if event.start_datetime:
+ lines.append(f'DTSTART:{format_ical_date(event.start_datetime)}')
+
+ if event.end_datetime:
+ lines.append(f'DTEND:{format_ical_date(event.end_datetime)}')
+ elif event.start_datetime:
+ # 如果没有结束时间,默认为开始后1小时
+ end_time = event.start_datetime + timedelta(hours=1)
+ lines.append(f'DTEND:{format_ical_date(end_time)}')
+
+ # 添加标题和描述
+ lines.append(f'SUMMARY:{escape_ical_text(event.title)}')
+
+ if event.description:
+ lines.append(f'DESCRIPTION:{escape_ical_text(event.description)}')
+
+ # 添加地点(如果可用)
+ if event.location:
+ lines.append(f'LOCATION:{escape_ical_text(event.location)}')
+
+ # 添加参与者(如果可用)
+ if event.attendees:
+ try:
+ attendees_list = json.loads(event.attendees)
+ for attendee in attendees_list:
+ if attendee:
+ lines.append(f'ATTENDEE;CN={escape_ical_text(attendee)}:mailto:{attendee.replace(" ", ".").lower()}@example.com')
+ except:
+ pass
+
+ # 添加提醒/闹钟(如果指定)
+ if event.reminder_minutes and event.reminder_minutes > 0:
+ lines.extend([
+ 'BEGIN:VALARM',
+ 'TRIGGER:-PT{}M'.format(event.reminder_minutes),
+ 'ACTION:DISPLAY',
+ f'DESCRIPTION:提醒: {escape_ical_text(event.title)}',
+ 'END:VALARM'
+ ])
+
+ # 关闭事件
+ lines.extend([
+ 'STATUS:CONFIRMED',
+ 'TRANSP:OPAQUE',
+ 'END:VEVENT',
+ ])
+
+ # 关闭日历
+ lines.append('END:VCALENDAR')
+
+ return '\r\n'.join(lines)
+
+
+def escape_ical_text(text):
+ """
+ 转义 iCalendar 格式的特殊字符
+
+ Args:
+ text: 需要转义的文本
+
+ Returns:
+ str: 转义后的文本
+ """
+ if not text:
+ return ''
+ # 转义特殊字符
+ text = str(text)
+ text = text.replace('\\', '\\\\')
+ text = text.replace(',', '\\,')
+ text = text.replace(';', '\\;')
+ text = text.replace('\n', '\\n')
+ return text
diff --git a/speakr/src/services/file_monitor.py b/speakr/src/services/file_monitor.py
new file mode 100644
index 0000000..313e511
--- /dev/null
+++ b/speakr/src/services/file_monitor.py
@@ -0,0 +1,495 @@
+#!/usr/bin/env python3
+"""
+文件监视器 - 自动化音频处理
+监视目录中的新音频文件并自动处理它们。
+支持多种用户模式:
+1. 仅管理员:文件仅分配给管理员用户
+2. 用户特定目录:每个用户有自己的文件夹(例如 /auto-process/user-uuid/)
+3. 单一默认用户:所有文件分配给一个指定的用户
+"""
+
+import os
+import time
+import threading
+import logging
+from datetime import datetime
+from pathlib import Path
+import mimetypes
+import subprocess
+from werkzeug.utils import secure_filename
+
+# Flask 应用组件将在函数内部导入以避免循环导入
+
+class FileMonitor:
+ def __init__(self, app, base_watch_directory, check_interval=30, mode='admin_only'):
+ """
+ 初始化文件监视器。
+
+ 参数:
+ app: Flask 应用实例(用于在后台线程中创建应用上下文)
+ base_watch_directory (str): 监视新文件的基础目录
+ check_interval (int): 检查新文件的频率(秒)
+ mode (str): 处理模式 - 'admin_only', 'user_directories', 或 'single_user'
+ """
+ self.app = app
+ self.base_watch_directory = Path(base_watch_directory)
+ self.check_interval = check_interval
+ self.mode = mode
+ self.running = False
+ self.thread = None
+
+ # 确保基础目录存在
+ self.base_watch_directory.mkdir(parents=True, exist_ok=True)
+
+ # 设置日志
+ self.logger = logging.getLogger('file_monitor')
+ self.logger.setLevel(logging.INFO)
+
+ # 支持的音频文件扩展名
+ self.supported_extensions = {
+ '.wav', '.mp3', '.flac', '.amr', '.3gp', '.3gpp',
+ '.m4a', '.aac', '.ogg', '.wma', '.webm', '.mp4', '.mov'
+ }
+
+ # 管理员用户和有效用户的缓存
+ self._admin_user_id = None
+ self._valid_users = {} # 映射 user_id 到 username
+ self._username_to_id = {} # 映射 username 到 user_id
+ self._last_user_cache_update = 0
+
+ def start(self):
+ """在后台线程中启动文件监视。"""
+ if self.running:
+ self.logger.warning("文件监视器已在运行")
+ return
+
+ self.running = True
+ self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
+ self.thread.start()
+ self.logger.info(f"文件监视器以 '{self.mode}' 模式启动,监视目录: {self.base_watch_directory}")
+
+ def stop(self):
+ """停止文件监视。"""
+ self.running = False
+ if self.thread:
+ self.thread.join(timeout=5)
+ self.logger.info("文件监视器已停止")
+
+ def _update_user_cache(self):
+ """更新有效用户和管理员用户的缓存。"""
+ current_time = time.time()
+ # 每5分钟更新一次缓存
+ if current_time - self._last_user_cache_update < 300:
+ return
+
+ from src.clients import db
+ from src.models import User
+
+ with self.app.app_context():
+ try:
+ # 查找管理员用户
+ admin_user = User.query.filter_by(is_admin=True).first()
+ self._admin_user_id = admin_user.id if admin_user else None
+
+ # 缓存所有有效用户
+ users = User.query.all()
+ self._valid_users = {user.id: user.username for user in users}
+ self._username_to_id = {user.username: user.id for user in users}
+
+ self._last_user_cache_update = current_time
+ self.logger.debug(f"更新用户缓存: {len(self._valid_users)} 个用户,管理员: {self._admin_user_id}")
+
+ except Exception as e:
+ self.logger.error(f"更新用户缓存时出错: {e}")
+
+ def _monitor_loop(self):
+ """主监视循环。"""
+ while self.running:
+ try:
+ self._update_user_cache()
+
+ if self.mode == 'admin_only':
+ self._scan_admin_directory()
+ elif self.mode == 'user_directories':
+ self._scan_user_directories()
+ elif self.mode == 'single_user':
+ self._scan_single_user_directory()
+
+ except Exception as e:
+ self.logger.error(f"目录扫描期间出错: {e}", exc_info=True)
+
+ # 等待下一次检查
+ time.sleep(self.check_interval)
+
+ def _scan_admin_directory(self):
+ """为主目录中的文件扫描,作为管理员用户处理。"""
+ if not self._admin_user_id:
+ self.logger.warning("未找到管理员用户,跳过管理员目录扫描")
+ return
+
+ self._scan_directory_for_user(self.base_watch_directory, self._admin_user_id)
+
+ def _scan_user_directories(self):
+ """扫描用户特定的子目录。"""
+ if not self.base_watch_directory.exists():
+ return
+
+ # 查找用户目录(例如 user-uuid1, user-uuid2)
+ for item in self.base_watch_directory.iterdir():
+ if not item.is_dir():
+ continue
+
+ # 从目录名中提取用户 ID
+ user_id = self._extract_user_id_from_dirname(item.name)
+ if user_id and user_id in self._valid_users:
+ self._scan_directory_for_user(item, user_id)
+ elif item.name.startswith('user-'):
+ self.logger.warning(f"找到用户目录 '{item.name}' 但用户 ID {user_id} 无效")
+
+ def _scan_single_user_directory(self):
+ """为单个配置的用户扫描目录。"""
+ default_username = os.environ.get('AUTO_PROCESS_DEFAULT_USERNAME')
+ if not default_username:
+ self.logger.warning("single_user 模式下未配置 AUTO_PROCESS_DEFAULT_USERNAME")
+ return
+
+ user_id = self._username_to_id.get(default_username)
+ if user_id:
+ self._scan_directory_for_user(self.base_watch_directory, user_id)
+ else:
+ self.logger.warning(f"配置的默认用户名 '{default_username}' 无效")
+
+ def _scan_directory_for_user(self, directory, user_id):
+ """为特定用户扫描特定目录中的文件。"""
+ if not directory.exists():
+ return
+
+ for file_path in directory.iterdir():
+ if not file_path.is_file():
+ continue
+
+ # 跳过隐藏文件、处理中的文件或不支持的文件
+ if file_path.name.startswith('.') or file_path.suffix == '.processing':
+ continue
+
+ if file_path.suffix.lower() not in self.supported_extensions:
+ continue
+
+ # 检查文件是否仍在写入中(大小稳定性检查)
+ try:
+ if not self._is_file_stable(file_path):
+ continue
+ except FileNotFoundError:
+ # 文件可能在 iterdir() 后被其他工作进程获取
+ continue
+
+ self.logger.info(f"为用户 {user_id} 找到可能的音频文件: {file_path}")
+
+ # --- 通过重命名进行原子锁定 ---
+ processing_path = file_path.with_suffix(file_path.suffix + '.processing')
+
+ try:
+ file_path.rename(processing_path)
+ self.logger.info(f"为 {file_path} 获取锁,重命名为 {processing_path}")
+ except FileNotFoundError:
+ self.logger.debug(f"无法为 {file_path} 获取锁,已被其他工作进程处理。")
+ continue
+ except Exception as e:
+ self.logger.error(f"为 {file_path} 获取锁时出错: {e}")
+ continue
+
+ # --- 处理锁定的文件 ---
+ try:
+ self._process_file(processing_path, user_id)
+ except Exception as e:
+ self.logger.error(f"处理文件 {processing_path} 时出错: {e}", exc_info=True)
+ # 如果处理失败,通过重命名回原来解锁文件
+ try:
+ original_path = processing_path.with_suffix(processing_path.suffix.replace('.processing', ''))
+ processing_path.rename(original_path)
+ self.logger.info(f"处理错误后将文件 {processing_path} 解锁回 {original_path}。")
+ except Exception as rename_err:
+ self.logger.error(f"严重: 错误后无法解锁文件 {processing_path}: {rename_err}")
+
+ def _extract_user_id_from_dirname(self, dirname):
+ """
+ 从目录名中提取用户 ID。
+
+ 预期格式: user-uuid, uuid
+
+ 参数:
+ dirname (str): 目录名
+
+ 返回:
+ str or None: 如果找到则返回用户 ID,否则返回 None
+ """
+ import re
+ import uuid
+
+ # 模式: user-uuid 或仅 uuid
+ patterns = [
+ r'^user-([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$', # user-uuid
+ r'^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$' # uuid
+ ]
+
+ for pattern in patterns:
+ match = re.match(pattern, dirname, re.IGNORECASE)
+ if match:
+ try:
+ user_id_str = match.group(1)
+ # 验证是否为有效的 UUID
+ uuid.UUID(user_id_str)
+ return user_id_str
+ except (ValueError, AttributeError):
+ continue
+
+ return None
+
+ def _is_file_stable(self, file_path, stability_time=5):
+ """
+ 检查文件是否稳定(未被写入)。
+
+ 参数:
+ file_path (Path): 文件路径
+ stability_time (int): 等待大小稳定的时间(秒)
+
+ 返回:
+ bool: 如果文件看起来稳定则返回 True
+ """
+ try:
+ initial_size = file_path.stat().st_size
+ initial_mtime = file_path.stat().st_mtime
+
+ # 等待一会儿再检查
+ time.sleep(min(stability_time, 2))
+
+ current_size = file_path.stat().st_size
+ current_mtime = file_path.stat().st_mtime
+
+ # 如果大小和修改时间未改变,则文件稳定
+ return initial_size == current_size and initial_mtime == current_mtime
+
+ except (OSError, FileNotFoundError):
+ return False
+
+ def _process_file(self, processing_path, user_id):
+ """
+ 为特定用户处理单个锁定的音频文件。
+
+ 参数:
+ processing_path (Path): 锁定的音频文件路径(例如 file.mp3.processing)
+ user_id (str): 要分配录音的用户 ID(UUID 字符串)
+ """
+ from src.clients import db
+ from src.models import Recording, User
+ from src.services.transcription_service import transcribe_audio_task
+ from src.services.summary_service import generate_summary_only_task, generate_title_task
+
+ with self.app.app_context():
+ try:
+ # 验证用户存在
+ user = db.session.get(User, user_id)
+ if not user:
+ self.logger.error(f"数据库中未找到用户 ID {user_id},文件: {processing_path}")
+ # 必须引发异常以触发解锁机制
+ raise ValueError(f"未找到用户 ID {user_id}")
+
+ # 通过移除 .processing 后缀推导原始文件名
+ original_filename = processing_path.name.replace('.processing', '')
+ safe_filename = secure_filename(original_filename)
+ timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
+ new_filename = f"auto_{timestamp}_{safe_filename}"
+
+ uploads_dir = Path(self.app.config['UPLOAD_FOLDER'])
+ uploads_dir.mkdir(parents=True, exist_ok=True)
+ destination_path = uploads_dir / new_filename
+
+ # 将锁定文件复制到上传目录
+ import shutil
+ shutil.copy(str(processing_path), str(destination_path))
+ self.logger.info(f"已将 {processing_path} 复制到 {destination_path}")
+
+ # 成功复制后从监视目录中删除锁定文件
+ try:
+ processing_path.unlink()
+ self.logger.info(f"已删除锁定文件: {processing_path}")
+ except FileNotFoundError:
+ # 如果持有锁,这不应该发生,但最好记录
+ self.logger.warning(f"锁定文件 {processing_path} 已被删除。")
+
+ # 如果需要,转换文件(与 upload_file 相同的逻辑)
+ final_path = self._convert_file_if_needed(destination_path, original_filename)
+
+ # 获取文件大小和 MIME 类型
+ file_size = final_path.stat().st_size
+ mime_type, _ = mimetypes.guess_type(str(final_path))
+
+ # 创建数据库记录
+ now = datetime.utcnow()
+ recording = Recording(
+ audio_path=str(final_path),
+ original_filename=original_filename,
+ title=f"自动处理 - {original_filename}",
+ file_size=file_size,
+ status='PENDING',
+ meeting_date=now.date(),
+ user_id=user_id, # 现在使用字符串 UUID
+ mime_type=mime_type,
+ is_inbox=True, # 自动处理的文件进入收件箱
+ processing_source='auto_process' # 跟踪这是自动处理的
+ )
+
+ db.session.add(recording)
+ db.session.commit()
+
+ self.logger.info(f"为用户 {user.username} 创建录音记录,ID: {recording.id}")
+
+ # 启动后台处理
+ start_time = datetime.utcnow()
+ task_kwargs = {
+ 'USE_ASR_ENDPOINT': self.app.config.get('USE_ASR_ENDPOINT', False),
+ 'ASR_BASE_URL': self.app.config.get('ASR_BASE_URL', ''),
+ 'ASR_DIARIZE': self.app.config.get('ASR_DIARIZE', False),
+ 'chunking_service': self.app.config.get('chunking_service'),
+ 'ENABLE_CHUNKING': self.app.config.get('ENABLE_CHUNKING', False),
+ 'generate_title_task': generate_title_task,
+ 'generate_summary_only_task': generate_summary_only_task,
+ 'app_logger': self.app.logger,
+ 'UPLOAD_FOLDER': self.app.config.get('UPLOAD_FOLDER')
+ }
+ thread = threading.Thread(
+ target=transcribe_audio_task,
+ args=(self.app.app_context(), recording.id, str(final_path), final_path.name, start_time),
+ kwargs=task_kwargs
+ )
+ thread.start()
+
+ self.logger.info(f"为录音 ID {recording.id} 启动后台处理")
+ self.logger.info(f"成功处理并移动文件从: {processing_path}")
+
+ except Exception as e:
+ self.logger.error(f"为用户 {user_id} 处理文件 {processing_path} 时出错: {e}", exc_info=True)
+ # 重新引发异常,由调用方法捕获,该方法将处理解锁。
+ raise
+
+ def _convert_file_if_needed(self, file_path, original_filename):
+ """
+ 如果需要,将音频文件转换为支持的格式。
+ 使用 128kbps MP3 以获得最佳大小/质量平衡。
+
+ 参数:
+ file_path (Path): 当前文件路径
+ original_filename (str): 用于格式检测的原始文件名
+
+ 返回:
+ Path: 最终(可能已转换的)文件的路径
+ """
+ filename_lower = original_filename.lower()
+
+ # 在可能的情况下直接支持 WebM 和其他格式
+ supported_formats = ('.wav', '.mp3', '.flac', '.webm', '.m4a', '.aac', '.ogg')
+ convertible_formats = ('.amr', '.3gp', '.3gpp', '.wma', '.mp4', '.mov')
+
+ if filename_lower.endswith(supported_formats):
+ self.logger.info(f"文件格式 {filename_lower} 受支持,无需转换")
+ return file_path
+
+ if not filename_lower.endswith(convertible_formats):
+ self.logger.warning(f"未知文件格式 {filename_lower},仍尝试转换")
+
+ # 需要转换为高质量 MP3 以获得更好的转录准确性
+ self.logger.info(f"将 {filename_lower} 格式转换为高质量 MP3")
+
+ base_path = file_path.with_suffix('')
+ temp_mp3_path = base_path.with_suffix('.temp.mp3')
+ final_mp3_path = base_path.with_suffix('.mp3')
+
+ try:
+ # 转换为高质量 MP3(128kbps, 44.1kHz)以获得更好的转录准确性
+ subprocess.run([
+ 'ffmpeg', '-i', str(file_path), '-y',
+ '-acodec', 'libmp3lame', '-b:a', '128k', '-ar', '44100', '-ac', '1',
+ str(temp_mp3_path)
+ ], check=True, capture_output=True, text=True)
+
+ self.logger.info(f"成功将 {file_path} 转换为 {temp_mp3_path} (128kbps MP3)")
+
+ # 删除原始文件并重命名临时文件
+ file_path.unlink()
+ temp_mp3_path.rename(final_mp3_path)
+
+ return final_mp3_path
+
+ except FileNotFoundError:
+ self.logger.error("未找到 ffmpeg。请确保已安装 ffmpeg。")
+ raise
+ except subprocess.CalledProcessError as e:
+ self.logger.error(f"ffmpeg 转换失败: {e.stderr}")
+ raise
+
+
+# 全局文件监视器实例
+file_monitor = None
+
+def start_file_monitor(app=None):
+ """使用环境变量中的配置启动文件监视器。"""
+ global file_monitor
+
+ if file_monitor and file_monitor.running:
+ return
+
+ # 如果未提供 app,尝试使用 current_app(仅当在应用上下文中调用时有效)
+ if app is None:
+ try:
+ from flask import has_app_context, current_app
+ if has_app_context():
+ app = current_app._get_current_object()
+ else:
+ raise RuntimeError("不在应用上下文中")
+ except Exception:
+ logging.getLogger('file_monitor').error("无法获取 Flask 应用实例,请确保传入 app 参数")
+ return
+
+ # 从环境变量获取配置
+ watch_dir = os.environ.get('AUTO_PROCESS_WATCH_DIR', '/data/auto-process')
+ check_interval = int(os.environ.get('AUTO_PROCESS_CHECK_INTERVAL', '30'))
+ mode = os.environ.get('AUTO_PROCESS_MODE', 'admin_only') # admin_only, user_directories, single_user
+
+ # 验证模式
+ valid_modes = ['admin_only', 'user_directories', 'single_user']
+ if mode not in valid_modes:
+ app.logger.error(f"无效的 AUTO_PROCESS_MODE: {mode}。必须是以下之一: {valid_modes}")
+ return
+
+ # 仅在启用自动处理时启动
+ if os.environ.get('ENABLE_AUTO_PROCESSING', 'false').lower() == 'true':
+ file_monitor = FileMonitor(
+ app=app,
+ base_watch_directory=watch_dir,
+ check_interval=check_interval,
+ mode=mode
+ )
+ file_monitor.start()
+ app.logger.info(f"自动化文件处理以 '{mode}' 模式启动")
+ else:
+ app.logger.info("自动化文件处理已禁用")
+
+def stop_file_monitor():
+ """停止文件监视器。"""
+ global file_monitor
+ if file_monitor:
+ file_monitor.stop()
+ file_monitor = None
+
+def get_file_monitor_status():
+ """获取文件监视器的当前状态。"""
+ global file_monitor
+ if file_monitor and file_monitor.running:
+ return {
+ 'running': True,
+ 'mode': file_monitor.mode,
+ 'watch_directory': str(file_monitor.base_watch_directory),
+ 'check_interval': file_monitor.check_interval
+ }
+ else:
+ return {'running': False}
diff --git a/speakr/src/services/llm_service.py b/speakr/src/services/llm_service.py
new file mode 100644
index 0000000..7bcf8c8
--- /dev/null
+++ b/speakr/src/services/llm_service.py
@@ -0,0 +1,515 @@
+"""
+LLM 服务模块 - 处理所有与大语言模型相关的业务逻辑
+
+主要功能:
+- LLM API 调用封装
+- 响应内容清理和格式化
+- 流式响应处理
+- 转录文本预处理
+"""
+import json
+import re
+import logging
+
+from src.clients.llm_client import client, TEXT_MODEL_BASE_URL, TEXT_MODEL_NAME, TEXT_MODEL_API_KEY
+
+logger = logging.getLogger(__name__)
+
+
+def format_transcription_for_llm(transcription_text):
+ """
+ 将转录文本格式化为 LLM 可理解的纯文本格式
+
+ 如果输入是简化的 JSON 格式(包含 speaker 和 sentence),则转换为:
+ [Speaker]: sentence
+ [Speaker]: sentence
+
+ 否则直接返回原文本
+
+ Args:
+ transcription_text: 转录文本(可能是 JSON 字符串或纯文本)
+
+ Returns:
+ str: 格式化后的纯文本
+ """
+ try:
+ transcription_data = json.loads(transcription_text)
+ if isinstance(transcription_data, list):
+ # 这是我们的简化 JSON 格式
+ formatted_lines = []
+ for segment in transcription_data:
+ speaker = segment.get('speaker', 'Unknown Speaker')
+ sentence = segment.get('sentence', '')
+ formatted_lines.append(f"[{speaker}]: {sentence}")
+ return "\n".join(formatted_lines)
+ except (json.JSONDecodeError, TypeError):
+ # 不是 JSON,或者不是我们期望的格式,直接返回原文
+ pass
+ return transcription_text
+
+
+def clean_llm_response(text):
+ """
+ 清理 LLM 响应内容,移除思考标签和多余空白
+
+ 处理推理模型返回的包含 标签的响应,保留主要内容
+
+ Args:
+ text: LLM 返回的原始文本
+
+ Returns:
+ str: 清理后的文本
+ """
+ if not text:
+ return ""
+
+ # 移除思考标签及其内容
+ # 处理 和 标签的各种闭合格式
+ cleaned = re.sub(r'.*? ', '', text, flags=re.DOTALL | re.IGNORECASE)
+
+ # 同时处理未闭合的思考标签(以防模型没有正确闭合)
+ cleaned = re.sub(r'.*$', '', cleaned, flags=re.DOTALL | re.IGNORECASE)
+
+ # 移除任何可能与思考相关的剩余 XML 类标签
+ # 但保留 markdown 格式标签
+ cleaned = re.sub(r'<(?!/?(?:code|pre|blockquote|p|br|hr|ul|ol|li|h[1-6]|em|strong|b|i|a|img)(?:\s|>|/))[^>]+>', '', cleaned)
+
+ # 清理多余的空白,同时保留有意的格式化
+ # 移除每行的前导和尾随空白
+ lines = cleaned.split('\n')
+ cleaned_lines = []
+ for line in lines:
+ # 保留代码块或列表中的行
+ if line.strip() or (len(cleaned_lines) > 0 and cleaned_lines[-1].strip().startswith(('```', '-', '*', '1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.'))):
+ cleaned_lines.append(line.rstrip())
+
+ # 连接行并移除多个连续的空白行
+ cleaned = '\n'.join(cleaned_lines)
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
+
+ # 最终去除前导和尾随空白
+ return cleaned.strip()
+
+
+def extract_thinking_content(text):
+ """
+ 从 LLM 响应中提取思考内容
+ 返回一个元组 (thinking_content, main_content)
+ """
+ if not text:
+ return ("", "")
+
+ # 查找所有思考标签及其内容
+ thinking_pattern = r'.*? '
+ thinking_matches = re.findall(thinking_pattern, text, flags=re.DOTALL | re.IGNORECASE)
+
+ # 从标签中提取内容
+ thinking_content = ""
+ for match in thinking_matches:
+ # 移除开始和结束标签
+ content = re.sub(r'^', '', match, flags=re.IGNORECASE)
+ content = re.sub(r' $', '', content, flags=re.IGNORECASE)
+ if thinking_content:
+ thinking_content += "\n\n"
+ thinking_content += content.strip()
+
+ # 移除思考标签后获取主要内容
+ main_content = clean_llm_response(text)
+
+ return (thinking_content, main_content)
+
+
+def process_streaming_with_thinking(stream):
+ """
+ 处理流式响应并分离思考内容的生成器
+ 以 SSE 格式输出数据,'delta' 表示常规内容,'thinking' 表示思考内容
+ """
+ content_buffer = ""
+ in_thinking = False
+ thinking_buffer = ""
+
+ for chunk in stream:
+ content = chunk.choices[0].delta.content
+ if content:
+ content_buffer += content
+
+ # 处理缓冲区以检测和分离思考标签
+ while True:
+ if not in_thinking:
+ # 查找开始的思考标签
+ think_start = re.search(r'', content_buffer, re.IGNORECASE)
+ if think_start:
+ # 发送思考标签前的内容
+ before_thinking = content_buffer[:think_start.start()]
+ if before_thinking:
+ yield f"data: {json.dumps({'delta': before_thinking})}\n\n"
+
+ # 开始捕获思考内容
+ in_thinking = True
+ content_buffer = content_buffer[think_start.end():]
+ thinking_buffer = ""
+ else:
+ # 未找到思考标签,发送累积的内容
+ if content_buffer:
+ yield f"data: {json.dumps({'delta': content_buffer})}\n\n"
+ content_buffer = ""
+ break
+ else:
+ # 在思考标签内部,查找结束标签
+ think_end = re.search(r' ', content_buffer, re.IGNORECASE)
+ if think_end:
+ # 捕获到结束标签前的思考内容
+ thinking_buffer += content_buffer[:think_end.start()]
+
+ # 以特殊类型发送思考内容
+ if thinking_buffer.strip():
+ yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n"
+
+ # 继续处理结束标签后的内容
+ in_thinking = False
+ content_buffer = content_buffer[think_end.end():]
+ thinking_buffer = ""
+ else:
+ # 仍在思考标签内部,累积内容
+ thinking_buffer += content_buffer
+ content_buffer = ""
+ break
+
+ # 处理剩余内容
+ if in_thinking and thinking_buffer:
+ # 未闭合的思考标签 - 作为思考内容发送
+ yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n"
+ elif content_buffer:
+ # 常规内容
+ yield f"data: {json.dumps({'delta': content_buffer})}\n\n"
+
+ # 发送流结束信号
+ yield f"data: {json.dumps({'end_of_stream': True})}\n\n"
+
+
+def call_llm_completion(messages, temperature=0.7, response_format=None, stream=False, max_tokens=None, extra_body=None, model_name=None, api_key=None):
+ """
+ 集中处理LLM API调用的函数,包含完善的错误处理和数据记录功能。
+
+ 参数:
+ messages: 消息字典列表,包含'role'和'content'键
+ temperature: 采样温度值,范围0-1,数值越高随机性越强
+ response_format: 可选的响应格式字典,例如 {"type": "json_object"}
+ stream: 是否启用流式输出模式
+ max_tokens: 可选参数,限制生成内容的最大token数量
+ extra_body: 可选参数,用于传递额外的API参数字典
+ model_name: 可选,覆盖默认模型名称
+ api_key: 可选,覆盖默认API密钥(目前保留兼容性,实际使用全局client)
+
+ 返回:
+ OpenAI的完成结果对象,如果启用流式输出则返回生成器对象
+ """
+ if client is None:
+ raise ValueError("LLM客户端未初始化")
+
+ model = model_name or TEXT_MODEL_NAME
+
+ try:
+ # 构建API调用基础参数
+ completion_args = {
+ "model": model, # 使用的模型名称
+ "messages": messages, # 对话消息列表
+ "temperature": temperature, # 温度参数控制随机性
+ "stream": stream # 流式输出开关
+ }
+
+ # 如果指定了响应格式,添加到参数中
+ if response_format:
+ completion_args["response_format"] = response_format
+
+ # 如果设置了最大token数,添加到参数中
+ if max_tokens:
+ completion_args["max_tokens"] = max_tokens
+
+ # 新增:如果提供了额外参数,添加到API调用中
+ if extra_body:
+ completion_args["extra_body"] = extra_body
+
+ # 执行API调用并返回结果
+ return client.chat.completions.create(**completion_args)
+
+ except Exception as e:
+ # 记录错误日志并重新抛出异常
+ logger.error(f"LLM API 调用失败(端点:{TEXT_MODEL_BASE_URL},模型:{model}):{e}")
+ raise
+
+
+def format_api_error_message(error_str):
+ """
+ 格式化 API 错误消息,使其对用户更友好
+ 特别处理 token 超限错误并提供有用的建议
+ """
+ error_lower = error_str.lower()
+
+ # 检查 token 超限错误
+ if 'maximum context length' in error_lower and 'tokens' in error_lower:
+ return "[摘要生成失败:转录内容过长,超出 AI 处理限制。请联系管理员尝试使用更大上下文窗口的模型,或在系统设置中调整 transcript_length_limit。]"
+
+ # 检查其他常见 API 错误
+ if 'rate limit' in error_lower:
+ return "[摘要生成失败:API 请求频率超限。请稍后重试。]"
+
+ if 'insufficient funds' in error_lower or 'quota exceeded' in error_lower:
+ return "[摘要生成失败:API 配额已用完。请联系支持人员。]"
+
+ if 'timeout' in error_lower:
+ return "[摘要生成失败:请求超时。请重试。]"
+
+ if 'connection error' in error_lower or 'connecterror' in error_lower or 'connection refused' in error_lower or 'actively refused' in error_lower:
+ return "[摘要生成失败:无法连接到 LLM 服务。请检查 TEXT_MODEL_BASE_URL 对应的服务是否已启动并可访问。]"
+
+ # 其他错误显示通用消息
+ return f"[摘要生成失败:{error_str}]"
+
+
+def extract_corrected_text(corrected_text, asr_text):
+ """
+ 从模型回答中提取修正后的文本
+
+ Args:
+ corrected_text: 模型回答的完整内容
+ asr_text: 原始ASR文本
+
+ Returns:
+ str: 提取到的修正文本或原始ASR文本
+ """
+ # 使用正则表达式匹配 '''内容''' 格式
+ pattern = r'(.*?) '
+ matches = re.findall(pattern, corrected_text, re.DOTALL)
+
+ # 如果找到匹配项
+ if matches:
+ extracted_text = matches[0].strip()
+
+ # 检查提取的文本长度是否在合理范围内
+ # 不超过原始的1.2倍
+ max_allowed_length = len(asr_text) * 1.2
+
+ if len(extracted_text) <= max_allowed_length:
+ logger.info(f"转录完成,已找到匹配内容")
+ return extracted_text
+
+ # 如果没有匹配到或者长度超标,返回原始ASR文本
+ logger.info(f"未找到匹配内容或长度超标")
+ return asr_text
+
+
+def calculate_tokens(text):
+ """计算文本的大致token数量"""
+ # 保守估算:1个token ≈ 3个字符
+ return len(text) // 3
+
+
+def tokens_to_chars(tokens):
+ """将token数量转换为字符数量"""
+ return tokens * 3
+
+
+def parse_speaker_segments(transcription_text):
+ """
+ 解析转录文本,按发言人分组内容
+
+ Args:
+ transcription_text (str): 原始转录文本
+
+ Returns:
+ dict: 按发言人分组的对话内容 {speaker: [content1, content2, ...]}
+ """
+ speaker_segments = {}
+
+ # 使用正则表达式匹配发言模式:[SPK_X]: 内容
+ pattern = r'\[(SPK_\d+)\]:\s*(.*?)(?=\n\[SPK_|\n\n|\n$|$)'
+
+ matches = re.findall(pattern, transcription_text, re.DOTALL)
+
+ for speaker, content in matches:
+ content = content.strip()
+ if content:
+ if speaker not in speaker_segments:
+ speaker_segments[speaker] = []
+ speaker_segments[speaker].append(content)
+
+ logger.info(f"解析到 {len(speaker_segments)} 个发言人,共 {len(matches)} 段内容")
+ return speaker_segments
+
+
+def summarize_single_speaker(speaker, content):
+ """
+ 总结单个发言人的内容
+
+ Args:
+ speaker (str): 发言人标识
+ content (str): 发言内容
+
+ Returns:
+ str: 总结后的内容
+ """
+ from src.config import MAX_TOKENS_FOR_TRANSCRIPTION, SPEAKR_TOKENS_FOR_TRANSCRIPTION
+
+ # 如果内容超过限制,进行截断
+ content_tokens = calculate_tokens(content)
+ max_content_length = tokens_to_chars(MAX_TOKENS_FOR_TRANSCRIPTION)
+ if content_tokens > MAX_TOKENS_FOR_TRANSCRIPTION:
+ logger.warning(f"发言人 {speaker} 内容过长({len(content)} 字符),截断至 {max_content_length} 字符")
+ content = content[:max_content_length] + "...[内容过长已截断]"
+
+ prompt = f"""请总结以下发言人的对话内容,保留关键信息和主要观点:
+
+发言人: {speaker}
+
+对话内容:
+{content}
+
+请用简洁的语言总结这个发言人的主要观点、关键信息和重要发言,保持客观准确,不要添加额外评论。"""
+
+ try:
+ completion = call_llm_completion(
+ messages=[
+ {"role": "system", "content": "你是一个专业的会议记录总结助手,擅长提取对话中的关键信息。"},
+ {"role": "user", "content": prompt}
+ ],
+ temperature=0.3, # 较低温度确保准确性
+ max_tokens=SPEAKR_TOKENS_FOR_TRANSCRIPTION # 限制总结长度
+ )
+
+ summary = completion.choices[0].message.content.strip()
+ return summary
+
+ except Exception as e:
+ logger.error(f"发言人 {speaker} 的 LLM 总结失败:{e}")
+ # 回退策略:返回内容的前500个字符
+ return content[:500] + "..."
+
+
+def summarize_speaker_segments(speaker_segments):
+ """
+ 对每个发言人的内容进行分段总结
+
+ Args:
+ speaker_segments (dict): 按发言人分组的对话内容
+
+ Returns:
+ dict: 每个发言人的总结内容 {speaker: summarized_content}
+ """
+ from src.config import SPEAKR_TOKENS_FOR_TRANSCRIPTION
+
+ summarized_speakers = {}
+
+ for speaker, segments in speaker_segments.items():
+ try:
+ # 合并该发言人的所有内容
+ full_content = "\n".join(segments)
+ estimated_tokens = calculate_tokens(full_content)
+ # 如果内容不长,直接使用(避免过度总结)
+ if estimated_tokens < SPEAKR_TOKENS_FOR_TRANSCRIPTION:
+ summarized_speakers[speaker] = full_content
+ continue
+
+ # 对长内容进行总结
+ summary = summarize_single_speaker(speaker, full_content)
+ summarized_speakers[speaker] = summary
+
+ logger.info(f"已总结发言人 {speaker} 的内容:{len(full_content)} -> {len(summary)} 字符")
+
+ except Exception as e:
+ logger.error(f"总结发言人 {speaker} 的内容失败:{e}")
+ # 如果总结失败,使用前500字符内容作为回退
+ fallback_content = "\n".join(segments)
+ summarized_speakers[speaker] = fallback_content[:500]
+
+ return summarized_speakers
+
+
+def combine_speaker_summaries(summarized_speakers):
+ """
+ 直接将各发言人的总结内容组合成原始格式
+
+ Args:
+ summarized_speakers (dict): 每个发言人的总结内容
+
+ Returns:
+ str: 组合后的文本,格式为 "[SPK_X]: 总结内容"
+ """
+ from src.config import MAX_TOKENS_FOR_TRANSCRIPTION, SPEAKR_TOKENS_FOR_TRANSCRIPTION
+
+ lines = []
+
+ for speaker, summary in summarized_speakers.items():
+ # 如果总结内容仍然很长,进行适当截断
+ content_tokens = calculate_tokens(summary)
+ max_summary_length = SPEAKR_TOKENS_FOR_TRANSCRIPTION
+ if content_tokens > max_summary_length:
+ summary = summary[:max_summary_length] + "..."
+
+ lines.append(f"[{speaker}]: {summary}")
+
+ combined_text = "\n".join(lines)
+
+ # 最终检查总长度,确保不超过token限制
+ max_tokens = MAX_TOKENS_FOR_TRANSCRIPTION # 约10000 tokens
+ max_content_length = tokens_to_chars(MAX_TOKENS_FOR_TRANSCRIPTION)
+ content_tokens = calculate_tokens(combined_text)
+ if content_tokens > max_tokens:
+ logger.warning(f"合并后的总结过长({len(combined_text)} 字符),正在截断")
+ combined_text = combined_text[:max_content_length] + "\n...[内容过长已截断]"
+
+ return combined_text
+
+
+def preprocess_long_transcription(transcription_text):
+ """
+ 预处理过长的转录文本,避免token超限问题。
+ 如果文本过长,先分段总结每个发言人的内容,再进行最终总结。
+
+ Args:
+ transcription_text (str): 原始转录文本,格式为 "[SPK_X]: 发言内容\n[SPK_X]: 发言内容"
+
+ Returns:
+ str: 处理后的文本,如果未超限则返回原文本,如果超限则返回分段总结后的文本
+ """
+ from src.config import MAX_TOKENS_FOR_TRANSCRIPTION
+
+ max_tokens = int(MAX_TOKENS_FOR_TRANSCRIPTION)
+
+ # 如果转录文本为空或很短,直接返回
+ if not transcription_text or len(transcription_text.strip()) < 1000:
+ return transcription_text
+
+ estimated_tokens = calculate_tokens(transcription_text)
+
+ logger.info(f"转录文本预估 token 数:{estimated_tokens},上限:{max_tokens}")
+
+ # 如果估算的token数量未超过限制,直接返回原文本
+ if estimated_tokens <= max_tokens:
+ logger.info("转录文本在 token 限制内,使用原始文本")
+ return transcription_text
+
+ logger.info("转录文本超出 token 限制,开始分段总结流程")
+
+ try:
+ # 解析发言内容,按发言人分组
+ speaker_segments = parse_speaker_segments(transcription_text)
+
+ if not speaker_segments:
+ logger.warning("未找到发言人分块,返回原始文本")
+ return transcription_text
+
+ # 对每个发言人的内容进行分段总结
+ summarized_speakers = summarize_speaker_segments(speaker_segments)
+
+ # 生成最终格式
+ final_summary = combine_speaker_summaries(summarized_speakers)
+
+ logger.info(f"分段总结完成,原始长度: {len(transcription_text)}, 总结后长度: {len(final_summary)}")
+ return final_summary
+
+ except Exception as e:
+ logger.error(f"预处理长转录文本失败:{e}")
+ # 如果预处理失败,返回原始文本的前面部分(安全回退)
+ safe_length = tokens_to_chars(max_tokens) # 保守估计字符数
+ return transcription_text[:safe_length]
diff --git a/speakr/src/services/register.py b/speakr/src/services/register.py
new file mode 100644
index 0000000..2f25312
--- /dev/null
+++ b/speakr/src/services/register.py
@@ -0,0 +1,179 @@
+"""
+服务层初始化模块
+
+负责初始化所有业务服务,包括:
+- 音频分段服务
+- 嵌入功能依赖检测
+- 远程数据同步线程
+- 文件监控服务
+- 数据库表结构迁移
+"""
+import os
+import tempfile
+from sqlalchemy import text
+
+
+# 全局服务实例
+chunking_service = None
+
+
+def init_early_services(app):
+ """
+ 早期服务初始化(在蓝图注册前调用)
+
+ 初始化 chunking_service 和 EMBEDDINGS_AVAILABLE,
+ 这些服务需要在蓝图注册时可用。
+
+ Args:
+ app: Flask 应用实例
+ """
+ with app.app_context():
+ _init_chunking_service(app)
+ _check_embedding_dependencies(app)
+
+
+def init_services(app):
+ """
+ 初始化所有服务
+
+ Args:
+ app: Flask 应用实例
+ """
+ global chunking_service
+
+ with app.app_context():
+ # 1. 初始化音频分段服务
+ _init_chunking_service(app)
+
+ # 2. 检测嵌入功能依赖
+ _check_embedding_dependencies(app)
+
+ # 3. 启动远程数据同步线程
+ _start_sync_thread_safely(app)
+
+ # 4. 初始化文件监控
+ _init_file_monitor(app)
+
+ # 5. 执行数据库表结构迁移
+ _migrate_database(app)
+
+
+def _init_chunking_service(app):
+ """初始化音频分段服务"""
+ global chunking_service
+
+ from src.services.audio_chunking import AudioChunkingService
+ from src.config import ENABLE_CHUNKING
+
+ if ENABLE_CHUNKING:
+ chunking_service = AudioChunkingService()
+ app.logger.info("音频分段服务已启用")
+ else:
+ chunking_service = None
+ app.logger.info("音频分段服务已禁用")
+
+
+def _check_embedding_dependencies(app):
+ """检测嵌入功能依赖是否可用"""
+ try:
+ import numpy
+ from sentence_transformers import SentenceTransformer
+ from sklearn.metrics.pairwise import cosine_similarity
+
+ app.config['EMBEDDINGS_AVAILABLE'] = True
+ app.logger.info("✅ 嵌入功能依赖已就绪,语义搜索可用")
+
+ except ImportError as e:
+ app.config['EMBEDDINGS_AVAILABLE'] = False
+ app.logger.warning(f"⚠️ 嵌入功能依赖缺失,语义搜索不可用: {e}")
+ app.logger.info(" 启用语义搜索请安装: pip install sentence-transformers scikit-learn")
+
+
+def _start_sync_thread_safely(app):
+ """
+ 安全启动远程数据同步线程
+
+ 在 Gunicorn 多进程环境下,使用文件锁确保只有一个 Worker 启动同步线程。
+ 在 Windows 开发环境下,直接启动同步线程。
+ """
+ from src.services.sync_service import start_sync_thread
+
+ enable_sync = os.environ.get('ENABLE_REMOTE_SYNC', 'false').lower() == 'true'
+
+ if not enable_sync:
+ app.logger.info("远程数据同步已禁用(设置 ENABLE_REMOTE_SYNC=true 以启用)")
+ return
+
+ try:
+ # 尝试使用文件锁(Unix/Linux 环境)
+ import fcntl
+
+ lock_file_path = os.path.join(tempfile.gettempdir(), 'speakr_sync_worker.lock')
+ lock_file = open(lock_file_path, 'w')
+
+ try:
+ # 尝试获取非阻塞排他锁
+ fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+
+ # 成功获取锁,启动同步线程
+ app.logger.info(f"当前 Worker (PID: {os.getpid()}) 获得同步锁,启动同步线程...")
+ start_sync_thread(app)
+
+ except (IOError, BlockingIOError):
+ # 锁已被其他 Worker 持有
+ app.logger.info(f"当前 Worker (PID: {os.getpid()}) 未获得锁,跳过同步线程启动")
+
+ except ImportError:
+ # Windows 环境,不支持 fcntl,直接启动
+ app.logger.warning("当前环境不支持 fcntl(Windows),直接启动同步线程")
+ start_sync_thread(app)
+
+ except Exception as e:
+ app.logger.error(f"启动同步线程失败: {e}")
+
+
+def _init_file_monitor(app):
+ """初始化文件监控服务"""
+ try:
+ from src.services.auto_process_service import initialize_file_monitor
+ initialize_file_monitor()
+ app.logger.info("文件监控服务已启动")
+ except Exception as e:
+ app.logger.error(f"启动文件监控服务失败: {e}")
+
+
+def _migrate_database(app):
+ """
+ 执行数据库表结构迁移
+
+ 检查并添加缺失的字段,确保数据库结构与代码一致。
+ """
+ from src.clients import db
+ from src.models import SystemSetting
+
+ try:
+ db.create_all()
+ except Exception as e:
+ app.logger.warning(f"数据库表结构自动创建检查失败: {e}")
+
+ # 动态设置最大上传文件大小
+ max_file_size_mb = SystemSetting.get_setting('max_file_size_mb', 250)
+ app.config['MAX_CONTENT_LENGTH'] = max_file_size_mb * 1024 * 1024
+ app.logger.info(f"最大上传文件大小已设置为 {max_file_size_mb}MB")
+
+ # 检查 draft_recording 表结构
+ try:
+ app.logger.info("检查 draft_recording 表结构...")
+
+ with db.engine.connect() as conn:
+ result = conn.execute(text("PRAGMA table_info(draft_recording)"))
+ columns = [row[1] for row in result]
+
+ if 'combined_transcription' not in columns:
+ app.logger.info("添加 combined_transcription 字段...")
+ conn.execute(text("ALTER TABLE draft_recording ADD COLUMN combined_transcription TEXT"))
+ conn.commit()
+ app.logger.info("字段添加成功")
+
+ except Exception as e:
+ app.logger.warning(f"表结构迁移检查失败: {e}")
\ No newline at end of file
diff --git a/speakr/src/services/speaker_service.py b/speakr/src/services/speaker_service.py
new file mode 100644
index 0000000..b3d4e8e
--- /dev/null
+++ b/speakr/src/services/speaker_service.py
@@ -0,0 +1,217 @@
+"""
+说话人服务 - 处理说话人识别和管理相关功能
+"""
+import re
+import logging
+from datetime import datetime
+
+from flask import current_app
+from flask_login import current_user
+
+from src.clients import db
+from src.models import Speaker, SystemSetting
+from src.services.llm_service import call_llm_completion, format_transcription_for_llm
+from src.utils.json_utils import safe_json_loads
+
+logger = logging.getLogger(__name__)
+
+
+def update_speaker_usage(speaker_names):
+ """
+ 更新说话人使用统计
+
+ Args:
+ speaker_names: 说话人名称列表
+ """
+ if not speaker_names or not current_user.is_authenticated:
+ return
+
+ try:
+ for name in speaker_names:
+ name = name.strip()
+ if not name:
+ continue
+
+ speaker = Speaker.query.filter_by(user_id=current_user.id, name=name).first()
+ if speaker:
+ # 更新现有说话人
+ speaker.use_count += 1
+ speaker.last_used = datetime.utcnow()
+ else:
+ # 创建新说话人
+ speaker = Speaker(
+ name=name,
+ user_id=current_user.id,
+ use_count=1,
+ created_at=datetime.utcnow(),
+ last_used=datetime.utcnow()
+ )
+ db.session.add(speaker)
+
+ db.session.commit()
+ except Exception as e:
+ current_app.logger.error(f"更新说话人使用统计时出错: {e}")
+ db.session.rollback()
+
+
+def identify_speakers_from_text(transcription):
+ """
+ 使用 LLM 从转录文本中识别说话人
+
+ Args:
+ transcription: 转录文本(可能是 JSON 格式)
+
+ Returns:
+ dict: 说话人标签到名称的映射,如 {"SPEAKER_00": "张三", "SPEAKER_01": "李四"}
+
+ Raises:
+ ValueError: 如果 TEXT_MODEL_API_KEY 未配置
+ """
+ from src.config import TEXT_MODEL_API_KEY
+
+ if not TEXT_MODEL_API_KEY:
+ raise ValueError("TEXT_MODEL_API_KEY 未配置。")
+
+ # 转录文本可能是 JSON,先格式化
+ formatted_transcription = format_transcription_for_llm(transcription)
+
+ # 提取现有的说话人标签(如 SPEAKER_00, SPEAKER_01),按出现顺序
+ all_labels = re.findall(r'\[(SPEAKER_\d+)\]', formatted_transcription)
+ seen = set()
+ speaker_labels = [x for x in all_labels if not (x in seen or seen.add(x))]
+
+ if not speaker_labels:
+ return {}
+
+ # 获取可配置的转录文本长度限制
+ transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000)
+ if transcript_limit == -1:
+ # 不限制
+ transcript_text = formatted_transcription
+ else:
+ transcript_text = formatted_transcription[:transcript_limit]
+
+ prompt = f"""分析以下转录文本并识别说话人的姓名。说话人被标记为 {', '.join(speaker_labels)}。根据对话上下文,确定每个说话人标签最可能的姓名。
+
+转录文本:
+---
+{transcript_text}
+---
+
+回复一个 JSON 对象,键是说话人标签(如 "SPEAKER_00"),值是识别出的全名。如果无法确定姓名,使用值 "Unknown"。
+
+示例:
+{{
+ "SPEAKER_00": "张三",
+ "SPEAKER_01": "李四",
+ "SPEAKER_02": "Unknown"
+}}
+
+JSON 响应:
+"""
+
+ try:
+ completion = call_llm_completion(
+ messages=[
+ {"role": "system", "content": "你是分析对话转录文本以识别说话人的专家。你的回复必须是单个、有效的 JSON 对象。"},
+ {"role": "user", "content": prompt}
+ ],
+ temperature=0.2,
+ response_format={"type": "json_object"}
+ )
+ response_content = completion.choices[0].message.content
+ speaker_map = safe_json_loads(response_content, {})
+
+ # 后处理:将 "Unknown" 替换为空字符串
+ for speaker_label, identified_name in speaker_map.items():
+ if identified_name.strip().lower() == "unknown":
+ speaker_map[speaker_label] = ""
+
+ return speaker_map
+ except Exception as e:
+ current_app.logger.error(f"调用 LLM 识别说话人时出错: {e}")
+ raise
+
+
+def identify_unidentified_speakers_from_text(transcription, unidentified_speakers):
+ """
+ 使用 LLM 仅识别转录文本中未识别的说话人
+
+ Args:
+ transcription: 转录文本(可能是 JSON 格式)
+ unidentified_speakers: 需要识别的说话人标签列表
+
+ Returns:
+ dict: 说话人标签到名称的映射
+
+ Raises:
+ ValueError: 如果 TEXT_MODEL_API_KEY 未配置
+ """
+ from src.config import TEXT_MODEL_API_KEY
+
+ if not TEXT_MODEL_API_KEY:
+ raise ValueError("TEXT_MODEL_API_KEY 未配置。")
+
+ # 转录文本可能是 JSON,先格式化
+ formatted_transcription = format_transcription_for_llm(transcription)
+
+ if not unidentified_speakers:
+ return {}
+
+ # 获取可配置的转录文本长度限制
+ transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000)
+ if transcript_limit == -1:
+ # 不限制
+ transcript_text = formatted_transcription
+ else:
+ transcript_text = formatted_transcription[:transcript_limit]
+
+ prompt = f"""分析以下对话转录文本,根据对话的上下文和内容识别未识别说话人的姓名。
+
+需要识别的说话人是: {', '.join(unidentified_speakers)}
+
+在对话中寻找线索,如:
+- 其他说话人在称呼某人时提到的名字
+- 自我介绍或提及自己姓名
+- 关于角色、关系或职位的上下文线索
+- 对话中直接提到的姓名
+
+以下是完整的对话转录文本:
+
+{transcript_text}
+
+根据以上对话,识别未识别说话人最可能的真实姓名。注意倾听说话人之间如何称呼对方以及对话中提到的姓名。
+
+回复一个 JSON 对象,键是说话人标签(如 "SPEAKER_01"),值是识别出的全名。如果无法从对话上下文中确定姓名,使用空字符串 ""。
+
+示例格式:
+{{
+ "SPEAKER_01": "张三",
+ "SPEAKER_03": "李四",
+ "SPEAKER_05": ""
+}}
+
+JSON 响应:
+"""
+
+ try:
+ completion = call_llm_completion(
+ messages=[
+ {"role": "system", "content": "你是根据对话上下文线索分析对话转录文本以识别说话人的专家。仔细分析对话,找出说话人相互称呼时提到的名字或自我介绍时的姓名。你的回复必须是单个、有效的 JSON 对象,仅包含请求的说话人识别信息。"},
+ {"role": "user", "content": prompt}
+ ],
+ temperature=0.2,
+ response_format={"type": "json_object"}
+ )
+ response_content = completion.choices[0].message.content
+ speaker_map = safe_json_loads(response_content, {})
+
+ # 后处理:将 "Unknown" 等替换为空字符串
+ for speaker_label, identified_name in speaker_map.items():
+ if identified_name and identified_name.strip().lower() in ["unknown", "n/a", "not available", "unclear"]:
+ speaker_map[speaker_label] = ""
+
+ return speaker_map
+ except Exception as e:
+ current_app.logger.error(f"调用 LLM 识别说话人时出错: {e}")
+ raise
diff --git a/speakr/src/services/summary_service.py b/speakr/src/services/summary_service.py
new file mode 100644
index 0000000..b217c8b
--- /dev/null
+++ b/speakr/src/services/summary_service.py
@@ -0,0 +1,325 @@
+"""
+Summary Service - 处理录音标题和摘要生成
+"""
+import os
+import logging
+from datetime import datetime
+
+from src.clients import db
+from src.models import Recording, SystemSetting
+from src.services.llm_service import (
+ call_llm_completion,
+ clean_llm_response,
+ format_transcription_for_llm,
+ preprocess_long_transcription,
+ format_api_error_message
+)
+from src.services.embedding_service import process_recording_chunks
+from src.clients.llm_client import client, TEXT_MODEL_NAME
+
+# 查询模式开关
+ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
+
+logger = logging.getLogger(__name__)
+
+
+def generate_title_task(app_context, recording_id):
+ """
+ 为录音生成标题(仅标题)
+
+ Args:
+ app_context: Flask 应用上下文
+ recording_id: 录音 ID
+ """
+ with app_context:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ logger.error(f"错误:未找到录音 {recording_id},无法生成标题。")
+ return
+
+ if client is None:
+ logger.warning(f"跳过录音 {recording_id} 的标题生成:OpenRouter 客户端未配置。")
+ # 即使无法生成标题,仍标记为完成
+ recording.status = 'COMPLETED'
+ recording.completed_at = datetime.utcnow()
+ db.session.commit()
+ return
+
+ if not recording.transcription or len(recording.transcription.strip()) < 10:
+ logger.warning(f"录音 {recording_id} 的转录文本为空或过短,跳过标题生成。")
+ # 即使无法生成标题,仍标记为完成
+ recording.status = 'COMPLETED'
+ recording.completed_at = datetime.utcnow()
+ db.session.commit()
+ return
+
+ # 获取可配置的转录文本长度限制,并格式化为 LLM 可理解的格式
+ transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000)
+ if transcript_limit == -1:
+ raw_transcription = recording.transcription
+ else:
+ raw_transcription = recording.transcription[:transcript_limit]
+
+ # 将 ASR JSON 转换为干净的文本格式
+ transcript_text = format_transcription_for_llm(raw_transcription)
+
+
+ # 获取用户语言偏好
+ user_output_language = None
+ if recording.owner:
+ user_output_language = recording.owner.output_language
+
+ language_directive = f"请使用 {user_output_language} 提供标题。" if user_output_language else ""
+
+ prompt_text = f"""为这段对话创建一个简短标题:
+
+{transcript_text}
+
+要求:
+- 最多 8 个词
+- 不要使用类似"关于...的讨论"或"...的会议"这样的短语
+- 直接点明主题
+
+{language_directive}
+
+标题:"""
+
+ system_message_content = "你是一个 AI 助手,为音频转录文本生成简洁的标题。只回复标题本身。"
+ if user_output_language:
+ system_message_content += f" 确保你的回复使用 {user_output_language}。"
+
+
+ try:
+ completion = call_llm_completion(
+ messages=[
+ {"role": "system", "content": system_message_content},
+ {"role": "user", "content": prompt_text}
+ ],
+ temperature=0.7,
+ max_tokens=5000
+ )
+
+
+ raw_response = completion.choices[0].message.content
+ reasoning = getattr(completion.choices[0].message, 'reasoning', None)
+
+ # 如果主内容为空,使用推理内容作为后备(针对推理模型的降级处理)
+ if not raw_response and reasoning:
+ logger.info(f"录音 {recording_id} 的标题生成:使用推理字段作为后备")
+ # 尝试从推理字段中提取标题
+ lines = reasoning.strip().split('\n')
+ # 查找可能是标题的最后一行
+ for line in reversed(lines):
+ line = line.strip()
+ if line and not line.startswith('I') and len(line.split()) <= 8:
+ raw_response = line
+ break
+
+ title = clean_llm_response(raw_response) if raw_response else ""
+
+ if title:
+ recording.title = title
+ logger.info(f"录音 {recording_id} 已生成标题:{title}")
+ else:
+ logger.warning(f"录音 {recording_id} 生成的标题为空")
+
+ except Exception as e:
+ logger.error(f"为录音 {recording_id} 生成标题时出错:{str(e)}")
+ logger.error(f"异常详情:", exc_info=True)
+
+ # 标题生成后(无论成功与否)始终将状态设置为 COMPLETED
+ # 这确保转录处理被标记为完成
+ recording.status = 'COMPLETED'
+ recording.completed_at = datetime.utcnow()
+ db.session.commit()
+
+ # 完成后处理分块以进行语义搜索(如果启用了 inquire 模式)
+ if ENABLE_INQUIRE_MODE:
+ try:
+ process_recording_chunks(recording_id)
+ except Exception as e:
+ logger.error(f"处理已完成录音 {recording_id} 的分块时出错:{e}")
+
+
+def generate_summary_only_task(app_context, recording_id):
+ """仅生成录音摘要(无标题,无JSON响应)。
+
+ 参数:
+ app_context: Flask应用上下文
+ recording_id:录音的唯一标识
+ """
+ with app_context:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ logger.error(f"录音 {recording_id} 未找到,无法生成摘要。")
+ return
+
+ if client is None:
+ logger.warning(f"跳过录音 {recording_id} 的摘要生成:OpenRouter 客户端未配置。")
+ recording.summary = "[摘要已跳过:OpenRouter 客户端未配置]"
+ db.session.commit()
+ return
+
+ recording.status = 'SUMMARIZING'
+ db.session.commit()
+
+ logger.info(f"正在为录音 {recording_id} 请求摘要生成,使用模型 {TEXT_MODEL_NAME}...")
+
+ if not recording.transcription or len(recording.transcription.strip()) < 10:
+ logger.warning(f"录音 {recording_id} 的转录文本过短或为空,跳过摘要生成。")
+ recording.summary = "[摘要已跳过:转录文本过短]"
+ recording.status = 'COMPLETED'
+ db.session.commit()
+ return
+
+ # 获取用户偏好和标签自定义提示词
+ user_summary_prompt = None
+ user_output_language = None
+ tag_custom_prompt = None
+
+ # 按标签添加到录音的顺序收集所有自定义提示词
+ tag_custom_prompts = []
+ if recording.tags:
+ for tag in recording.tags:
+ if tag.custom_prompt and tag.custom_prompt.strip():
+ tag_custom_prompts.append({
+ 'name': tag.name,
+ 'prompt': tag.custom_prompt.strip()
+ })
+ logger.info(f"录音 {recording_id} 找到标签 '{tag.name}' 的自定义提示词")
+
+ # 如果有多个标签提示词则合并
+ if tag_custom_prompts:
+ if len(tag_custom_prompts) == 1:
+ tag_custom_prompt = tag_custom_prompts[0]['prompt']
+ logger.info(f"录音 {recording_id} 使用单个标签 '{tag_custom_prompts[0]['name']}' 的自定义提示词")
+ else:
+ # 将多个提示词无缝合并为统一指令
+ merged_parts = []
+ for tag_prompt in tag_custom_prompts:
+ merged_parts.append(tag_prompt['prompt'])
+ tag_custom_prompt = "\n\n".join(merged_parts)
+ tag_names = [tp['name'] for tp in tag_custom_prompts]
+ logger.info(f"录音 {recording_id} 合并了 {len(tag_custom_prompts)} 个标签的自定义提示词({', '.join(tag_names)})")
+ else:
+ tag_custom_prompt = None
+
+ if recording.owner:
+ user_summary_prompt = recording.owner.summary_prompt
+ user_output_language = recording.owner.output_language
+
+ # 将转录文本格式化为 LLM 可理解的格式(将 JSON 转换为干净文本)
+ raw_transcription = format_transcription_for_llm(recording.transcription)
+ formatted_transcription = preprocess_long_transcription(raw_transcription)
+
+ # 获取可配置的转录文本长度限制
+ transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000)
+ if transcript_limit == -1:
+ transcript_text = formatted_transcription
+ else:
+ transcript_text = formatted_transcription[:transcript_limit]
+
+ language_directive = f"重要:你必须使用 {user_output_language} 提供摘要。整个回复必须使用 {user_output_language}。" if user_output_language else ""
+
+ # 确定使用哪个摘要生成指令
+ # 优先级:标签自定义提示词 > 用户摘要提示词 > 管理员默认提示词 > 硬编码默认值
+ summarization_instructions = ""
+ if tag_custom_prompt:
+ logger.info(f"录音 {recording_id} 使用标签自定义提示词")
+ summarization_instructions = tag_custom_prompt
+ elif user_summary_prompt:
+ logger.info(f"录音 {recording_id} 使用用户自定义提示词")
+ summarization_instructions = user_summary_prompt
+ else:
+ # 从系统设置中获取管理员默认提示词
+ admin_default_prompt = SystemSetting.get_setting('admin_default_summary_prompt', None)
+ if admin_default_prompt:
+ logger.info(f"录音 {recording_id} 使用管理员默认提示词")
+ summarization_instructions = admin_default_prompt
+ else:
+ # 如果管理员未设置,使用硬编码默认值
+ summarization_instructions = """生成一个全面的摘要,包含以下部分:
+- **讨论的关键问题**:主要话题的要点列表
+- **做出的关键决策**:达成的决策要点列表
+- **行动项**:分配的任务要点列表,包括负责人(如果提及)"""
+ logger.info(f"录音 {recording_id} 使用硬编码默认提示词")
+
+ # 构建上下文信息
+ current_date = datetime.now().strftime("%Y年%m月%d日")
+ context_parts = []
+ context_parts.append(f"当前日期:{current_date}")
+
+ # 添加选中标签信息
+ if recording.tags:
+ tag_names = [tag.name for tag in recording.tags]
+ context_parts.append(f"用户为此转录应用的标签:{', '.join(tag_names)}")
+
+ context_section = "上下文:\n" + "\n".join(f"- {part}" for part in context_parts)
+
+ # 构建 SYSTEM 消息:初始指令 + 上下文 + 语言要求
+ system_message_content = "你是一个 AI 助手,专门为会议转录文本生成全面摘要。仅以 Markdown 格式回复摘要内容。不要使用 Markdown 代码块。"
+ system_message_content += f"\n\n{context_section}"
+ if user_output_language:
+ system_message_content += f"\n\n语言要求:你必须使用 {user_output_language} 生成整个摘要。这是强制性的。"
+
+ # 构建 USER 消息:转录文本 + 摘要指令 + 语言要求
+ prompt_text = f"""转录内容:
+\"\"\"
+{transcript_text}
+\"\"\"
+
+摘要指令:
+{summarization_instructions}
+
+{language_directive}"""
+
+ # 调试日志:记录发送给 LLM 的完整提示词
+ logger.info(f"向 LLM 发送摘要提示词(长度:{len(prompt_text)} 字符)。设置 LOG_LEVEL=DEBUG 可查看完整提示词详情。")
+ logger.debug(f"=== 录音 {recording_id} 摘要生成调试 ===")
+ logger.debug(f"系统消息:{system_message_content}")
+ logger.debug(f"用户提示词(长度:{len(prompt_text)} 字符):\n{prompt_text}")
+ logger.debug(f"=== 录音 {recording_id} 摘要生成调试结束 ===")
+
+ try:
+ completion = call_llm_completion(
+ messages=[
+ {"role": "system", "content": system_message_content},
+ {"role": "user", "content": prompt_text}
+ ],
+ temperature=0.5,
+ max_tokens=int(os.environ.get("SUMMARY_MAX_TOKENS", "3000"))
+ )
+
+ raw_response = completion.choices[0].message.content
+ logger.info(f"录音 {recording_id} 的 LLM 原始响应:'{raw_response}'")
+
+ summary = clean_llm_response(raw_response) if raw_response else ""
+ logger.info(f"录音 {recording_id} 的处理后摘要长度:{len(summary)} 字符")
+
+ if summary:
+ recording.summary = summary
+ db.session.commit()
+ logger.info(f"录音 {recording_id} 摘要生成成功")
+
+ # 如果用户启用了事件提取,则在标记完成前执行
+ if recording.owner and recording.owner.extract_events:
+ from src.services.event_service import extract_events_from_transcript
+ extract_events_from_transcript(recording_id, formatted_transcription, summary)
+
+ # 事件提取完成后标记为已完成
+ recording.status = 'COMPLETED'
+ recording.completed_at = datetime.utcnow()
+ db.session.commit()
+ else:
+ logger.warning(f"录音 {recording_id} 生成的摘要为空")
+ recording.summary = "[摘要未生成]"
+ recording.status = 'COMPLETED'
+ db.session.commit()
+
+ except Exception as e:
+ error_msg = format_api_error_message(str(e))
+ logger.error(f"录音 {recording_id} 摘要生成出错:{str(e)}")
+ recording.summary = error_msg
+ recording.status = 'COMPLETED'
+ recording.completed_at = datetime.utcnow()
+ db.session.commit()
diff --git a/speakr/src/services/sync_service.py b/speakr/src/services/sync_service.py
new file mode 100644
index 0000000..f010041
--- /dev/null
+++ b/speakr/src/services/sync_service.py
@@ -0,0 +1,160 @@
+"""
+远程数据同步服务
+将本地完成的录音同步到远程 PostgreSQL 数据库
+"""
+import os
+import time
+import threading
+from sqlalchemy import create_engine, inspect as sqlalchemy_inspect, text
+from src.clients import db
+from src.models import Recording
+import logging
+
+logger = logging.getLogger(__name__)
+
+# 配置远程数据库连接
+REMOTE_DB_URI = os.environ.get('REMOTE_DB_URI', 'postgresql://postgres:password@172.18.30.122:5433/planner')
+# 同步间隔(秒),默认 5 分钟
+SYNC_INTERVAL = int(os.environ.get('SYNC_INTERVAL', 300))
+
+
+def resolve_remote_recording_table_name(remote_engine):
+ """
+ 解析远程录音表名(支持单复数变体)
+
+ Args:
+ remote_engine: SQLAlchemy 引擎实例
+
+ Returns:
+ str: 远程表名
+
+ Raises:
+ RuntimeError: 如果找不到对应的表
+ """
+ candidate_table_names = []
+ local_table_name = Recording.__table__.name
+
+ for table_name in [local_table_name, f"{local_table_name}s", "recording", "recordings"]:
+ if table_name not in candidate_table_names:
+ candidate_table_names.append(table_name)
+
+ inspector = sqlalchemy_inspect(remote_engine)
+ for table_name in candidate_table_names:
+ if inspector.has_table(table_name):
+ return table_name
+
+ raise RuntimeError(
+ "远程数据库中未找到录音表,已尝试: "
+ + ", ".join(candidate_table_names)
+ )
+
+
+def sync_completed_recordings_to_remote(app):
+ """
+ 后台任务:将本地 COMPLETED 状态的录音同步到远程 PostgreSQL 数据库。
+ 增量同步:只推送 ID 不在远程数据库中的记录。
+
+ Args:
+ app: Flask 应用实例
+ """
+ app.logger.info(f"启动远程数据同步服务,目标: {REMOTE_DB_URI},间隔: {SYNC_INTERVAL}秒")
+
+ while True:
+ try:
+ # 使用应用上下文访问本地数据库
+ with app.app_context():
+ # 1. 获取本地所有 COMPLETED 的录音
+ local_completed_recordings = Recording.query.filter_by(status='COMPLETED').all()
+
+ if not local_completed_recordings:
+ app.logger.debug("本地没有 COMPLETED 的录音,跳过同步。")
+ time.sleep(SYNC_INTERVAL)
+ continue
+
+ local_map = {r.id: r for r in local_completed_recordings}
+ local_ids = set(local_map.keys())
+
+ # 2. 连接远程 PostgreSQL 数据库
+ remote_engine = create_engine(REMOTE_DB_URI)
+ remote_table_name = resolve_remote_recording_table_name(remote_engine)
+
+ with remote_engine.connect() as remote_conn:
+ # 3. 查询远程数据库已存在的 ID
+ result = remote_conn.execute(text(f"SELECT id FROM {remote_table_name}"))
+ remote_ids = set(row[0] for row in result)
+
+ # 4. 计算增量(本地有但远程没有的 ID)
+ ids_to_sync = local_ids - remote_ids
+
+ if not ids_to_sync:
+ app.logger.debug("远程数据库已是最新,无需同步。")
+ else:
+ app.logger.info(f"发现 {len(ids_to_sync)} 条新记录需要同步到远程数据库。")
+
+ # 5. 准备插入数据
+ records_to_insert = []
+ for rid in ids_to_sync:
+ rec = local_map[rid]
+
+ # 构建字典,确保字段与远程数据库列名一致
+ record_dict = {
+ 'id': rec.id,
+ 'user_id': rec.user_id,
+ 'title': rec.title,
+ 'participants': rec.participants,
+ 'notes': rec.notes,
+ 'transcription': rec.transcription,
+ 'summary': rec.summary,
+ 'status': rec.status,
+ 'audio_path': rec.audio_path,
+ 'created_at': rec.created_at,
+ 'meeting_date': rec.meeting_date,
+ 'file_size': rec.file_size,
+ 'original_filename': rec.original_filename,
+ 'is_inbox': rec.is_inbox,
+ 'is_highlighted': rec.is_highlighted,
+ 'mime_type': rec.mime_type,
+ 'completed_at': rec.completed_at,
+ 'processing_time_seconds': rec.processing_time_seconds,
+ 'processing_source': rec.processing_source,
+ 'error_message': rec.error_message,
+ 'is_sync': 0
+ }
+ records_to_insert.append(record_dict)
+
+ # 6. 执行批量插入
+ if records_to_insert:
+ keys = list(records_to_insert[0].keys())
+ columns = ", ".join(keys)
+ placeholders = ", ".join([f":{key}" for key in keys])
+
+ insert_sql = text(
+ f"INSERT INTO {remote_table_name} ({columns}) VALUES ({placeholders})"
+ )
+
+ remote_conn.execute(insert_sql, records_to_insert)
+ remote_conn.commit()
+
+ app.logger.info(f"成功同步 {len(records_to_insert)} 条记录到远程 PostgreSQL。")
+
+ except Exception as e:
+ app.logger.error(f"远程同步过程中发生错误: {str(e)}")
+
+ # 休眠指定时间
+ time.sleep(SYNC_INTERVAL)
+
+
+def start_sync_thread(app):
+ """
+ 启动同步线程
+
+ Args:
+ app: Flask 应用实例
+ """
+ app.logger.info("启动同步线程")
+ if os.environ.get('ENABLE_REMOTE_SYNC', 'false').lower() == 'true':
+ sync_thread = threading.Thread(target=sync_completed_recordings_to_remote, args=(app,), daemon=True)
+ sync_thread.start()
+ app.logger.info("远程数据同步线程已启动")
+ else:
+ app.logger.info("远程数据同步功能未启用 (ENABLE_REMOTE_SYNC=false)")
diff --git a/speakr/src/services/transcription_service.py b/speakr/src/services/transcription_service.py
new file mode 100644
index 0000000..d21b5c8
--- /dev/null
+++ b/speakr/src/services/transcription_service.py
@@ -0,0 +1,896 @@
+"""
+Transcription Service - 处理音频转录相关业务逻辑
+"""
+import os
+import json
+import time
+import tempfile
+import subprocess
+import mimetypes
+import logging
+from pathlib import Path
+from datetime import datetime
+
+import httpx
+from openai import OpenAI
+
+from src.clients import db
+from src.models import Recording, SystemSetting
+from src.config import TRANSCRIPTION_API_KEY, TRANSCRIPTION_BASE_URL
+
+logger = logging.getLogger(__name__)
+
+
+def extract_audio_from_video(video_filepath, output_format='mp3', cleanup_original=True, app_logger=None):
+ """使用 FFmpeg 从视频容器中提取音频
+
+ 使用 MP3 编解码器以获得最佳兼容性和可预测的文件大小。
+ 64kbps MP3 提供良好的语音质量,每分钟约 480KB。
+ """
+ _logger = app_logger or logger
+
+ try:
+ # 生成带有音频扩展名的输出文件名
+ base_filepath, file_ext = os.path.splitext(video_filepath)
+ temp_audio_filepath = f"{base_filepath}_audio_temp.{output_format}"
+ final_audio_filepath = f"{base_filepath}_audio.{output_format}"
+
+ _logger.info(f"从视频中提取音频:{video_filepath} -> {temp_audio_filepath}")
+
+ # 使用 FFmpeg 提取音频 - 使用高质量 MP3 以获得更好的转录效果
+ subprocess.run([
+ 'ffmpeg', '-i', video_filepath, '-y',
+ '-vn', # 不提取视频
+ '-codec:a', 'libmp3lame', # 明确使用 LAME MP3 编码器
+ '-b:a', '128k', # 128kbps 比特率,高质量
+ '-ar', '44100', # 44.1kHz 采样率,更好的质量
+ '-ac', '1', # 单声道(语音足够,减少文件大小)
+ '-compression_level', '2', # 更好的压缩
+ temp_audio_filepath
+ ], check=True, capture_output=True, text=True)
+
+ _logger.info(f"音频提取成功:{temp_audio_filepath}")
+
+ # 可选保留临时文件用于调试(在环境变量中设置 PRESERVE_TEMP_AUDIO=true)
+ if os.getenv('PRESERVE_TEMP_AUDIO', 'false').lower() == 'true':
+ import shutil
+ shutil.copy2(temp_audio_filepath, temp_audio_filepath.replace('_temp', '_debug'))
+ _logger.info(f"调试:已保留临时音频文件为 {temp_audio_filepath.replace('_temp', '_debug')}")
+
+ # 将临时文件重命名为最终文件名
+ os.rename(temp_audio_filepath, final_audio_filepath)
+
+ # 如果要求,清理原始视频文件
+ if cleanup_original:
+ try:
+ os.remove(video_filepath)
+ _logger.info(f"已清理原始视频文件:{video_filepath}")
+ except Exception as e:
+ _logger.warning(f"清理原始视频文件 {video_filepath} 失败:{str(e)}")
+
+ return final_audio_filepath, f'audio/{output_format}'
+
+ except subprocess.CalledProcessError as e:
+ _logger.error(f"FFmpeg 音频提取失败,文件 {video_filepath}:{e.stderr}")
+ raise Exception(f"音频提取失败:{e.stderr}")
+ except FileNotFoundError:
+ _logger.error("未找到 FFmpeg 命令。请确保已安装 FFmpeg 并将其添加到系统 PATH 中。")
+ raise Exception("服务器上未找到音频转换工具(FFmpeg)。")
+ except Exception as e:
+ _logger.error(f"从 {video_filepath} 提取音频时出错:{str(e)}")
+ raise
+
+
+def convert_to_wav(input_path, app_logger=None):
+ """
+ 将音频文件转换为WAV格式
+ Args:
+ input_path: 输入音频文件路径
+ app_logger: Flask app logger
+ Returns:
+ str: 转换后的WAV文件路径
+ Raises:
+ Exception: 转换失败时抛出异常
+ """
+ _logger = app_logger or logger
+
+ # 检查文件是否存在
+ if not os.path.exists(input_path):
+ raise FileNotFoundError(f"音频文件不存在: {input_path}")
+
+ # 获取文件MIME类型
+ mime_type, _ = mimetypes.guess_type(input_path)
+ _logger.info(f"文件 MIME 类型:{mime_type},路径:{input_path}")
+ # 如果是WAV格式,直接返回
+ if mime_type == 'audio/wav' or input_path.lower().endswith('.wav'):
+ return input_path
+
+ # 创建临时文件用于存储转换后的WAV
+ temp_dir = tempfile.gettempdir()
+ output_filename = Path(input_path).stem + '_converted.wav'
+ output_path = os.path.join(temp_dir, output_filename)
+
+ try:
+ # 使用ffmpeg进行格式转换
+ cmd = [
+ 'ffmpeg', '-y', '-i', input_path,
+ '-acodec', 'pcm_s16le',
+ '-ar', '16000',
+ '-ac', '1',
+ output_path
+ ]
+
+ # 执行转换命令
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+
+ if result.returncode != 0:
+ raise Exception(f"音频转换失败: {result.stderr}")
+
+ # 检查输出文件是否生成成功
+ if not os.path.exists(output_path):
+ raise Exception("转换后的文件未生成")
+
+ return output_path
+
+ except subprocess.TimeoutExpired:
+ raise Exception("音频转换超时")
+ except Exception as e:
+ # 清理可能生成的临时文件
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ raise Exception(f"音频转换错误: {str(e)}")
+
+
+def transcribe_audio_asr(app_context, recording_id, filepath, original_filename, start_time,
+ mime_type=None, language=None, diarize=False, min_speakers=None,
+ max_speakers=None, tag_id=None,
+ ASR_BASE_URL=None, USE_ASR_ENDPOINT=True, ASR_DIARIZE=False,
+ generate_title_task=None, generate_summary_only_task=None,
+ app_logger=None, UPLOAD_FOLDER=None):
+ """使用 ASR 服务进行音频转录"""
+ _logger = app_logger or logger
+
+ with app_context:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ _logger.error(f"错误:未找到录音 {recording_id},无法进行 ASR 转录。")
+ return
+
+ try:
+ if not ASR_BASE_URL:
+ raise ValueError("ASR_BASE_URL 未配置,无法调用外部 ASR 服务")
+
+ _logger.info(f"开始对录音 {recording_id} 进行 ASR 转录...")
+ recording.status = 'PROCESSING'
+ db.session.commit()
+
+ # 检查是否需要从视频容器中提取音频
+ actual_filepath = filepath
+ actual_content_type = mime_type or mimetypes.guess_type(original_filename)[0] or 'application/octet-stream'
+ actual_filename = original_filename
+
+ # 需要提取音频的视频 MIME 类型列表
+ video_mime_types = [
+ 'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm',
+ 'video/avi', 'video/x-ms-wmv', 'video/3gpp'
+ ]
+
+ # 通过 MIME 类型或扩展名检查文件是否为视频容器
+ is_video = (
+ actual_content_type.startswith('video/') or
+ actual_content_type in video_mime_types or
+ original_filename.lower().endswith(('.mp4', '.mov', '.avi', '.mkv', '.webm', '.wmv', '.3gp'))
+ )
+
+ if is_video:
+ _logger.info(f"检测到视频容器({actual_content_type}),正在提取音频...")
+ try:
+ # 从视频中提取音频
+ audio_filepath, audio_mime_type = extract_audio_from_video(filepath, 'mp3', app_logger=_logger)
+
+ # 更新路径和 MIME 类型用于 ASR 处理
+ actual_filepath = audio_filepath
+ actual_content_type = audio_mime_type
+ actual_filename = os.path.basename(audio_filepath)
+
+ # 更新录音的音频路径和新的 MIME 类型
+ recording.audio_path = audio_filepath
+ recording.mime_type = audio_mime_type
+ db.session.commit()
+
+ _logger.info(f"音频提取成功:{audio_filepath}")
+ except Exception as e:
+ _logger.error(f"从视频中提取音频失败:{str(e)}")
+ recording.status = 'FAILED'
+ recording.error_message = f"音频提取失败:{str(e)}"
+ db.session.commit()
+ return
+
+ # 跟踪是否已尝试过 WAV 转换
+ wav_conversion_attempted = False
+ wav_converted_filepath = None
+
+ # 重试循环,用于处理 500 错误并进行 WAV 转换
+ max_attempts = 2
+ for attempt in range(max_attempts):
+ try:
+ # 如果之前有转换过的 MP3 则使用
+ current_filepath = wav_converted_filepath if wav_converted_filepath else actual_filepath
+ current_content_type = 'audio/mpeg' if wav_converted_filepath else actual_content_type
+ current_filename = os.path.basename(current_filepath)
+
+ with open(current_filepath, 'rb') as audio_file:
+ url = f"{ASR_BASE_URL}/asr"
+ params = {
+ 'batch_size_s':300,
+ 'spk_diarization':True,
+ 'spk_num':0,
+ 'spk_threshold':0.6,
+ 'spk_cluster_method':'ahc',
+ 'spk_smooth_window':3,
+ 'spk_min_duration':1,
+ 'spk_merge_duration':0.3
+ }
+
+ content_type = current_content_type
+ _logger.info(f"ASR 上传使用 MIME 类型:{content_type}")
+ files = {'audio_file': (current_filename, audio_file, content_type)}
+
+ with httpx.Client() as client:
+ # 从数据库获取可配置的 ASR 超时时间(默认 30 分钟)
+ asr_timeout_seconds = SystemSetting.get_setting('asr_timeout_seconds', 18000)
+ timeout = httpx.Timeout(None, connect=30.0, read=float(asr_timeout_seconds), write=30.0, pool=30.0)
+ _logger.info(f"向 {url} 发送 ASR 请求,参数:{params}(超时:{asr_timeout_seconds}秒)")
+ response = client.post(url, params=params, files=files, timeout=timeout)
+ _logger.info(f"ASR 请求完成,状态码:{response.status_code}")
+ response.raise_for_status()
+
+ # 解析 ASR 的 JSON 响应
+ asr_response_data = response.json()
+
+ # 请求成功,退出重试循环
+ break
+
+ except httpx.HTTPStatusError as e:
+ # 检查是否为 500 错误且尚未尝试过转换
+ if e.response.status_code == 500 and attempt == 0 and not wav_conversion_attempted:
+ _logger.warning(f"录音 {recording_id} ASR 返回 500 错误,尝试高质量 MP3 转换并重试...")
+
+ # 转换为高质量 MP3 以获得更好的兼容性
+ filename_lower = actual_filename.lower()
+ if not filename_lower.endswith('.mp3'):
+ try:
+ base_filepath, file_ext = os.path.splitext(actual_filepath)
+ temp_mp3_filepath = f"{base_filepath}_temp.mp3"
+
+ _logger.info(f"正在将 {actual_filename} 转换为高质量 MP3 格式以便重试...")
+ subprocess.run(
+ ['ffmpeg', '-i', actual_filepath, '-y', '-acodec', 'libmp3lame', '-b:a', '128k', '-ar', '44100', temp_mp3_filepath],
+ check=True, capture_output=True, text=True
+ )
+ _logger.info(f"成功将 {actual_filepath} 转换为 {temp_mp3_filepath}")
+
+ wav_converted_filepath = temp_mp3_filepath
+ wav_conversion_attempted = True
+ continue
+ except subprocess.CalledProcessError as conv_error:
+ _logger.error(f"WAV 转换失败:{conv_error}")
+ raise e
+ else:
+ _logger.error(f"文件已经是 WAV 格式但仍收到 500 错误")
+ raise e
+ else:
+ raise e
+
+ # 调试:保留转换后的文件用于质量检查
+ if wav_converted_filepath and os.path.exists(wav_converted_filepath):
+ try:
+ converted_size = os.path.getsize(wav_converted_filepath)
+ converted_size_mb = converted_size / (1024 * 1024)
+
+ debug_dir = os.path.join(UPLOAD_FOLDER, 'debug_converted')
+ os.makedirs(debug_dir, exist_ok=True)
+
+ from shutil import copy2
+ file_ext = os.path.splitext(wav_converted_filepath)[1] or '.mp3'
+ debug_filename = f"debug_{recording_id}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}{file_ext}"
+ debug_filepath = os.path.join(debug_dir, debug_filename)
+ copy2(wav_converted_filepath, debug_filepath)
+
+ _logger.info(f"调试:转换后的文件已保留在:{debug_filepath}")
+ _logger.info(f"调试:转换后的文件大小:{converted_size_mb:.2f} MB({converted_size} 字节)")
+ _logger.info(f"调试:原始文件:{actual_filename}")
+ _logger.info(f"调试:录音 ID:{recording_id}")
+ _logger.info(f"调试:你可以从容器中下载此文件:{debug_filepath}")
+
+ except Exception as debug_error:
+ _logger.warning(f"调试:保留转换后的文件失败:{debug_error}")
+
+ # 清理原始临时转换文件(保留调试副本)
+ try:
+ if wav_converted_filepath and os.path.exists(wav_converted_filepath):
+ os.remove(wav_converted_filepath)
+ _logger.info(f"已清理原始临时转换文件:{wav_converted_filepath}")
+ except Exception as cleanup_error:
+ _logger.warning(f"清理临时转换文件失败:{cleanup_error}")
+
+ # ASR 响应的调试日志
+ _logger.info(f"ASR 响应键:{list(asr_response_data.keys())}")
+
+ # 记录完整的原始 JSON 响应(截断以提高可读性)
+ import json as json_module
+ raw_json_str = json_module.dumps(asr_response_data, indent=2)
+ if len(raw_json_str) > 5000:
+ _logger.info(f"原始 ASR 响应(前 5000 字符):{raw_json_str[:5000]}...")
+ else:
+ _logger.info(f"原始 ASR 响应:{raw_json_str}")
+
+ if 'segments' in asr_response_data:
+ _logger.info(f"片段数量:{len(asr_response_data['segments'])}")
+
+ # 从响应中收集所有唯一说话人
+ all_speakers = set()
+ segments_with_speakers = 0
+ segments_without_speakers = 0
+
+ for segment in asr_response_data['segments']:
+ if 'speaker' in segment and segment['speaker'] is not None:
+ all_speakers.add(segment['speaker'])
+ segments_with_speakers += 1
+ else:
+ segments_without_speakers += 1
+
+ _logger.info(f"原始响应中找到的唯一说话人:{sorted(list(all_speakers))}")
+ _logger.info(f"有说话人的片段:{segments_with_speakers},无说话人的片段:{segments_without_speakers}")
+
+ # 记录前几个片段用于调试
+ for i, segment in enumerate(asr_response_data['segments'][:5]):
+ segment_keys = list(segment.keys())
+ _logger.info(f"片段 {i} 的键:{segment_keys}")
+ _logger.info(f"片段 {i}:说话人='{segment.get('speaker')}',文本='{segment.get('text', '')[:50]}...'")
+
+ # 简化 JSON 数据
+ simplified_segments = []
+ if 'segments' in asr_response_data and isinstance(asr_response_data['segments'], list):
+ last_known_speaker = None
+
+ for i, segment in enumerate(asr_response_data['segments']):
+ speaker = segment.get('speaker')
+ text = segment.get('text', '').strip()
+
+ # 如果片段没有说话人,使用前一个片段的说话人
+ if speaker is None:
+ if last_known_speaker is not None:
+ speaker = last_known_speaker
+ _logger.info(f"为片段 {i} 分配了前一片段的说话人 '{speaker}'")
+ else:
+ speaker = 'UNKNOWN_SPEAKER'
+ _logger.warning(f"片段 {i} 无前一个说话人可用,使用 UNKNOWN_SPEAKER")
+ else:
+ last_known_speaker = speaker
+
+ simplified_segments.append({
+ 'speaker': speaker,
+ 'sentence': text,
+ 'start_time': segment.get('start'),
+ 'end_time': segment.get('end')
+ })
+
+ # 记录最终简化片段数量
+ _logger.info(f"创建了 {len(simplified_segments)} 个简化片段")
+ null_speaker_count = sum(1 for seg in simplified_segments if seg['speaker'] is None)
+ if null_speaker_count > 0:
+ _logger.warning(f"最终输出中发现 {null_speaker_count} 个片段的说话人为空")
+
+ # 将简化后的 JSON 存储为字符串
+ recording.transcription = json.dumps(simplified_segments)
+
+ # 提交转录数据
+ db.session.commit()
+ _logger.info(f"录音 {recording_id} 的 ASR 转录已完成。")
+
+ # 立即生成标题
+ generate_title_task(app_context, recording_id)
+
+ # 始终自动为所有录音生成摘要
+ _logger.info(f"正在为录音 {recording_id} 自动生成摘要")
+ generate_summary_only_task(app_context, recording_id)
+
+ except Exception as e:
+ db.session.rollback()
+
+ # 专门处理超时错误
+ error_msg = str(e)
+ if "timed out" in error_msg.lower() or "timeout" in error_msg.lower():
+ asr_timeout = SystemSetting.get_setting('asr_timeout_seconds', 18000)
+ _logger.error(f"录音 {recording_id} 的 ASR 处理在 {asr_timeout} 秒后超时。考虑在管理后台 > 系统设置中增加 'asr_timeout_seconds'。")
+ user_error_msg = f"ASR 处理在 {asr_timeout} 秒后超时。文件可能过长,超出当前超时设置。"
+ else:
+ _logger.error(f"录音 {recording_id} 的 ASR 处理失败:{error_msg}")
+ user_error_msg = f"ASR 处理失败:{error_msg}"
+
+ recording = db.session.get(Recording, recording_id)
+ if recording:
+ recording.status = 'FAILED'
+ recording.transcription = user_error_msg
+ db.session.commit()
+
+
+def transcribe_audio_task(app_context, recording_id, filepath, filename_for_asr, start_time,
+ language=None, min_speakers=None, max_speakers=None, tag_id=None,
+ ASR_BASE_URL=None, USE_ASR_ENDPOINT=True, ASR_DIARIZE=False,
+ chunking_service=None, ENABLE_CHUNKING=False,
+ transcription_api_key=None, transcription_base_url=None,
+ http_client_no_proxy=None, http_client=None,
+ generate_title_task=None, generate_summary_only_task=None,
+ app_logger=None, UPLOAD_FOLDER=None):
+ """在后台线程中运行转录和摘要生成
+
+ Args:
+ app_context: Flask 应用上下文
+ recording_id: 待处理的录音 ID
+ filepath: 音频文件路径
+ filename_for_asr: ASR 使用的文件名
+ start_time: 处理开始时间
+ language: 可选的语言代码(来自上传表单)
+ min_speakers: 可选的最少说话人数(来自上传表单)
+ max_speakers: 可选的最多说话人数(来自上传表单)
+ tag_id: 可选的标签 ID,用于应用自定义提示词
+ """
+ _logger = app_logger or logger
+
+ if USE_ASR_ENDPOINT:
+ with app_context:
+ recording = db.session.get(Recording, recording_id)
+ # 使用 ASR 配置的说话人分离设置(已从 config 统一读取)
+ diarize_setting = ASR_DIARIZE
+
+ # 如果上传表单提供了语言则使用,否则使用用户默认设置
+ if language:
+ user_transcription_language = language
+ else:
+ user_transcription_language = recording.owner.transcription_language if recording.owner else None
+ # 使用上传表单中的最少数/最多数说话人设置(已处理优先级层次)
+ final_min_speakers = min_speakers
+ final_max_speakers = max_speakers
+
+ transcribe_audio_asr(app_context, recording_id, filepath, filename_for_asr, start_time,
+ mime_type=recording.mime_type,
+ language=user_transcription_language,
+ diarize=diarize_setting,
+ min_speakers=final_min_speakers,
+ max_speakers=final_max_speakers,
+ tag_id=tag_id,
+ ASR_BASE_URL=ASR_BASE_URL, USE_ASR_ENDPOINT=USE_ASR_ENDPOINT,
+ ASR_DIARIZE=ASR_DIARIZE,
+ generate_title_task=generate_title_task,
+ generate_summary_only_task=generate_summary_only_task,
+ app_logger=_logger, UPLOAD_FOLDER=UPLOAD_FOLDER)
+
+ # ASR 任务完成后,计算处理时间
+ with app_context:
+ recording = db.session.get(Recording, recording_id)
+ if recording.status in ['COMPLETED', 'FAILED']:
+ end_time = datetime.utcnow()
+ recording.processing_time_seconds = (end_time - start_time).total_seconds()
+ db.session.commit()
+ return
+
+ with app_context:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ _logger.error(f"错误:未找到录音 {recording_id},无法进行转录。")
+ return
+
+ try:
+ _logger.info(f"开始转录录音 {recording_id}({filename_for_asr})...")
+ recording.status = 'PROCESSING'
+ db.session.commit()
+
+ # 检查大文件是否需要分块处理
+ needs_chunking = (chunking_service and
+ ENABLE_CHUNKING and
+ chunking_service.needs_chunking(filepath, USE_ASR_ENDPOINT))
+
+ if needs_chunking:
+ _logger.info(f"文件 {filepath} 较大({os.path.getsize(filepath)/1024/1024:.1f}MB),使用分块转录")
+ transcription_text = transcribe_with_chunking(app_context, recording_id, filepath, filename_for_asr,
+ db=db, Recording=Recording,
+ chunking_service=chunking_service,
+ transcription_api_key=transcription_api_key,
+ transcription_base_url=transcription_base_url,
+ http_client_no_proxy=http_client_no_proxy,
+ app_logger=_logger)
+ else:
+ # --- 小文件的标准转录 ---
+ transcription_text = transcribe_single_file(filepath, recording,
+ db=db,
+ transcription_api_key=transcription_api_key,
+ transcription_base_url=transcription_base_url,
+ http_client_no_proxy=http_client_no_proxy,
+ app_logger=_logger)
+
+ recording.transcription = transcription_text
+ _logger.info(f"录音 {recording_id} 转录完成。文本长度:{len(recording.transcription)}")
+
+ # 立即生成标题
+ generate_title_task(app_context, recording_id)
+
+ # 始终自动为所有录音生成摘要
+ _logger.info(f"正在为录音 {recording_id} 自动生成摘要")
+ generate_summary_only_task(app_context, recording_id)
+
+ except Exception as e:
+ db.session.rollback()
+ _logger.error(f"录音 {recording_id} 处理失败:{str(e)}", exc_info=True)
+ # 重新获取录音(会话可能已被回滚)
+ recording = db.session.get(Recording, recording_id)
+ if recording:
+ if recording.status not in ['COMPLETED', 'FAILED']:
+ recording.status = 'FAILED'
+ if not recording.transcription:
+ recording.transcription = f"处理失败:{str(e)}"
+ if recording.status == 'SUMMARIZING' and not recording.summary:
+ recording.summary = f"[摘要生成期间处理失败:{str(e)}]"
+ db.session.commit()
+
+ # 计算处理时间
+ end_time = datetime.utcnow()
+ recording = db.session.get(Recording, recording_id)
+ if recording and recording.status in ['COMPLETED', 'FAILED']:
+ recording.processing_time_seconds = (end_time - start_time).total_seconds()
+ db.session.commit()
+
+
+def transcribe_single_file(filepath, recording, db=None,
+ transcription_api_key=None, transcription_base_url=None,
+ http_client_no_proxy=None, app_logger=None):
+ """使用 OpenAI Whisper API 转录单个音频文件"""
+ _logger = app_logger or logger
+
+ # 如果未提供,使用配置的默认值
+ if transcription_api_key is None:
+ transcription_api_key = TRANSCRIPTION_API_KEY
+ if transcription_base_url is None:
+ transcription_base_url = TRANSCRIPTION_BASE_URL
+
+ # 检查是否需要从视频容器中提取音频
+ actual_filepath = filepath
+ mime_type = recording.mime_type if recording else None
+
+ # 检测视频容器
+ is_video = False
+ if mime_type:
+ is_video = mime_type.startswith('video/')
+ else:
+ is_video = filepath.lower().endswith(('.mp4', '.mov', '.avi', '.mkv', '.webm', '.wmv', '.3gp'))
+
+ if is_video:
+ _logger.info(f"检测到视频容器用于 Whisper 转录,正在提取音频...")
+ try:
+ audio_filepath, audio_mime_type = extract_audio_from_video(filepath, 'wav', app_logger=_logger)
+ actual_filepath = audio_filepath
+
+ if recording:
+ recording.audio_path = audio_filepath
+ recording.mime_type = audio_mime_type
+ db.session.commit()
+
+ _logger.info(f"Whisper 音频提取成功:{audio_filepath}")
+ except Exception as e:
+ _logger.error(f"Whisper 从视频中提取音频失败:{str(e)}")
+ if recording:
+ recording.status = 'FAILED'
+ recording.error_message = f"音频提取失败:{str(e)}"
+ db.session.commit()
+ raise Exception(f"音频提取失败:{str(e)}")
+
+ # Whisper API 支持的文件格式列表
+ WHISPER_SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']
+
+ # 检查文件格式是否需要转换
+ file_ext = os.path.splitext(actual_filepath)[1].lower().lstrip('.')
+ converted_filepath = None
+
+ # 延迟导入 http_client,避免循环导入
+ from src.clients.llm_client import http_client_no_proxy as llm_http_client
+
+ try:
+ with open(actual_filepath, 'rb') as audio_file:
+ transcription_client = OpenAI(
+ api_key=transcription_api_key,
+ base_url=transcription_base_url,
+ http_client=http_client_no_proxy or llm_http_client
+ )
+ whisper_model = os.environ.get("WHISPER_MODEL", "Systran/faster-distil-whisper-large-v3")
+
+ user_transcription_language = None
+ if recording and recording.owner:
+ user_transcription_language = recording.owner.transcription_language
+
+ transcription_language = user_transcription_language
+
+ transcription_params = {
+ "model": whisper_model,
+ "file": audio_file
+ }
+
+ if transcription_language:
+ transcription_params["language"] = transcription_language
+ _logger.info(f"使用转录语言:{transcription_language}")
+ else:
+ _logger.info("未设置转录语言,使用自动检测或服务默认值。")
+
+ transcript = transcription_client.audio.transcriptions.create(**transcription_params)
+ return transcript.text
+
+ except Exception as e:
+ # 检查是否为格式错误
+ error_message = str(e)
+ if "Invalid file format" in error_message or "Supported formats" in error_message:
+ _logger.warning(f"检测到不支持的音频格式 '{file_ext}',正在转换为 MP3...")
+
+ # 转换为 MP3
+ temp_mp3_filepath = None
+ try:
+ with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as temp_mp3:
+ temp_mp3_filepath = temp_mp3.name
+
+ # 使用 ffmpeg 以一致的设置转换为 MP3
+ subprocess.run(
+ ['ffmpeg', '-i', actual_filepath, '-y', '-acodec', 'libmp3lame', '-b:a', '128k', '-ar', '44100', temp_mp3_filepath],
+ check=True,
+ capture_output=True
+ )
+ _logger.info(f"成功将 {actual_filepath} 转换为 MP3 格式")
+ converted_filepath = temp_mp3_filepath
+
+ # 使用转换后的文件重试转录
+ with open(converted_filepath, 'rb') as audio_file:
+ transcription_client = OpenAI(
+ api_key=transcription_api_key,
+ base_url=transcription_base_url,
+ http_client=http_client_no_proxy or llm_http_client
+ )
+
+ transcription_params = {
+ "model": whisper_model,
+ "file": audio_file
+ }
+
+ if transcription_language:
+ transcription_params["language"] = transcription_language
+
+ transcript = transcription_client.audio.transcriptions.create(**transcription_params)
+ return transcript.text
+
+ finally:
+ # 清理临时转换文件
+ if converted_filepath and os.path.exists(converted_filepath):
+ try:
+ os.unlink(converted_filepath)
+ _logger.info(f"已清理临时转换文件:{converted_filepath}")
+ except Exception as cleanup_error:
+ _logger.warning(f"清理临时文件 {converted_filepath} 失败:{cleanup_error}")
+ else:
+ # 如果不是格式错误则重新抛出
+ raise
+
+
+def transcribe_with_chunking(app_context, recording_id, filepath, filename_for_asr,
+ db=None, Recording=None,
+ chunking_service=None,
+ transcription_api_key=None, transcription_base_url=None,
+ http_client_no_proxy=None, app_logger=None):
+ """使用分块方式转录大型音频文件"""
+ _logger = app_logger or logger
+ import tempfile
+
+ # 如果未提供,使用配置的默认值
+ if transcription_api_key is None:
+ transcription_api_key = TRANSCRIPTION_API_KEY
+ if transcription_base_url is None:
+ transcription_base_url = TRANSCRIPTION_BASE_URL
+
+ with app_context:
+ recording = db.session.get(Recording, recording_id)
+ if not recording:
+ raise ValueError(f"未找到录音 {recording_id}")
+
+ # 为分块创建临时目录
+ with tempfile.TemporaryDirectory() as temp_dir:
+ try:
+ # 创建分块
+ _logger.info(f"正在为大文件创建分块:{filepath}")
+ chunks = chunking_service.create_chunks(filepath, temp_dir)
+
+ if not chunks:
+ from src.services.audio_chunking import ChunkProcessingError
+ raise ChunkProcessingError("未从音频文件创建任何分块")
+
+ _logger.info(f"已创建 {len(chunks)} 个分块,正在使用 Whisper API 处理每个分块...")
+
+ # 使用适当的超时和重试处理处理每个分块
+ chunk_results = []
+
+ # 创建具有适当超时的 HTTP 客户端
+ timeout_config = httpx.Timeout(
+ connect=30.0,
+ read=300.0,
+ write=60.0,
+ pool=10.0
+ )
+
+ http_client_with_timeout = httpx.Client(
+ verify=True,
+ timeout=timeout_config,
+ limits=httpx.Limits(max_connections=5, max_keepalive_connections=2)
+ )
+
+ transcription_client = OpenAI(
+ api_key=transcription_api_key,
+ base_url=transcription_base_url,
+ http_client=http_client_with_timeout,
+ max_retries=3,
+ timeout=300.0
+ )
+ whisper_model = os.environ.get("WHISPER_MODEL", "Systran/faster-distil-whisper-large-v3")
+
+ # 获取用户语言偏好
+ user_transcription_language = None
+ with app_context:
+ recording = db.session.get(Recording, recording_id)
+ if recording and recording.owner:
+ user_transcription_language = recording.owner.transcription_language
+
+ for i, chunk in enumerate(chunks):
+ max_chunk_retries = 3
+ chunk_retry_count = 0
+ chunk_success = False
+
+ while chunk_retry_count < max_chunk_retries and not chunk_success:
+ try:
+ retry_suffix = f"(重试 {chunk_retry_count + 1}/{max_chunk_retries})" if chunk_retry_count > 0 else ""
+ _logger.info(f"正在处理分块 {i+1}/{len(chunks)}:{chunk['filename']}({chunk['size_mb']:.1f}MB){retry_suffix}")
+
+ # 记录每个步骤的详细耗时
+ step_start_time = time.time()
+
+ # 步骤 1:打开文件
+ file_open_start = time.time()
+ with open(chunk['path'], 'rb') as chunk_file:
+ file_open_time = time.time() - file_open_start
+ _logger.info(f"分块 {i+1}:文件打开耗时 {file_open_time:.2f}秒")
+
+ # 步骤 2:准备转录参数
+ param_start = time.time()
+ transcription_params = {
+ "model": whisper_model,
+ "file": chunk_file
+ }
+
+ if user_transcription_language:
+ transcription_params["language"] = user_transcription_language
+
+ param_time = time.time() - param_start
+ _logger.info(f"分块 {i+1}:参数准备耗时 {param_time:.2f}秒")
+
+ # 步骤 3:API 调用及详细计时
+ api_start = time.time()
+ _logger.info(f"分块 {i+1}:开始向 {transcription_base_url} 发起 API 调用")
+
+ # 记录连接详情
+ _logger.info(f"分块 {i+1}:超时配置 - 连接:30秒,读取:300秒,写入:60秒")
+ _logger.info(f"分块 {i+1}:最大重试:2次,API 超时:300秒")
+
+ try:
+ transcript = transcription_client.audio.transcriptions.create(**transcription_params)
+ except Exception as chunk_error:
+ # 检查是否为格式错误
+ error_msg = str(chunk_error)
+ if "Invalid file format" in error_msg or "Supported formats" in error_msg:
+ _logger.warning(f"分块 {i+1} 格式问题,尝试转换...")
+ with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as temp_mp3:
+ temp_mp3_path = temp_mp3.name
+ try:
+ subprocess.run(
+ ['ffmpeg', '-i', chunk['path'], '-y', '-acodec', 'libmp3lame', '-b:a', '128k', '-ar', '44100', temp_mp3_path],
+ check=True,
+ capture_output=True
+ )
+ with open(temp_mp3_path, 'rb') as converted_chunk:
+ transcription_params['file'] = converted_chunk
+ transcript = transcription_client.audio.transcriptions.create(**transcription_params)
+ finally:
+ if os.path.exists(temp_mp3_path):
+ os.unlink(temp_mp3_path)
+ else:
+ raise
+
+ api_time = time.time() - api_start
+ _logger.info(f"分块 {i+1}:API 调用完成,耗时 {api_time:.2f}秒")
+
+ # 步骤 4:处理响应
+ response_start = time.time()
+ chunk_result = {
+ 'index': chunk['index'],
+ 'start_time': chunk['start_time'],
+ 'end_time': chunk['end_time'],
+ 'duration': chunk['duration'],
+ 'size_mb': chunk['size_mb'],
+ 'transcription': transcript.text,
+ 'filename': chunk['filename'],
+ 'processing_time': api_time
+ }
+ chunk_results.append(chunk_result)
+ response_time = time.time() - response_start
+
+ total_time = time.time() - step_start_time
+ _logger.info(f"分块 {i+1}:响应处理耗时 {response_time:.2f}秒")
+ _logger.info(f"分块 {i+1}:总处理耗时 {total_time:.2f}秒")
+ _logger.info(f"分块 {i+1} 转录成功:{len(transcript.text)} 字符")
+ chunk_success = True
+
+ except Exception as chunk_error:
+ chunk_retry_count += 1
+ error_msg = str(chunk_error)
+
+ if chunk_retry_count < max_chunk_retries:
+ # 根据错误类型确定等待时间
+ if "timeout" in error_msg.lower() or "timed out" in error_msg.lower():
+ wait_time = 30
+ elif "rate limit" in error_msg.lower():
+ wait_time = 60
+ else:
+ wait_time = 15
+
+ _logger.warning(f"分块 {i+1} 失败(第 {chunk_retry_count}/{max_chunk_retries} 次尝试):{chunk_error}。{wait_time}秒后重试...")
+ time.sleep(wait_time)
+ else:
+ _logger.error(f"分块 {i+1} 在 {max_chunk_retries} 次尝试后仍然失败:{chunk_error}")
+ # 将失败的分块添加到结果中
+ chunk_result = {
+ 'index': chunk['index'],
+ 'start_time': chunk['start_time'],
+ 'end_time': chunk['end_time'],
+ 'transcription': f"[分块 {i+1} 转录在 {max_chunk_retries} 次尝试后失败:{str(chunk_error)}]",
+ 'filename': chunk['filename']
+ }
+ chunk_results.append(chunk_result)
+
+ # 分块之间添加短暂延迟,避免对 API 造成过大压力
+ if i < len(chunks) - 1:
+ time.sleep(2)
+
+ # 合并转录结果
+ _logger.info(f"正在合并 {len(chunk_results)} 个分块的转录结果...")
+ merged_transcription = chunking_service.merge_transcriptions(chunk_results)
+
+ if not merged_transcription.strip():
+ from src.services.audio_chunking import ChunkProcessingError
+ raise ChunkProcessingError("合并后的转录结果为空")
+
+ # 记录详细的性能统计和分析
+ chunking_service.log_processing_statistics(chunk_results)
+
+ # 获取性能建议
+ recommendations = chunking_service.get_performance_recommendations(chunk_results)
+ if recommendations:
+ _logger.info("=== 性能建议 ===")
+ for i, rec in enumerate(recommendations, 1):
+ _logger.info(f"{i}. {rec}")
+ _logger.info("=== 建议结束 ===")
+
+ _logger.info(f"分块转录完成。最终长度:{len(merged_transcription)} 字符")
+ return merged_transcription
+
+ except Exception as e:
+ _logger.error(f"分块转录失败,文件 {filepath}:{e}")
+ # 清理分块(如果存在)
+ if 'chunks' in locals():
+ chunking_service.cleanup_chunks(chunks)
+ from src.services.audio_chunking import ChunkProcessingError
+ raise ChunkProcessingError(f"分块转录失败:{str(e)}")
+ finally:
+ # 清理工作由 tempfile.TemporaryDirectory 上下文管理器处理
+ pass
diff --git a/speakr/src/services/voiceprint_service.py b/speakr/src/services/voiceprint_service.py
new file mode 100644
index 0000000..e354d5e
--- /dev/null
+++ b/speakr/src/services/voiceprint_service.py
@@ -0,0 +1,36 @@
+"""
+声纹服务 - 处理声纹同步相关业务逻辑
+"""
+import logging
+from flask import current_app
+from src.clients.voiceprint_client import voiceprint_client
+
+logger = logging.getLogger(__name__)
+
+
+def sync_voiceprint_to_platform(voiceprint, operation):
+ """
+ 同步声纹数据到外部平台
+
+ Args:
+ voiceprint: 声纹对象,包含 name 和 audio_path 属性
+ operation: 操作类型 ('add' 或 'delete')
+
+ Raises:
+ Exception: 同步失败时抛出异常
+ """
+ try:
+ client = voiceprint_client()
+ if operation == 'add':
+ # 上传音频文件到声纹平台
+ with open(voiceprint.audio_path, 'rb') as audio_file:
+ client.add_voiceprint(voiceprint.name, audio_file)
+ logger.info(f"成功同步添加声纹人到平台:{voiceprint.name}")
+
+ elif operation == 'delete':
+ client.delete_voiceprint(voiceprint.name)
+ logger.info(f"成功同步删除声纹人从平台:{voiceprint.name}")
+
+ except Exception as e:
+ logger.error(f"同步声纹数据到平台失败:{str(e)}")
+ raise
diff --git a/speakr/src/utils/__init__.py b/speakr/src/utils/__init__.py
new file mode 100644
index 0000000..053289c
--- /dev/null
+++ b/speakr/src/utils/__init__.py
@@ -0,0 +1,2 @@
+# Utils package
+# This module exports utility functions for easy importing
diff --git a/speakr/src/utils/datetime.py b/speakr/src/utils/datetime.py
new file mode 100644
index 0000000..edeaeed
--- /dev/null
+++ b/speakr/src/utils/datetime.py
@@ -0,0 +1,28 @@
+# Datetime utility functions
+from datetime import datetime
+from flask import current_app
+from babel.dates import format_datetime
+import pytz
+
+
+def local_datetime_filter(dt):
+ """将 UTC 时间转换为用户本地时区时间"""
+ if dt is None:
+ return ""
+
+ # 获取用户时区配置
+ user_tz_name = current_app.config.get('USER_TIMEZONE', 'UTC')
+ try:
+ user_tz = pytz.timezone(user_tz_name)
+ except pytz.UnknownTimeZoneError:
+ user_tz = pytz.utc
+
+ # 如果时间没有时区信息,假设是 UTC
+ if dt.tzinfo is None:
+ dt = pytz.utc.localize(dt)
+
+ # 转换到用户时区
+ local_dt = dt.astimezone(user_tz)
+
+ # 格式化输出
+ return format_datetime(local_dt, format='medium', locale='en_US')
diff --git a/speakr/src/utils/json_utils.py b/speakr/src/utils/json_utils.py
new file mode 100644
index 0000000..2faea4c
--- /dev/null
+++ b/speakr/src/utils/json_utils.py
@@ -0,0 +1,206 @@
+"""
+JSON utility functions for safe parsing and preprocessing.
+"""
+import json
+import re
+import ast
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def auto_close_json(json_string):
+ """
+ Attempts to close an incomplete JSON string by appending necessary brackets and braces.
+ This is a simplified parser and may not handle all edge cases, but is
+ designed to fix unterminated strings from API responses.
+ """
+ if not isinstance(json_string, str):
+ return json_string
+
+ stack = []
+ in_string = False
+ escape_next = False
+
+ for char in json_string:
+ if escape_next:
+ escape_next = False
+ continue
+
+ if char == '\\':
+ escape_next = True
+ continue
+
+ if char == '"':
+ # We don't handle escaped quotes inside strings perfectly,
+ # but this is a simple heuristic.
+ if not escape_next:
+ in_string = not in_string
+
+ if not in_string:
+ if char == '{':
+ stack.append('}')
+ elif char == '[':
+ stack.append(']')
+ elif char == '}':
+ if stack and stack[-1] == '}':
+ stack.pop()
+ elif char == ']':
+ if stack and stack[-1] == ']':
+ stack.pop()
+
+ # If we are inside a string at the end, close it.
+ if in_string:
+ json_string += '"'
+
+ # Close any remaining open structures
+ while stack:
+ json_string += stack.pop()
+
+ return json_string
+
+
+def safe_json_loads(json_string, fallback_value=None):
+ """
+ Safely parse JSON with preprocessing to handle common LLM JSON formatting issues.
+
+ Args:
+ json_string (str): The JSON string to parse
+ fallback_value: Value to return if parsing fails (default: None)
+
+ Returns:
+ Parsed JSON object or fallback_value if parsing fails
+ """
+ if not json_string or not isinstance(json_string, str):
+ logger.warning(f"JSON 输入无效:类型 {type(json_string)},内容:{json_string}")
+ return fallback_value
+
+ # Step 1: Clean the input string
+ cleaned_json = json_string.strip()
+
+ # Step 2: Extract JSON from markdown code blocks if present
+ json_match = re.search(r'```(?:json)?\s*(.*?)\s*```', cleaned_json, re.DOTALL)
+ if json_match:
+ cleaned_json = json_match.group(1).strip()
+
+ # Step 3: Try multiple parsing strategies
+ parsing_strategies = [
+ # Strategy 1: Direct parsing (for well-formed JSON)
+ lambda x: json.loads(x),
+
+ # Strategy 2: Fix common escape issues
+ lambda x: json.loads(preprocess_json_escapes(x)),
+
+ # Strategy 3: Use ast.literal_eval as fallback for simple cases
+ lambda x: ast.literal_eval(x) if x.startswith(('{', '[')) else None,
+
+ # Strategy 4: Extract JSON object/array using regex
+ lambda x: json.loads(extract_json_object(x)),
+
+ # Strategy 5: Auto-close incomplete JSON and parse
+ lambda x: json.loads(auto_close_json(x)),
+ ]
+
+ for i, strategy in enumerate(parsing_strategies):
+ try:
+ result = strategy(cleaned_json)
+ if result is not None:
+ if i > 0: # Log if we had to use a fallback strategy
+ logger.info(f"JSON 解析成功(使用策略 {i+1} 回退)")
+ return result
+ except (json.JSONDecodeError, ValueError, SyntaxError) as e:
+ if i == 0: # Only log the first failure to avoid spam
+ logger.debug(f"JSON 解析策略 {i+1} 失败:{e}")
+ continue
+
+ # All strategies failed
+ logger.error(f"所有 JSON 解析策略均失败,输入内容:{cleaned_json[:200]}...")
+ return fallback_value
+
+
+def preprocess_json_escapes(json_string):
+ """
+ Preprocess JSON string to fix common escape issues from LLM responses.
+ Uses a more sophisticated approach to handle nested quotes properly.
+ """
+ if not json_string:
+ return json_string
+
+ result = []
+ i = 0
+ in_string = False
+ escape_next = False
+ expecting_value = False # Track if we're expecting a value (after :)
+
+ while i < len(json_string):
+ char = json_string[i]
+
+ if escape_next:
+ # This character is escaped, add it as-is
+ result.append(char)
+ escape_next = False
+ elif char == '\\':
+ # This is an escape character
+ result.append(char)
+ escape_next = True
+ elif char == ':' and not in_string:
+ # We found a colon, next string will be a value
+ result.append(char)
+ expecting_value = True
+ elif char == ',' and not in_string:
+ # We found a comma, reset expecting_value
+ result.append(char)
+ expecting_value = False
+ elif char == '"':
+ if not in_string:
+ # Starting a string
+ in_string = True
+ result.append(char)
+ else:
+ # We're in a string, check if this quote should be escaped
+ # Look ahead to see if this is the end of the string value
+ j = i + 1
+ while j < len(json_string) and json_string[j].isspace():
+ j += 1
+
+ # For keys (not expecting_value), only end on colon
+ # For values (expecting_value), end on comma, closing brace, or closing bracket
+ if expecting_value:
+ end_chars = ',}]'
+ else:
+ end_chars = ':'
+
+ if j < len(json_string) and json_string[j] in end_chars:
+ # This is the end of the string
+ in_string = False
+ result.append(char)
+ if not expecting_value:
+ # We just finished a key, next will be expecting value
+ expecting_value = True
+ else:
+ # This is an inner quote that should be escaped
+ result.append('\\"')
+ else:
+ result.append(char)
+
+ i += 1
+
+ return ''.join(result)
+
+
+def extract_json_object(text):
+ """
+ Extract the first complete JSON object or array from text using regex.
+ """
+ # Look for JSON object
+ obj_match = re.search(r'\{.*\}', text, re.DOTALL)
+ if obj_match:
+ return obj_match.group(0)
+
+ # Look for JSON array
+ arr_match = re.search(r'\[.*\]', text, re.DOTALL)
+ if arr_match:
+ return arr_match.group(0)
+
+ # Return original if no JSON structure found
+ return text
diff --git a/speakr/src/utils/markdown.py b/speakr/src/utils/markdown.py
new file mode 100644
index 0000000..36983f7
--- /dev/null
+++ b/speakr/src/utils/markdown.py
@@ -0,0 +1,28 @@
+# Markdown utility functions
+import markdown
+import bleach
+
+
+def md_to_html(text):
+ """Convert markdown text到 HTML,并进行安全过滤"""
+ if not text:
+ return ""
+
+ # 使用 markdown 库转换
+ html = markdown.markdown(text, extensions=['fenced_code', 'tables'])
+
+ # 使用 bleach 进行安全过滤
+ allowed_tags = [
+ 'p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'hr', 'table',
+ 'thead', 'tbody', 'tr', 'th', 'td'
+ ]
+ allowed_attrs = {
+ 'a': ['href', 'title', 'target'],
+ 'img': ['src', 'alt'],
+ 'th': ['align'],
+ 'td': ['align'],
+ }
+
+ clean_html = bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs)
+ return clean_html
diff --git a/speakr/src/utils/standard_word_mappings.py b/speakr/src/utils/standard_word_mappings.py
new file mode 100644
index 0000000..1b8d0b5
--- /dev/null
+++ b/speakr/src/utils/standard_word_mappings.py
@@ -0,0 +1,162 @@
+"""解析和应用标准词映射的辅助工具。"""
+
+import json
+import re
+
+
+def _split_standard_word_variants(variants_value):
+ """使用支持的分隔符拆分变体词。"""
+ if isinstance(variants_value, list):
+ raw_variants = variants_value
+ else:
+ raw_variants = re.split(r"[、,,\s]+", str(variants_value or ""))
+
+ return [
+ variant.strip()
+ for variant in raw_variants
+ if isinstance(variant, str) and variant.strip()
+ ]
+
+
+def parse_standard_word_mappings(config_value):
+ """
+ 从 JSON 或旧版文本行解析标准词映射配置。
+
+ 支持的格式:
+ 1. JSON 字符串 / Python 列表:
+ [{"standard": "标准词", "variants": ["常用词1", "常用词2"]}]
+ 2. 旧版文本行:
+ 标准词:常用词1、常用词2
+ """
+ if not config_value:
+ return []
+
+ raw_entries = []
+
+ if isinstance(config_value, list):
+ raw_entries = config_value
+ elif isinstance(config_value, dict):
+ raw_entries = [config_value]
+ elif isinstance(config_value, str):
+ normalized_value = config_value.strip()
+ if not normalized_value:
+ return []
+
+ if normalized_value[0] in ["[", "{"]:
+ try:
+ parsed_value = json.loads(normalized_value)
+ except (TypeError, ValueError, json.JSONDecodeError):
+ parsed_value = None
+
+ if isinstance(parsed_value, list):
+ raw_entries = parsed_value
+ elif isinstance(parsed_value, dict):
+ raw_entries = [parsed_value]
+
+ if not raw_entries:
+ for raw_line in normalized_value.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+
+ if ":" in line:
+ standard_word, variants_value = line.split(":", 1)
+ elif ":" in line:
+ standard_word, variants_value = line.split(":", 1)
+ else:
+ continue
+
+ raw_entries.append(
+ {
+ "standard": standard_word.strip(),
+ "variants": _split_standard_word_variants(variants_value),
+ }
+ )
+ else:
+ return []
+
+ normalized_entries = []
+ for entry in raw_entries:
+ if not isinstance(entry, dict):
+ continue
+
+ standard_word = (
+ entry.get("standard")
+ or entry.get("standard_word")
+ or entry.get("target")
+ or ""
+ )
+ standard_word = str(standard_word).strip()
+ if not standard_word:
+ continue
+
+ variants = _split_standard_word_variants(
+ entry.get("variants")
+ or entry.get("common_words")
+ or entry.get("sources")
+ or ""
+ )
+
+ deduplicated_variants = []
+ seen_variants = set()
+ for variant in variants:
+ if variant == standard_word or variant in seen_variants:
+ continue
+ deduplicated_variants.append(variant)
+ seen_variants.add(variant)
+
+ if deduplicated_variants:
+ normalized_entries.append(
+ {"standard": standard_word, "variants": deduplicated_variants}
+ )
+
+ return normalized_entries
+
+
+def apply_standard_word_mappings_with_details(text, config_value):
+ """应用映射并返回映射后的文本以及已应用的映射详情。"""
+ if not text or not isinstance(text, str):
+ return text, []
+
+ mapping_entries = parse_standard_word_mappings(config_value)
+ if not mapping_entries:
+ return text, []
+
+ variant_to_standard = {}
+ ordered_variants = []
+
+ for mapping_entry in mapping_entries:
+ standard_word = mapping_entry["standard"]
+ for variant in mapping_entry["variants"]:
+ if variant in variant_to_standard:
+ continue
+ variant_to_standard[variant] = standard_word
+ ordered_variants.append(variant)
+
+ if not ordered_variants:
+ return text, []
+
+ replacement_pattern = re.compile(
+ "|".join(
+ re.escape(variant)
+ for variant in sorted(ordered_variants, key=len, reverse=True)
+ )
+ )
+
+ applied_mappings = []
+
+ def replace_match(match):
+ variant = match.group(0)
+ standard_word = variant_to_standard.get(variant, variant)
+ if standard_word != variant:
+ applied_mappings.append({"from": variant, "to": standard_word})
+ return standard_word
+
+ mapped_text = replacement_pattern.sub(replace_match, text)
+ return mapped_text, applied_mappings
+
+
+def apply_standard_word_mappings(text, config_value):
+ """将配置的标准词映射应用到校正后的 ASR 文本。"""
+ mapped_text, _ = apply_standard_word_mappings_with_details(text, config_value)
+ return mapped_text
diff --git a/speakr/src/utils/text_utils.py b/speakr/src/utils/text_utils.py
new file mode 100644
index 0000000..814dd3d
--- /dev/null
+++ b/speakr/src/utils/text_utils.py
@@ -0,0 +1,83 @@
+"""
+Text utility functions for HTML sanitization and text processing.
+"""
+import re
+import bleach
+import subprocess
+
+
+def chinese_to_pinyin(chinese_text):
+ """将汉字转换为拼音"""
+ if not chinese_text:
+ return ""
+ import pypinyin
+ pinyin_list = pypinyin.lazy_pinyin(chinese_text)
+ return "".join(pinyin_list)
+
+
+def sanitize_html(text):
+ """
+ Sanitize HTML content to prevent XSS attacks and code execution.
+ This function removes dangerous content while preserving safe formatting.
+ """
+ if not text:
+ return ""
+
+ # First, remove any template syntax that could be dangerous
+ # Remove Jinja2/Flask template syntax
+ text = re.sub(r'\{\{.*?\}\}', '', text, flags=re.DOTALL)
+ text = re.sub(r'\{%.*?%\}', '', text, flags=re.DOTALL)
+
+ # Remove other template-like syntax
+ text = re.sub(r'<%.*?%>', '', text, flags=re.DOTALL)
+ text = re.sub(r'<\?.*?\?>', '', text, flags=re.DOTALL)
+
+ # Define allowed tags and attributes for safe HTML
+ allowed_tags = [
+ 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'a', 'img', 'table', 'thead',
+ 'tbody', 'tr', 'th', 'td', 'dl', 'dt', 'dd', 'div', 'span', 'hr', 'sup', 'sub'
+ ]
+
+ allowed_attributes = {
+ 'a': ['href', 'title'],
+ 'img': ['src', 'alt', 'title', 'width', 'height'],
+ 'code': ['class'], # For syntax highlighting
+ 'pre': ['class'], # For syntax highlighting
+ 'div': ['class'], # For code blocks
+ 'span': ['class'], # For syntax highlighting
+ 'th': ['align'],
+ 'td': ['align'],
+ 'table': ['class']
+ }
+
+ # Sanitize the HTML to remove dangerous content
+ sanitized_html = bleach.clean(
+ text,
+ tags=allowed_tags,
+ attributes=allowed_attributes,
+ protocols=['http', 'https', 'mailto'],
+ strip=True # Strip disallowed tags instead of escaping them
+ )
+
+ return sanitized_html
+
+
+def get_version():
+ """获取应用版本号"""
+ # Try reading VERSION file first (works in Docker)
+ try:
+ with open('VERSION', 'r') as f:
+ return f.read().strip()
+ except FileNotFoundError:
+ pass
+
+ # Fall back to git tags (works in development)
+ try:
+ return subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'],
+ stderr=subprocess.DEVNULL).decode().strip()
+ except:
+ pass
+
+ # Final fallback
+ return "unknown"
diff --git a/speakr/static/css/loading.css b/speakr/static/css/loading.css
new file mode 100644
index 0000000..ae5e2fa
--- /dev/null
+++ b/speakr/static/css/loading.css
@@ -0,0 +1,83 @@
+/* Critical loading styles - inline these in the HTML head for instant loading */
+.app-loading-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-primary, #1a1b26);
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: opacity 0.3s ease-out;
+}
+
+.app-loading-overlay.fade-out {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.app-loading-content {
+ text-align: center;
+}
+
+.app-loading-spinner {
+ width: 50px;
+ height: 50px;
+ margin: 0 auto 20px;
+ border: 3px solid rgba(255, 255, 255, 0.1);
+ border-top-color: var(--text-accent, #7aa2f7);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+.app-loading-text {
+ color: var(--text-muted, #a0a0b0);
+ font-size: 14px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ letter-spacing: 0.5px;
+}
+
+.app-loading-logo {
+ width: 60px;
+ height: 60px;
+ margin: 0 auto 20px;
+ opacity: 0.8;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Hide body content until ready */
+body.app-loading {
+ overflow: hidden;
+}
+
+body.app-loading > *:not(.app-loading-overlay) {
+ opacity: 0;
+}
+
+/* Dark mode default colors */
+@media (prefers-color-scheme: dark) {
+ .app-loading-overlay {
+ background: #1a1b26;
+ }
+}
+
+/* Light mode if explicitly set */
+body.light .app-loading-overlay {
+ background: #ffffff;
+}
+
+body.light .app-loading-spinner {
+ border-color: rgba(0, 0, 0, 0.1);
+ border-top-color: #3b82f6;
+}
+
+body.light .app-loading-text {
+ color: #6b7280;
+}
\ No newline at end of file
diff --git a/speakr/static/css/styles.css b/speakr/static/css/styles.css
new file mode 100644
index 0000000..ed1b628
--- /dev/null
+++ b/speakr/static/css/styles.css
@@ -0,0 +1,3471 @@
+body.unloaded {
+ opacity: 0;
+ transition: opacity 0.5s ease-in-out;
+}
+
+[v-cloak] {
+ display: none;
+}
+
+/* Color Scheme CSS Variables */
+:root {
+ /* Enable light color scheme for native controls */
+ color-scheme: light;
+
+ /* Default Light Theme (Blue) - More muted with better contrast */
+ --bg-primary: #e8eaed; /* muted gray */
+ --bg-secondary: #f8f9fa; /* off-white */
+ --bg-tertiary: #f0f2f4; /* light gray */
+ --bg-accent: #c3d9f7; /* muted blue */
+ --bg-accent-hover: #aec9f2; /* deeper muted blue */
+ --bg-button: #2563eb; /* blue-600 */
+ --bg-button-hover: #1d4ed8; /* blue-700 */
+ --bg-danger: #dc2626; /* red-600 */
+ --bg-danger-hover: #b91c1c; /* red-700 */
+ --bg-danger-light: #fce4e4; /* muted red */
+ --bg-info-light: #c3d9f7; /* muted blue */
+ --bg-warn-light: #d4e8f5; /* soft blue */
+ --bg-success-light: #c6f0dc; /* muted green */
+ --bg-pending-light: #eeeeec; /* stone */
+ --bg-input: #fafbfc; /* slightly gray white */
+ --bg-audio-player: var(--bg-tertiary);
+
+ --text-primary: #1f2937; /* gray-800 */
+ --text-secondary: #374151; /* gray-700 */
+ --text-muted: #6b7280; /* gray-500 */
+ --text-light: #9ca3af; /* gray-400 */
+ --text-accent: #1d4ed8; /* blue-700 */
+ --text-button: #ffffff; /* white */
+ --text-danger: #b91c1c; /* red-700 */
+ --text-danger-strong: #991b1b; /* red-800 */
+ --text-info-strong: #1e40af; /* blue-800 */
+ --text-warn-strong: #0369a1; /* sky-700 - friendly blue tone */
+ --text-success-strong: #065f46; /* green-800 */
+ --text-pending-strong: #44403c; /* stone-700 */
+
+ --border-primary: #e5e7eb; /* gray-200 */
+ --border-secondary: #d1d5db; /* gray-300 */
+ --border-accent: #93c5fd; /* blue-300 */
+ --border-danger: #f87171; /* red-400 */
+ --border-focus: #3b82f6; /* blue-500 */
+ --ring-focus: #bfdbfe; /* blue-200 */
+
+ --scrollbar-track: #f1f1f1;
+ --scrollbar-thumb: #c5c5c5;
+ --scrollbar-thumb-hover: #a8a8a8;
+
+ /* Toast notification colors */
+ --bg-success: #10b981;
+ --border-success: #059669;
+}
+
+/* Light Theme Variants */
+.theme-light-emerald {
+ --bg-primary: #e6f0e9; /* muted green-gray */
+ --bg-secondary: #f4f9f5; /* soft off-white green */
+ --bg-tertiary: #ebf4ed; /* light muted green */
+ --bg-accent: #d0e8d7; /* muted pastel green */
+ --bg-accent-hover: #c1e0ca; /* deeper muted green */
+ --bg-button: #059669; /* keep button visible */
+ --bg-button-hover: #047857; /* keep button visible */
+ --bg-input: #f9fcfa; /* slightly green white */
+ --text-accent: #047857; /* keep accent text visible */
+ --border-primary: #d4e5d8; /* muted green border */
+ --border-secondary: #c2dcc9; /* soft green border */
+ --border-accent: #a8d0b3; /* medium green border */
+ --border-focus: #10b981; /* keep focus visible */
+ --ring-focus: #d0e8d7; /* muted green ring */
+ --bg-audio-player: var(--bg-tertiary);
+}
+
+.theme-light-purple {
+ --bg-primary: #ebe9f0; /* muted purple-gray */
+ --bg-secondary: #f7f6f9; /* soft off-white purple */
+ --bg-tertiary: #f0eef4; /* light muted purple */
+ --bg-accent: #dcd7e8; /* muted pastel purple */
+ --bg-accent-hover: #d0c9df; /* deeper muted purple */
+ --bg-button: #7c3aed; /* keep button visible */
+ --bg-button-hover: #6d28d9; /* keep button visible */
+ --bg-input: #faf9fc; /* slightly purple white */
+ --text-accent: #6d28d9; /* keep accent text visible */
+ --border-primary: #dbd6e6; /* muted purple border */
+ --border-secondary: #cfc8dd; /* soft purple border */
+ --border-accent: #bab0d2; /* medium purple border */
+ --border-focus: #8b5cf6; /* keep focus visible */
+ --ring-focus: #dcd7e8; /* muted purple ring */
+ --bg-audio-player: var(--bg-tertiary);
+}
+
+.theme-light-rose {
+ --bg-primary: #f0e9ed; /* muted rose-gray */
+ --bg-secondary: #f9f6f7; /* soft off-white rose */
+ --bg-tertiary: #f4eef1; /* light muted rose */
+ --bg-accent: #e6d7df; /* muted pastel rose */
+ --bg-accent-hover: #dfc9d4; /* deeper muted rose */
+ --bg-button: #e11d48; /* keep button visible */
+ --bg-button-hover: #be185d; /* keep button visible */
+ --bg-input: #fcf9fa; /* slightly rose white */
+ --text-accent: #be185d; /* keep accent text visible */
+ --border-primary: #e5d6de; /* muted rose border */
+ --border-secondary: #dbc8d2; /* soft rose border */
+ --border-accent: #d0b0bf; /* medium rose border */
+ --border-focus: #ec4899; /* keep focus visible */
+ --ring-focus: #e6d7df; /* muted rose ring */
+ --bg-audio-player: var(--bg-tertiary);
+}
+
+.theme-light-amber {
+ --bg-primary: #efebe6; /* muted amber-gray */
+ --bg-secondary: #f9f7f4; /* soft off-white amber */
+ --bg-tertiary: #f3efe9; /* light muted amber */
+ --bg-accent: #e5ddd1; /* muted pastel amber */
+ --bg-accent-hover: #ddd2c2; /* deeper muted amber */
+ --bg-button: #d97706; /* keep button visible */
+ --bg-button-hover: #b45309; /* keep button visible */
+ --bg-input: #fbfaf8; /* slightly amber white */
+ --text-accent: #b45309; /* keep accent text visible */
+ --border-primary: #e2dace; /* muted amber border */
+ --border-secondary: #d9cdb9; /* soft amber border */
+ --border-accent: #cdbca1; /* medium amber border */
+ --border-focus: #f59e0b; /* keep focus visible */
+ --ring-focus: #e5ddd1; /* muted amber ring */
+ --bg-audio-player: var(--bg-tertiary);
+}
+
+.theme-light-teal {
+ --bg-primary: #e7f0ef; /* muted teal-gray */
+ --bg-secondary: #f5f9f8; /* soft off-white teal */
+ --bg-tertiary: #ecf4f3; /* light muted teal */
+ --bg-accent: #d2e5e2; /* muted pastel teal */
+ --bg-accent-hover: #c3ddd9; /* deeper muted teal */
+ --bg-button: #0d9488; /* keep button visible */
+ --bg-button-hover: #0f766e; /* keep button visible */
+ --bg-input: #f9fcfb; /* slightly teal white */
+ --text-accent: #0f766e; /* keep accent text visible */
+ --border-primary: #c8dfdc; /* muted teal border */
+ --border-secondary: #b5d3ce; /* soft teal border */
+ --border-accent: #9cc5be; /* medium teal border */
+ --border-focus: #14b8a6; /* keep focus visible */
+ --ring-focus: #d2e5e2; /* muted teal ring */
+ --bg-audio-player: var(--bg-tertiary);
+}
+
+/* Dark Theme Base */
+.dark {
+ /* Enable dark color scheme for native controls */
+ color-scheme: dark;
+
+ --bg-primary: #111827; /* gray-900 */
+ --bg-secondary: #1f2937; /* gray-800 */
+ --bg-tertiary: #374151; /* gray-700 */
+ --bg-accent: #1e3a8a; /* blue-900 */
+ --bg-accent-hover: #1e40af; /* blue-800 */
+ --bg-button: #2563eb; /* blue-600 */
+ --bg-button-hover: #3b82f6; /* blue-500 */
+ --bg-danger: #dc2626; /* red-600 */
+ --bg-danger-hover: #ef4444; /* red-500 */
+ --bg-danger-light: #7f1d1d; /* red-900 */
+ --bg-info-light: #1e3a8a; /* blue-900 */
+ --bg-warn-light: #164e63; /* cyan-800 - soft, friendly dark blue */
+ --bg-success-light: #064e3b; /* green-900 */
+ --bg-pending-light: #292524; /* stone-800 */
+ --bg-input: #374151; /* gray-700 */
+ --bg-audio-player: var(--bg-secondary);
+
+ --text-primary: #f3f4f6; /* gray-100 */
+ --text-secondary: #d1d5db; /* gray-300 */
+ --text-muted: #cbd5e1; /* slate-300 - improved contrast */
+ --text-light: #94a3b8; /* slate-400 - better visibility */
+ --text-accent: #60a5fa; /* blue-400 */
+ --text-button: #ffffff; /* white */
+ --text-danger: #f87171; /* red-400 */
+ --text-danger-strong: #fca5a5; /* red-300 */
+ --text-info-strong: #93c5fd; /* blue-300 */
+ --text-warn-strong: #67e8f9; /* cyan-300 - soft, friendly cyan text */
+ --text-success-strong: #6ee7b7; /* green-300 */
+ --text-pending-strong: #d6d3d1; /* stone-300 */
+
+ --border-primary: #475569; /* slate-600 - more visible */
+ --border-secondary: #64748b; /* slate-500 - better contrast */
+ --border-accent: #1d4ed8; /* blue-700 */
+ --border-danger: #ef4444; /* red-500 */
+ --border-focus: #3b82f6; /* blue-500 */
+ --ring-focus: #1e40af; /* blue-800 */
+
+ --scrollbar-track: #2d3748; /* gray-800 */
+ --scrollbar-thumb: #4a5568; /* gray-600 */
+ --scrollbar-thumb-hover: #718096; /* gray-500 */
+}
+
+/* Dark Theme Variants */
+.dark.theme-dark-emerald {
+ --bg-primary: #1a2420; /* lighter dark with emerald tint */
+ --bg-secondary: #243028; /* medium dark with emerald hint */
+ --bg-tertiary: #2e3c30; /* lighter dark with emerald hint */
+ --bg-accent: #384838; /* visible emerald accent */
+ --bg-accent-hover: #425440; /* lighter emerald accent */
+ --bg-button: #059669; /* keep button visible */
+ --bg-button-hover: #10b981; /* keep button visible */
+ --bg-input: #2e3c30; /* darker emerald input background */
+ --text-accent: #7dd3ae; /* muted pastel emerald text */
+ --border-primary: #3a4540; /* visible emerald border */
+ --border-secondary: #485548; /* lighter emerald border */
+ --border-accent: #556550; /* medium emerald border */
+ --border-focus: #10b981; /* keep focus visible */
+ --ring-focus: #384838; /* visible ring */
+ --bg-audio-player: var(--bg-tertiary);
+ --scrollbar-thumb: #485548; /* emerald tinted scrollbar */
+ --scrollbar-thumb-hover: #556655; /* brighter emerald on hover */
+}
+
+.dark.theme-dark-purple {
+ --bg-primary: #1e1a24; /* lighter dark with purple tint */
+ --bg-secondary: #2a2430; /* medium dark with purple hint */
+ --bg-tertiary: #36303c; /* lighter dark with purple hint */
+ --bg-accent: #423c48; /* visible purple accent */
+ --bg-accent-hover: #4e4854; /* lighter purple accent */
+ --bg-button: #7c3aed; /* keep button visible */
+ --bg-button-hover: #8b5cf6; /* keep button visible */
+ --bg-input: #36303c; /* darker purple input background */
+ --text-accent: #b8a5d4; /* muted pastel purple text */
+ --border-primary: #484050; /* visible purple border */
+ --border-secondary: #555058; /* lighter purple border */
+ --border-accent: #626060; /* medium purple border */
+ --border-focus: #8b5cf6; /* keep focus visible */
+ --ring-focus: #423c48; /* visible ring */
+ --bg-audio-player: var(--bg-tertiary);
+ --scrollbar-thumb: #555058; /* purple tinted scrollbar */
+ --scrollbar-thumb-hover: #666068; /* brighter purple on hover */
+}
+
+.dark.theme-dark-rose {
+ --bg-primary: #241a20; /* lighter dark with rose tint */
+ --bg-secondary: #302428; /* medium dark with rose hint */
+ --bg-tertiary: #3c3030; /* lighter dark with rose hint */
+ --bg-accent: #483c40; /* visible rose accent */
+ --bg-accent-hover: #54484c; /* lighter rose accent */
+ --bg-button: #e11d48; /* keep button visible */
+ --bg-button-hover: #f43f5e; /* keep button visible */
+ --bg-input: #3c3030; /* darker rose input background */
+ --text-accent: #d4a5b4; /* muted pastel rose text */
+ --border-primary: #504048; /* visible rose border */
+ --border-secondary: #585050; /* lighter rose border */
+ --border-accent: #606058; /* medium rose border */
+ --border-focus: #f43f5e; /* keep focus visible */
+ --ring-focus: #483c40; /* visible ring */
+ --bg-audio-player: var(--bg-tertiary);
+ --scrollbar-thumb: #554850; /* rose tinted scrollbar */
+ --scrollbar-thumb-hover: #665860; /* brighter rose on hover */
+}
+
+.dark.theme-dark-amber {
+ --bg-primary: #24201a; /* lighter dark with amber tint */
+ --bg-secondary: #302824; /* medium dark with amber hint */
+ --bg-tertiary: #3c342e; /* lighter dark with amber hint */
+ --bg-accent: #484038; /* visible amber accent */
+ --bg-accent-hover: #544c42; /* lighter amber accent */
+ --bg-button: #d97706; /* keep button visible */
+ --bg-button-hover: #f59e0b; /* keep button visible */
+ --bg-input: #3c342e; /* darker amber input background */
+ --text-accent: #d4c5a5; /* muted pastel amber text */
+ --border-primary: #504840; /* visible amber border */
+ --border-secondary: #585548; /* lighter amber border */
+ --border-accent: #606250; /* medium amber border */
+ --border-focus: #f59e0b; /* keep focus visible */
+ --ring-focus: #484038; /* visible ring */
+ --bg-audio-player: var(--bg-tertiary);
+ --scrollbar-thumb: #585548; /* amber tinted scrollbar */
+ --scrollbar-thumb-hover: #686658; /* brighter amber on hover */
+}
+
+.dark.theme-dark-teal {
+ --bg-primary: #1a2424; /* lighter dark with teal tint */
+ --bg-secondary: #243030; /* medium dark with teal hint */
+ --bg-tertiary: #2e3c3c; /* lighter dark with teal hint */
+ --bg-accent: #384848; /* visible teal accent */
+ --bg-accent-hover: #425454; /* lighter teal accent */
+ --bg-button: #0d9488; /* keep button visible */
+ --bg-button-hover: #14b8a6; /* keep button visible */
+ --bg-input: #2e3c3c; /* darker teal input background */
+ --text-accent: #a5d4d0; /* muted pastel teal text */
+ --border-primary: #404848; /* visible teal border */
+ --border-secondary: #485555; /* lighter teal border */
+ --border-accent: #506262; /* medium teal border */
+ --border-focus: #14b8a6; /* keep focus visible */
+ --ring-focus: #384848; /* visible ring */
+ --bg-audio-player: var(--bg-tertiary);
+ --scrollbar-thumb: #485555; /* teal tinted scrollbar */
+ --scrollbar-thumb-hover: #586666; /* brighter teal on hover */
+}
+
+/* Modern UI styles */
+.height-100 { height: 100%; }
+.drag-area { transition: background-color 0.3s ease, border-color 0.3s ease; }
+/* Global Scrollbar Styles */
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+ border-radius: 10px;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 10px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+/* Fix scrollbar corner for rounded containers */
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
+html { /* Apply base colors to html for smoother transitions */
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ transition: background-color 0.3s, color 0.3s;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+}
+html {
+ height: 100%;
+ margin: 0;
+}
+body {
+ height: 100%;
+ margin: 0;
+ overflow-y: auto; /* Allow scrolling on the body */
+}
+
+/* Mobile fly-in menu specific styles */
+@media (max-width: 1023px) { /* Corresponds to Tailwind's lg breakpoint */
+ .sidebar-container.fixed {
+ height: 100vh; /* Full viewport height */
+ overflow-y: auto; /* Allow scrolling within the fly-in menu */
+ z-index: 9999 !important; /* Ensure it's above everything else */
+ }
+ /* .main-content-area styling for mobile is handled by v-if in html now */
+ /*
+ .main-content-area {
+ transition: filter 0.3s ease-in-out;
+ }
+ body.mobile-menu-open .main-content-area {
+ filter: blur(4px);
+ }
+ */
+ body.mobile-menu-open {
+ overflow: hidden; /* Prevent body scroll when mobile menu is open */
+ }
+
+ /* Fix for mobile sidebar visibility */
+ .sidebar-container {
+ z-index: 50 !important; /* Ensure proper z-index */
+ }
+
+ /* Ensure the mobile sidebar shows above the overlay */
+ .fixed.inset-y-0.left-0.z-40 { /* This targets the overlay, not the sidebar */
+ z-index: 50 !important;
+ }
+
+ /* Custom easing for mobile sidebar slide animation */
+ /* This targets the main sidebar div when it's in mobile fly-in mode */
+ /* and has the transition-transform class applied by Vue/Tailwind. */
+ div.fixed.z-50.transition-transform {
+ transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);
+ }
+}
+
+/* Mobile viewport height fix */
+@media (max-width: 767px) { /* Target mobile devices specifically */
+ .main {
+ min-height: 4000px !important; /* Fixed viewport height for mobile */
+ }
+}
+#app {
+ min-height: 100%; /* Full viewport height */
+ display: flex;
+ flex-direction: column;
+}
+main {
+ flex: 1;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto; /* Allow scrolling on main */
+}
+
+/* Sidebar styles with flexible height */
+.sidebar-container {
+ height: 100%; /* Use relative height */
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto; /* Allow sidebar to scroll */
+}
+
+/* Grid container with flex layout and flexible height */
+.grid-container {
+ height: 100%; /* Use relative height */
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto; /* Allow scrolling */
+}
+
+.sidebar-header {
+ flex-shrink: 0; /* Prevent header from shrinking */
+}
+
+.sidebar-content {
+ flex-grow: 1;
+ overflow-y: auto; /* Enable scrolling for content */
+ padding-right: 6px; /* Space for scrollbar */
+ min-height: 0; /* Added for robust flex scrolling */
+}
+.progress-popup { position: fixed; bottom: 1rem; left: 1rem; z-index: 100; transition: all 0.3s ease-in-out; min-width: 300px; max-width: 400px; border-radius: 12px; overflow: hidden; }
+.progress-popup.minimized { transform: translateY(calc(100% - 45px)); }
+.progress-list-item { display: grid; grid-template-columns: auto 1fr auto; gap: 0.5rem; align-items: center; }
+.progress-list-item .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+.summary-box,
+.notes-box {
+ flex: 1; /* Take up available space in content-box */
+ min-height: 0; /* Critical for flex shrinking */
+ max-height: 100%; /* Prevent expansion beyond parent */
+ overflow-y: auto; /* Enable scrolling */
+ overflow-x: hidden; /* Prevent horizontal overflow */
+ /* Keep existing styling */
+ background-color: var(--bg-tertiary);
+ padding: 1rem;
+ border-radius: 0.75rem;
+ border: 1px solid var(--border-primary);
+ /* Fix scrollbar clipping rounded corners */
+ scrollbar-gutter: stable;
+ color: var(--text-secondary);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.03);
+}
+
+.transcription-box {
+ background-color: var(--bg-tertiary);
+ padding: 1rem; /* p-4 */
+ border-radius: 0.75rem; /* rounded-xl */
+ border: 1px solid var(--border-primary);
+ min-height: 0; /* Allow proper flex shrinking for scrolling */
+ overflow-y: auto; /* Enable vertical scrolling */
+ white-space: normal; /* Allow markdown HTML to control spacing */
+ font-family: inherit; /* Use body font */
+ font-size: 0.875rem; /* text-sm */
+ line-height: 1.5; /* Consistent line height */
+ box-shadow: 0 1px 2px rgba(0,0,0,0.03); /* Subtle shadow */
+ flex: 1; /* Take up available space */
+ color: var(--text-secondary);
+}
+
+/* Standardize border radius for all content boxes */
+.transcription-box,
+.summary-box,
+.chat-container,
+textarea,
+div[v-if="!editingParticipants"],
+div[v-if="!editingNotes"] {
+ border-radius: 0.75rem !important; /* rounded-xl */
+}
+
+/* Content boxes with flex layout */
+.content-column {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0; /* Allow content to size based on parent container */
+ flex: 1 1 auto; /* Grow and shrink as needed */
+ overflow: hidden;
+}
+
+/* Left column content (participants, transcription, tabs) */
+.left-content {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0; /* Allow flex to work properly */
+ overflow: hidden; /* Prevent this container from scrolling */
+}
+
+/* Right column content (audio player, chat) */
+.right-content {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ gap: 1rem;
+ overflow-y: auto; /* Enable scrolling for content that exceeds container */
+ min-height: 0; /* Allows flex to work properly */
+}
+
+/* Participants section - small fixed height */
+.participants-section {
+ flex-shrink: 0;
+ margin-bottom: 1rem; /* Add space below participants box */
+}
+
+/* Transcription section - takes up significant space */
+.transcription-section {
+ /* flex: 2; */ /* Removed: Let JavaScript control flex values */
+ min-height: 0; /* Critical for flex shrinking */
+ overflow: hidden; /* Contain overflow to enable proper flex behavior */
+ display: flex;
+ flex-direction: column;
+}
+
+/* Tab content section - takes up remaining space and aligns with chat box */
+.tab-section {
+ min-height: 0; /* Critical for flex items */
+ overflow: hidden; /* Prevent internal overflow from affecting layout */
+ display: flex;
+ flex-direction: column;
+}
+
+/* Audio player section - small fixed height */
+.audio-section {
+ flex-shrink: 0;
+}
+
+/* Chat section - takes up remaining space */
+.chat-section {
+ flex: 1;
+ min-height: 0; /* Allows flex to work properly */
+ overflow-y: auto; /* Changed from hidden to auto to enable scrolling */
+ display: flex;
+ flex-direction: column;
+}
+
+.tab-content-box {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0; /* Allows flex to work properly */
+ height: 100%; /* Fill available height */
+ display: flex;
+ flex-direction: column;
+}
+
+.chat-content-box {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0; /* Allows flex to work properly */
+}
+.metadata-panel {
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.75rem; /* rounded-xl to match others */
+ padding: 1rem; /* p-4 to be consistent */
+ /* margin-top removed to align with other boxes */
+ font-size: 0.875rem; /* text-sm */
+ color: var(--text-secondary);
+ flex: 1; /* Take up available space */
+ height: 100%; /* Fill the container height */
+ overflow-y: auto; /* Enable scrolling when content overflows */
+}
+.metadata-panel dt {
+ font-weight: 500;
+ color: var(--text-primary);
+ margin-bottom: 0.1rem;
+}
+.metadata-panel dd {
+ margin-left: 0;
+ margin-bottom: 0.5rem;
+ word-break: break-all; /* Wrap long filenames */
+}
+ .status-badge {
+ display: inline-block;
+ padding: 0.12rem 0.5rem; /* Even smaller padding */
+ font-size: 0.6rem; /* Smaller text */
+ font-weight: 500; /* font-medium */
+ border-radius: 9999px; /* rounded-full */
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+ letter-spacing: 0.025em;
+ vertical-align: middle; /* Align with text */
+ margin-left: 0.5rem; /* Less space */
+ opacity: 0.85; /* Slightly more subtle */
+ transition: opacity 0.2s ease;
+ }
+ .status-badge:hover {
+ opacity: 1;
+ }
+ .status-processing { color: #1d4ed8; background-color: #dbeafe; } /* text-blue-800 bg-blue-100 */
+ .status-summarizing { color: #92400e; background-color: #fef3c7; } /* text-amber-800 bg-amber-100 */
+ .status-completed { color: #065f46; background-color: #d1fae5; } /* text-green-800 bg-green-100 */
+ .status-failed { color: #991b1b; background-color: #fee2e2; } /* text-red-800 bg-red-100 */
+ .status-pending { color: #57534e; background-color: #f5f5f4; } /* text-stone-700 bg-stone-100 */
+ .status-highlighted { color: #d97706; background-color: #fef3c7; border: 1px solid #f59e0b; } /* text-amber-700 bg-amber-100 border-amber-500 */
+ .status-inbox { color: #1d4ed8; background-color: #dbeafe; border: 1px solid #3b82f6; } /* text-blue-700 bg-blue-100 border-blue-500 */
+
+ /* Clickable badge styles */
+ .clickable-badge {
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .clickable-badge:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.15);
+ opacity: 0.8;
+ }
+
+ /* Transcription box with flex layout */
+ .transcription-box {
+ flex: 1;
+ overflow-y: auto;
+ position: relative;
+ min-height: 0; /* Allows flex to work properly */
+ }
+
+ /* Modern copy button styles */
+ .copy-btn {
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ color: var(--text-secondary);
+ border-radius: 0.5rem;
+ padding: 0.35rem 0.75rem;
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .copy-btn:hover {
+ background-color: var(--bg-accent-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+
+ .dark .copy-btn {
+ background-color: var(--bg-secondary);
+ border-color: var(--border-secondary);
+ color: var(--text-primary);
+}
+
+.dark .copy-btn:hover {
+ background-color: var(--bg-tertiary);
+}
+
+ /* Hover edit button styles */
+.content-box {
+ position: relative;
+ height: 100%; /* Fill the available flex space */
+ min-height: 0; /* Allow shrinking */
+ display: flex;
+ flex-direction: column;
+ overflow: hidden; /* Prevent overflow from affecting parent layout */
+}
+
+ .hover-edit-btn {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background-color: rgba(255, 255, 255, 0.9);
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ padding: 0.35rem 0.75rem;
+ font-size: 0.75rem;
+ cursor: pointer;
+ z-index: 10;
+ transition: all 0.2s ease;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+ opacity: 0;
+ }
+
+ .content-box:hover .hover-edit-btn {
+ opacity: 1;
+ }
+
+ .hover-edit-btn:hover {
+ background-color: #f3f4f6;
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+
+ .dark .hover-edit-btn {
+ background-color: rgba(55, 65, 81, 0.9);
+ border-color: #4b5563;
+ }
+
+ .dark .hover-edit-btn:hover {
+ background-color: #4b5563;
+ }
+
+ /* Modern chat section styles */
+ .chat-container {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 300px; /* Minimum height for chat container */
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+ overflow: hidden;
+ }
+
+ .chat-messages {
+ flex-grow: 1;
+ overflow-y: auto;
+ padding: 1.25rem;
+ }
+
+ .chat-input-container {
+ border-top: 1px solid #e5e7eb;
+ padding: 0.75rem;
+ display: flex;
+ background-color: var(--bg-tertiary);
+ }
+
+ .message {
+ margin-bottom: 1.25rem;
+ max-width: 80%;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+ line-height: 1.5;
+ }
+
+ .user-message {
+ background-color: var(--accent-primary);
+ color: var(--text-accent-contrast);
+ border-radius: 1.25rem 1.25rem 0.25rem 1.25rem;
+ padding: 0.875rem 1rem;
+ margin-left: auto;
+ border: 1px solid var(--border-accent);
+ }
+
+ .ai-message {
+ background-color: var(--bg-tertiary);
+ color: var(--text-primary);
+ border-radius: 1.25rem 1.25rem 1.25rem 0.25rem;
+ padding: 0.875rem 1rem;
+ border: 1px solid var(--border-secondary);
+ }
+
+
+ .copyable {
+ position: relative;
+ }
+
+ /* Markdown styling */
+ .ai-message h1, .ai-message h2, .ai-message h3,
+ .summary-box h1, .summary-box h2, .summary-box h3,
+ .notes-box h1, .notes-box h2, .notes-box h3 {
+ font-weight: 600;
+ margin-top: 1rem;
+ margin-bottom: 0.5rem;
+ }
+
+ .ai-message h1, .summary-box h1, .notes-box h1 { font-size: 1.25rem; }
+ .ai-message h2, .summary-box h2, .notes-box h2 { font-size: 1.15rem; }
+ .ai-message h3, .summary-box h3, .notes-box h3 { font-size: 1.05rem; }
+
+ .ai-message p, .summary-box p, .notes-box p {
+ margin-bottom: 0.75rem;
+ }
+
+ .ai-message ul, .ai-message ol,
+ .summary-box ul, .summary-box ol,
+ .notes-box ul, .notes-box ol {
+ margin-left: 1.5rem;
+ margin-bottom: 0.75rem;
+ list-style-position: outside; /* Ensures bullets are outside the text flow */
+ }
+
+ .ai-message ul, .summary-box ul, .notes-box ul { list-style-type: disc; }
+ .ai-message ol, .summary-box ol, .notes-box ol { list-style-type: decimal; }
+
+ .ai-message li, .summary-box li, .notes-box li {
+ display: list-item; /* Ensure li elements are treated as list items */
+ margin-bottom: 0.25rem; /* Add some space between list items */
+ }
+
+ .ai-message code, .summary-box code, .notes-box code {
+ background-color: var(--bg-tertiary);
+ color: var(--text-primary);
+ padding: 0.125rem 0.25rem;
+ border-radius: 0.25rem;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
+ font-size: 0.875rem;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ }
+
+ .ai-message pre, .summary-box pre, .notes-box pre {
+ background-color: var(--bg-tertiary);
+ color: var(--text-primary);
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ margin-bottom: 0.75rem;
+ }
+
+ .ai-message pre code, .summary-box pre code, .notes-box pre code {
+ background-color: transparent;
+ padding: 0;
+ border-radius: 0;
+ color: inherit;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ }
+
+ .ai-message table, .summary-box table, .notes-box table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-bottom: 0.75rem;
+ }
+
+ .ai-message th, .ai-message td,
+ .summary-box th, .summary-box td,
+ .notes-box th, .notes-box td {
+ border: 1px solid var(--border-secondary); /* Use theme variable */
+ padding: 0.5rem;
+ text-align: left;
+ }
+
+ .ai-message th, .summary-box th, .notes-box th {
+ background-color: var(--bg-tertiary); /* Use theme variable */
+ font-weight: 600;
+ }
+
+ .ai-message blockquote, .summary-box blockquote, .notes-box blockquote {
+ border-left: 4px solid var(--border-secondary); /* Use theme variable */
+ padding-left: 1rem;
+ margin-left: 0;
+ margin-bottom: 0.75rem;
+ color: var(--text-muted); /* Use theme variable */
+ }
+
+ /* Main content container - ensure it fills available space and allows scrolling */
+.flex-grow.flex.flex-col.md\:flex-row.gap-6.overflow-hidden {
+ max-height: none !important; /* Override the inline style */
+ height: 100%;
+ flex: 1;
+ overflow-y: auto; /* Allow scrolling */
+}
+
+/* Ensure the main content container can grow properly and scroll - works for both col-span-3 and col-span-4 */
+.lg\:col-span-3.bg-\[var\(--bg-secondary\)\].p-6.rounded-lg.shadow-md.flex.flex-col.max-h-85vh,
+.lg\:col-span-4.bg-\[var\(--bg-secondary\)\].p-6.rounded-lg.shadow-md.flex.flex-col.max-h-85vh {
+ /* max-height: none !important; /* Let Tailwind class 'max-h-85vh' apply */
+ height: 100%; /* Occupy the height of its grid cell */
+ flex: 1; /* For its own children, as it's a flex container */
+ overflow-y: auto; /* Allow its own content to scroll if it exceeds its height (max 85vh) */
+}
+
+/* Ensure the main content area maintains its internal layout regardless of sidebar state */
+.main-content-area {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+/* Ensure the main content columns container maintains consistent behavior */
+#mainContentColumns {
+ display: flex;
+ flex-direction: row;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ width: 100%; /* Ensure full width usage */
+}
+
+/* Ensure left and right columns maintain their proportional widths */
+#leftMainColumn {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ flex-shrink: 0; /* Prevent shrinking */
+ border-right: 1px solid var(--border-primary);
+ /* Width is controlled by inline style */
+}
+
+#rightMainColumn {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ /* Width will be calculated as remaining space */
+}
+
+/* Ensure the main column resizer remains functional */
+#mainColumnResizer {
+ flex-shrink: 0;
+}
+
+/* Form styling for edit modal */
+.form-group {
+ position: relative;
+ transition: all 0.3s ease;
+}
+
+.form-group:hover {
+ transform: translateY(-1px);
+}
+
+.form-group input:focus,
+.form-group textarea:focus {
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
+}
+
+/* Dark mode adjustments for form elements */
+.dark .form-group input:focus,
+.dark .form-group textarea:focus {
+ box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.3);
+}
+
+/* Elegant divider styling */
+.relative.py-3 {
+ margin: 0.5rem 0;
+}
+
+/* Toast notification styles */
+.toast {
+ padding: 12px 18px;
+ border-radius: 8px;
+ background-color: #4CAF50;
+ color: white;
+ font-size: 14px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ opacity: 0;
+ transform: translateY(20px);
+ transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
+ display: flex;
+ align-items: center;
+ min-width: 200px;
+}
+
+ .toast.show {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ .toast i {
+ margin-right: 8px;
+ }
+
+ /* Copy button animation */
+ @keyframes copy-success {
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.2); }
+ 100% { transform: scale(1); }
+ }
+
+ .copy-success {
+ animation: copy-success 0.3s ease;
+ color: #4CAF50 !important;
+ }
+
+/* Fix for sidebar height and scrolling */
+/* Remove fixed height constraint from the grid container to allow natural height */
+.grid.grid-cols-1.lg\:grid-cols-4.gap-6.flex-grow {
+ display: grid;
+ min-height: 0; /* Allow the grid to shrink if needed */
+ overflow: visible; /* Allow overflow to be visible and scroll with the main window */
+ align-items: start; /* Align grid items to start to prevent stretching */
+}
+
+/* Set a fixed height for the sidebar only */
+.lg\:col-span-1.bg-\[var\(--bg-secondary\)\].p-4.rounded-lg.shadow-md.sidebar-container.max-h-85vh {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden; /* Hide overflow at container level */
+ height: calc(100vh - 10rem);
+ position: sticky;
+ top: 1rem; /* Stick to the top with some padding */
+}
+
+/* Make sure the sidebar content scrolls internally */
+.sidebar-content {
+ flex: 1;
+ overflow-y: auto; /* Enable scrolling for content */
+ min-height: 0; /* Allow content to shrink */
+}
+
+/* Ensure the main content area uses the window scroll and has consistent height */
+.lg\:col-span-3.bg-\[var\(--bg-secondary\)\].p-6.rounded-lg.shadow-md.flex.flex-col.max-h-85vh {
+ max-height: calc(100vh - 10rem) !important; /* Match sidebar height */
+ height: calc(100vh - 10rem); /* Fixed height to match sidebar */
+ overflow-y: auto; /* Enable internal scrolling */
+}
+
+/* Allow the main container to use window scrolling */
+main {
+ overflow-y: visible; /* Use window scrolling instead of internal scrolling */
+}
+
+/* Ensure body scrolls when content exceeds viewport */
+body {
+ overflow-y: auto; /* Allow scrolling on the body */
+}
+
+/* Fix recording list item layout to prevent title wrapping */
+.sidebar-content li {
+ display: flex;
+ align-items: center;
+ min-height: 3rem; /* Consistent minimum height for all items */
+ max-height: 3rem; /* Prevent items from growing taller */
+}
+
+/* Ensure recording title container doesn't grow beyond available space */
+.sidebar-content li .flex.items-center.overflow-hidden {
+ min-width: 0; /* Allow flex item to shrink below content size */
+ flex: 1; /* Take up available space */
+}
+
+/* Ensure truncate works properly on recording titles */
+.sidebar-content li .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0; /* Allow text to shrink */
+}
+
+/* Blinking animation for recording indicator */
+@keyframes blink-animation {
+ 0% { opacity: 1; }
+ 50% { opacity: 0.3; }
+ 100% { opacity: 1; }
+}
+.blink {
+ animation: blink-animation 1.5s infinite;
+}
+
+/* Speaker tag styling in the modal */
+.speaker-tag {
+ font-weight: 600; /* Make speaker tags bold */
+ color: var(--text-accent);
+ padding: 2px 4px;
+ border-radius: 4px;
+ transition: all 0.2s ease-in-out;
+}
+
+.speaker-highlight {
+ padding: 3px 6px;
+ border-radius: 6px;
+ transition: all 0.3s ease;
+ position: relative;
+}
+
+/* Speaker-specific highlight styles that match speaker colors - reduced glow */
+.speaker-highlight.speaker-color-1 {
+ background-color: #E3F2FD;
+ color: #0D47A1;
+ box-shadow: 0 0 8px rgba(227, 242, 253, 0.6), 0 0 12px rgba(13, 71, 161, 0.2);
+}
+
+.speaker-highlight.speaker-color-2 {
+ background-color: #F3E5F5;
+ color: #6A1B9A;
+ box-shadow: 0 0 8px rgba(243, 229, 245, 0.6), 0 0 12px rgba(106, 27, 154, 0.2);
+}
+
+.speaker-highlight.speaker-color-3 {
+ background-color: #E8F5E8;
+ color: #1B5E20;
+ box-shadow: 0 0 8px rgba(232, 245, 232, 0.6), 0 0 12px rgba(27, 94, 32, 0.2);
+}
+
+.speaker-highlight.speaker-color-4 {
+ background-color: #FFF3E0;
+ color: #E65100;
+ box-shadow: 0 0 8px rgba(255, 243, 224, 0.6), 0 0 12px rgba(230, 81, 0, 0.2);
+}
+
+.speaker-highlight.speaker-color-5 {
+ background-color: #FCE4EC;
+ color: #AD1457;
+ box-shadow: 0 0 8px rgba(252, 228, 236, 0.6), 0 0 12px rgba(173, 20, 87, 0.2);
+}
+
+.speaker-highlight.speaker-color-6 {
+ background-color: #E0F7FA;
+ color: #006064;
+ box-shadow: 0 0 8px rgba(224, 247, 250, 0.6), 0 0 12px rgba(0, 96, 100, 0.2);
+}
+
+.speaker-highlight.speaker-color-7 {
+ background-color: #FFF9C4;
+ color: #F57F17;
+ box-shadow: 0 0 8px rgba(255, 249, 196, 0.6), 0 0 12px rgba(245, 127, 23, 0.2);
+}
+
+.speaker-highlight.speaker-color-8 {
+ background-color: #EFEBE9;
+ color: #5D4037;
+ box-shadow: 0 0 8px rgba(239, 235, 233, 0.6), 0 0 12px rgba(93, 64, 55, 0.2);
+}
+
+/* Dark mode speaker-specific highlights - reduced glow */
+.dark .speaker-highlight.speaker-color-1 {
+ background-color: #1E3A5F;
+ color: #A5C9EA;
+ box-shadow: 0 0 8px rgba(30, 58, 95, 0.6), 0 0 12px rgba(165, 201, 234, 0.2);
+}
+
+.dark .speaker-highlight.speaker-color-2 {
+ background-color: #4A2C5A;
+ color: #D4A5D4;
+ box-shadow: 0 0 8px rgba(74, 44, 90, 0.6), 0 0 12px rgba(212, 165, 212, 0.2);
+}
+
+.dark .speaker-highlight.speaker-color-3 {
+ background-color: #1F4A3C;
+ color: #A8D5A8;
+ box-shadow: 0 0 8px rgba(31, 74, 60, 0.6), 0 0 12px rgba(168, 213, 168, 0.2);
+}
+
+.dark .speaker-highlight.speaker-color-4 {
+ background-color: #5A3A1F;
+ color: #E6B366;
+ box-shadow: 0 0 8px rgba(90, 58, 31, 0.6), 0 0 12px rgba(230, 179, 102, 0.2);
+}
+
+.dark .speaker-highlight.speaker-color-5 {
+ background-color: #5A2C3E;
+ color: #E6A5C4;
+ box-shadow: 0 0 8px rgba(90, 44, 62, 0.6), 0 0 12px rgba(230, 165, 196, 0.2);
+}
+
+.dark .speaker-highlight.speaker-color-6 {
+ background-color: #1F4A47;
+ color: #A5D5D0;
+ box-shadow: 0 0 8px rgba(31, 74, 71, 0.6), 0 0 12px rgba(165, 213, 208, 0.2);
+}
+
+.dark .speaker-highlight.speaker-color-7 {
+ background-color: #4A4A1F;
+ color: #E6E266;
+ box-shadow: 0 0 8px rgba(74, 74, 31, 0.6), 0 0 12px rgba(230, 226, 102, 0.2);
+}
+
+.dark .speaker-highlight.speaker-color-8 {
+ background-color: #3E2723;
+ color: #D7CCC8;
+ box-shadow: 0 0 8px rgba(62, 39, 35, 0.6), 0 0 12px rgba(215, 204, 200, 0.2);
+}
+
+/* Speaker Legend and Bubble Styles */
+.speaker-legend {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ margin-bottom: 0.5rem;
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.375rem;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ overflow: hidden;
+ transition: all 0.3s ease;
+}
+
+.dark .speaker-legend {
+ border: 1px solid var(--border-secondary);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
+}
+
+.speaker-legend-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.75rem;
+ background-color: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-primary);
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+}
+
+.dark .speaker-legend-header {
+ background-color: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-secondary);
+}
+
+.speaker-legend-header:hover {
+ background-color: var(--bg-accent);
+}
+
+.speaker-legend-title {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.speaker-legend-toggle {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ transition: transform 0.3s ease, color 0.2s ease;
+}
+
+.speaker-legend.expanded .speaker-legend-toggle {
+ transform: rotate(180deg);
+ color: var(--text-accent);
+}
+
+.speaker-legend-content {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+ padding: 0;
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.3s ease, padding 0.3s ease;
+}
+
+.speaker-legend.expanded .speaker-legend-content {
+ max-height: 200px; /* Reasonable max height for scrolling */
+ overflow-y: auto;
+ padding: 0.5rem 0.75rem;
+}
+
+.speaker-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.375rem;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ flex-shrink: 0;
+ width: auto;
+ max-width: fit-content;
+ border: 1px solid rgba(255,255,255,0.2);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
+}
+
+.speaker-legend-item:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.15);
+}
+
+.speaker-name {
+ font-weight: 500;
+}
+
+/* Compact legend when collapsed - show as many speakers as fit */
+.speaker-legend:not(.expanded) .speaker-legend-content {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ overflow: hidden;
+ max-height: 3rem; /* Allow up to 2 rows of speakers */
+ padding: 0.25rem 0.75rem;
+}
+
+.speaker-legend:not(.expanded) .speaker-legend-item {
+ flex-shrink: 1;
+ min-width: fit-content;
+}
+
+.speaker-count-indicator {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-weight: 400;
+ margin-left: 0.25rem;
+}
+
+/* Enhanced transcription display with speaker bubbles */
+.transcription-with-speakers {
+ line-height: 1.6;
+ font-family: inherit;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.speaker-bubble {
+ display: inline-block;
+ margin: 0.125rem 0.25rem 0.125rem 0;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.75rem;
+ max-width: fit-content;
+ min-width: fit-content;
+ width: auto;
+ word-wrap: break-word;
+ position: relative;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.08);
+ transition: all 0.2s ease;
+ border: 1px solid rgba(255,255,255,0.3);
+ vertical-align: top;
+}
+
+.speaker-bubble:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.12);
+}
+
+.speaker-bubble.speaker-me {
+ border-bottom-right-radius: 0.25rem;
+}
+
+.speaker-bubble:not(.speaker-me) {
+ border-bottom-left-radius: 0.25rem;
+}
+
+.speaker-bubble-content {
+ margin: 0;
+ color: inherit;
+ font-size: 0.875rem;
+ line-height: 1.4;
+ white-space: pre-wrap;
+}
+
+/* Bubble row container for horizontal grouping */
+.bubble-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ margin-bottom: 0.5rem;
+ gap: 0.25rem;
+ width: 100%;
+}
+
+.bubble-row.speaker-me {
+ justify-content: flex-end;
+}
+
+.bubble-row:not(.speaker-me) {
+ justify-content: flex-start;
+}
+
+/* Individual bubbles within rows should size to content and allow wrapping */
+.bubble-row .speaker-bubble {
+ flex: 0 0 auto;
+ max-width: calc(100% - 0.5rem);
+ width: auto;
+ min-width: fit-content;
+ margin: 0;
+}
+
+/* Ensure bubbles can wrap to fill available space on mobile - preserve desktop styling */
+@media (max-width: 1023px) {
+ /* Target all bubble rows on mobile - preserve desktop gap and margin */
+ .bubble-row,
+ .mobile-content-box .bubble-row,
+ .transcription-with-speakers .bubble-row {
+ display: flex !important;
+ flex-direction: row !important;
+ flex-wrap: wrap !important;
+ width: 100% !important;
+ gap: 0.25rem !important;
+ margin-bottom: 0.5rem !important;
+ align-items: flex-start !important;
+ }
+
+ /* Target all speaker bubbles on mobile - preserve desktop spacing and styling */
+ .speaker-bubble,
+ .mobile-content-box .speaker-bubble,
+ .bubble-row .speaker-bubble {
+ display: inline-block !important;
+ flex: 0 0 auto !important;
+ max-width: calc(70% - 0.25rem) !important;
+ width: auto !important;
+ min-width: fit-content !important;
+ margin: 0.125rem 0.25rem 0.125rem 0 !important; /* Preserve desktop margin */
+ flex-shrink: 1 !important;
+ vertical-align: top !important;
+ padding: 0.5rem 0.75rem !important; /* Preserve desktop padding */
+ border-radius: 0.75rem !important; /* Preserve desktop border-radius */
+ box-shadow: 0 1px 2px rgba(0,0,0,0.08) !important; /* Preserve desktop shadow */
+ transition: all 0.2s ease !important; /* Preserve desktop transition */
+ border: 1px solid rgba(255,255,255,0.3) !important; /* Preserve desktop border */
+ }
+
+ /* Preserve desktop hover effects */
+ .speaker-bubble:hover,
+ .mobile-content-box .speaker-bubble:hover,
+ .bubble-row .speaker-bubble:hover {
+ transform: translateY(-1px) !important;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.12) !important;
+ }
+
+ /* Preserve desktop bubble content styling */
+ .speaker-bubble .speaker-bubble-content,
+ .mobile-content-box .speaker-bubble .speaker-bubble-content {
+ margin: 0 !important;
+ color: inherit !important;
+ font-size: 0.875rem !important;
+ line-height: 1.4 !important;
+ white-space: pre-wrap !important;
+ }
+
+ /* Force horizontal layout for speaker rows */
+ .bubble-row.speaker-me,
+ .bubble-row:not(.speaker-me) {
+ display: flex !important;
+ flex-direction: row !important;
+ flex-wrap: wrap !important;
+ }
+
+ /* Override any conflicting styles */
+ .transcription-with-speakers {
+ display: block !important;
+ }
+}
+
+/* Simple view with speaker tablets */
+.transcription-simple-view {
+ line-height: 1.6;
+ font-family: inherit;
+}
+
+.speaker-segment {
+ margin-bottom: 1rem;
+}
+
+.speaker-tablet {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ margin-right: 0.5rem;
+ border: none;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
+}
+
+.speaker-text {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ line-height: 1.5;
+ margin-left: 0;
+}
+
+/* View mode toggle - compact tablet style */
+.view-mode-toggle {
+ display: inline-flex;
+ align-items: center;
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.5rem;
+ padding: 0;
+ gap: 0;
+}
+
+.toggle-button {
+ padding: 0.35rem 0.75rem;
+ border: none;
+ border-radius: 0.5rem;
+ background-color: transparent;
+ color: var(--text-secondary);
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+}
+
+.toggle-button.active {
+ background-color: var(--bg-accent);
+ color: var(--text-accent);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
+}
+
+.toggle-button:hover:not(.active) {
+ background-color: var(--bg-secondary);
+ color: var(--text-secondary);
+}
+
+.toggle-button i {
+ font-size: 0.75rem;
+ margin-right: 0;
+}
+.toggle-button span + i,
+.toggle-button i + span {
+ margin-left: 0.25rem;
+}
+
+.dark .view-mode-toggle {
+ background-color: var(--bg-secondary);
+ border-color: var(--border-secondary);
+}
+
+/* Speaker colors - enhanced palette with better variation and contrast */
+.speaker-color-1 { background-color: #E3F2FD; color: #0D47A1; } /* Deep Blue */
+.speaker-color-2 { background-color: #F3E5F5; color: #6A1B9A; } /* Deep Purple */
+.speaker-color-3 { background-color: #E8F5E8; color: #1B5E20; } /* Deep Green */
+.speaker-color-4 { background-color: #FFF3E0; color: #E65100; } /* Deep Orange */
+.speaker-color-5 { background-color: #FCE4EC; color: #AD1457; } /* Deep Pink */
+.speaker-color-6 { background-color: #E0F7FA; color: #006064; } /* Deep Cyan */
+.speaker-color-7 { background-color: #FFF9C4; color: #F57F17; } /* Deep Yellow */
+.speaker-color-8 { background-color: #EFEBE9; color: #5D4037; } /* Deep Brown */
+
+/* Dark mode speaker colors - tasteful muted pastels with better variation */
+.dark .speaker-color-1 { background-color: #1E3A5F; color: #A5C9EA; } /* Muted blue background, soft blue text */
+.dark .speaker-color-2 { background-color: #4A2C5A; color: #D4A5D4; } /* Muted purple background, soft purple text */
+.dark .speaker-color-3 { background-color: #1F4A3C; color: #A8D5A8; } /* Muted green background, soft green text */
+.dark .speaker-color-4 { background-color: #5A3A1F; color: #E6B366; } /* Muted orange background, soft orange text */
+.dark .speaker-color-5 { background-color: #5A2C3E; color: #E6A5C4; } /* Muted pink background, soft pink text */
+.dark .speaker-color-6 { background-color: #1F4A47; color: #A5D5D0; } /* Muted teal background, soft teal text */
+.dark .speaker-color-7 { background-color: #4A4A1F; color: #E6E266; } /* Muted yellow background, soft yellow text */
+.dark .speaker-color-8 { background-color: #3E2723; color: #D7CCC8; } /* Muted brown background, soft brown text */
+
+/* Meeting/Created time styling improvements */
+.recording-metadata {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1rem;
+ margin-top: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.metadata-item {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ color: var(--text-muted);
+ transition: color 0.2s ease;
+ white-space: nowrap;
+}
+
+.metadata-item:hover {
+ color: var(--text-secondary);
+}
+
+.metadata-icon {
+ color: var(--text-accent);
+ opacity: 0.75;
+ font-size: 0.75rem;
+ width: 12px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.metadata-label {
+ font-weight: 500;
+ color: var(--text-secondary);
+ font-size: 0.8125rem;
+}
+
+.metadata-value {
+ color: var(--text-muted);
+ font-size: 0.8125rem;
+}
+
+.metadata-value.editable {
+ cursor: pointer;
+ padding: 0.125rem 0.25rem;
+ border-radius: 0.25rem;
+ transition: all 0.2s ease;
+}
+
+.metadata-value.editable:hover {
+ background-color: var(--bg-tertiary);
+ color: var(--text-accent);
+}
+
+.metadata-edit-input {
+ padding: 0.125rem 0.5rem;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--border-secondary);
+ color: var(--text-primary);
+ font-size: inherit;
+ width: 140px;
+}
+
+.metadata-edit-input:focus {
+ outline: none;
+ border-bottom-color: var(--border-focus);
+}
+
+.metadata-edit-button {
+ margin-left: 0.25rem;
+ padding: 0.125rem;
+ background: none;
+ border: none;
+ color: var(--text-accent);
+ cursor: pointer;
+ font-size: 0.75rem;
+ opacity: 0.7;
+ transition: opacity 0.2s ease;
+}
+
+.metadata-edit-button:hover {
+ opacity: 1;
+}
+
+.modal-content {
+ max-height: calc(100vh - 200px);
+ overflow-y: auto;
+}
+
+/* Mobile Tabbed Layout */
+@media (max-width: 1023px) {
+ /* Desktop layout's main content is hidden via v-if="!isMobileScreen" in HTML */
+ /* .desktop-layout {
+ display: none !important;
+ } */
+
+ /* Show mobile layout only on mobile */
+ .mobile-layout {
+ display: flex !important;
+ flex-direction: column;
+ height: calc(100vh - 120px); /* Account for header and footer */
+ overflow: hidden;
+ }
+
+ /* Mobile audio player section */
+ .mobile-audio-section {
+ flex-shrink: 0;
+ padding: 1rem;
+ background-color: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-primary);
+ }
+
+ /* Mobile tab navigation */
+ .mobile-tab-nav {
+ flex-shrink: 0;
+ background-color: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-primary);
+ padding: 0 1rem;
+ }
+
+ .mobile-tab-nav .tab-list {
+ display: flex;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ }
+
+ .mobile-tab-nav .tab-list::-webkit-scrollbar {
+ display: none;
+ }
+
+ .mobile-tab-button {
+ flex-shrink: 0;
+ padding: 0.75rem 1rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ border-bottom: 2px solid transparent;
+ color: var(--text-muted);
+ background: none;
+ border: none;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ }
+
+ .mobile-tab-button.active {
+ color: var(--text-accent);
+ border-bottom-color: var(--border-focus);
+ }
+
+ .mobile-tab-button:hover {
+ color: var(--text-secondary);
+ }
+
+ /* Mobile tab content area */
+ .mobile-tab-content {
+ flex: 1;
+ overflow: hidden;
+ background-color: var(--bg-secondary);
+ }
+
+ .mobile-tab-panel {
+ height: 100%;
+ overflow-y: auto;
+ padding: 1rem;
+ display: none;
+ }
+
+ .mobile-tab-panel.active {
+ display: block;
+ }
+
+/* Mobile tab content styling */
+.mobile-content-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border-primary);
+}
+
+/* Mobile fullscreen button for editors */
+.mobile-fullscreen-btn {
+ padding: 0.375rem;
+ background: none;
+ border: 1px solid var(--border-secondary);
+ border-radius: 0.375rem;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 0.75rem;
+}
+
+.mobile-fullscreen-btn:hover {
+ background-color: var(--bg-tertiary);
+ color: var(--text-accent);
+}
+
+ .mobile-content-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border-primary);
+ }
+
+ .mobile-content-title {
+ font-weight: 600;
+ color: var(--text-secondary);
+ flex: 1;
+ }
+
+ .mobile-content-actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .mobile-action-btn {
+ padding: 0.5rem;
+ background: none;
+ border: 1px solid var(--border-secondary);
+ border-radius: 0.375rem;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 0.875rem;
+ }
+
+ .mobile-action-btn:hover {
+ background-color: var(--bg-tertiary);
+ color: var(--text-accent);
+ }
+
+ .mobile-content-box {
+ flex: 1;
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ overflow-y: auto;
+ overflow-x: hidden;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ /* Fix scrollbar clipping rounded corners */
+ scrollbar-gutter: stable;
+ color: var(--text-secondary);
+ min-height: 0;
+ }
+
+ /* Mobile chat layout */
+ .mobile-chat-content {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .mobile-chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1rem;
+ background-color: var(--bg-secondary); /* Changed from --bg-tertiary */
+ border: 1px solid var(--border-primary);
+ border-radius: 0.75rem 0.75rem 0 0;
+ margin-bottom: 0;
+ }
+
+ .mobile-chat-input {
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-top: none;
+ border-radius: 0 0 0.75rem 0.75rem;
+ padding: 0.75rem;
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .mobile-chat-input textarea { /* Changed from input to textarea */
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid var(--border-secondary);
+ border-radius: 0.375rem;
+ background-color: var(--bg-input);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ line-height: 1.5; /* Added for consistent line height */
+ resize: none; /* Prevent manual resizing */
+ min-height: calc(1.5em + 1rem); /* Approx 1 line with padding */
+ max-height: calc(1.5em * 4 + 1rem); /* Approx 4 lines with padding */
+ overflow-y: auto; /* Allow scrolling after max-height */
+ }
+
+/* Desktop chat input textarea styling */
+.chat-input-container textarea {
+ min-height: calc(1.5em * 3 + 1rem); /* Approx 3 lines with padding */
+ max-height: calc(1.5em * 6 + 1rem); /* Approx 6 lines with padding */
+ overflow-y: auto; /* Allow scrolling after max-height */
+ line-height: 1.5; /* Consistent line height */
+ font-family: inherit; /* Use the same font as the rest of the app */
+}
+
+ .mobile-chat-input button {
+ padding: 0.5rem 1rem;
+ background-color: var(--bg-button);
+ color: var(--text-button);
+ border: none;
+ border-radius: 0.375rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+ }
+
+ .mobile-chat-input button:disabled {
+ background-color: var(--bg-tertiary);
+ color: var(--text-muted);
+ cursor: not-allowed;
+ }
+}
+
+/* Hide mobile layout on desktop */
+@media (min-width: 1024px) {
+ .mobile-layout {
+ display: none !important;
+ }
+
+ .desktop-layout {
+ display: block !important;
+ }
+}
+
+/* Responsive adjustments for mobile */
+@media (max-width: 1023px) {
+ /* Ensure modals are not too wide on small screens */
+ .fixed.inset-0 .w-full.max-w-md,
+ .fixed.inset-0 .w-full.max-w-lg,
+ .fixed.inset-0 .w-full.max-w-4xl {
+ max-width: 95vw !important;
+ max-height: 90vh;
+ }
+
+ /* Ensure the main content area is visible when the mobile menu is closed */
+ .main-content-area {
+ display: block !important;
+ }
+
+}
+
+/* Responsive title that shrinks font size instead of wrapping */
+.responsive-title {
+ font-size: clamp(1.125rem, 3.5vw, 1.5rem); /* More aggressive responsive scaling */
+ line-height: 1.2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+ min-width: 0;
+}
+
+/* Ensure the title container doesn't grow beyond available space */
+.responsive-title-container {
+ min-width: 0;
+ flex: 1;
+ max-width: calc(100% - 200px); /* Reserve space for buttons */
+}
+
+/* Responsive button container */
+.responsive-button-container {
+ display: flex;
+ gap: 0.375rem;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+/* Responsive button styling */
+.responsive-button-container button {
+ padding: 0.375rem 0.5rem;
+ font-size: 0.875rem;
+ min-width: 2rem;
+ height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+/* On very narrow screens, make buttons smaller */
+@media (max-width: 640px) {
+ .responsive-title {
+ font-size: clamp(1rem, 4vw, 1.25rem);
+ }
+
+ .responsive-title-container {
+ max-width: calc(100% - 150px); /* Less space reserved for buttons */
+ }
+
+ .responsive-button-container {
+ gap: 0.25rem;
+ }
+
+ .responsive-button-container button {
+ padding: 0.25rem 0.375rem;
+ font-size: 0.75rem;
+ min-width: 1.75rem;
+ height: 1.75rem;
+ }
+
+ /* Hide button text on very small screens, show only icons */
+ .responsive-button-container button span {
+ display: none;
+ }
+
+ .responsive-button-container button i {
+ margin: 0;
+ }
+}
+
+/* Header layout improvements for better responsiveness */
+.recording-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--border-primary);
+}
+
+.recording-title-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ min-height: 2.5rem;
+}
+
+.recording-metadata-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1rem;
+ font-size: 0.875rem;
+}
+
+/* Ensure metadata items stay on same line when possible */
+@media (min-width: 768px) {
+ .recording-metadata-row {
+ flex-wrap: nowrap;
+ gap: 1.5rem;
+ }
+}
+
+/* Toast notifications */
+#toastContainer {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 12px;
+}
+
+.toast {
+ background: var(--bg-success);
+ color: var(--text-success-strong);
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ border: 1px solid var(--border-success);
+ transform: translateX(100%);
+ opacity: 0;
+ transition: all 0.3s ease-in-out;
+ pointer-events: auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ max-width: 300px;
+}
+
+.toast.show {
+ transform: translateX(0);
+ opacity: 1;
+}
+
+.toast i {
+ flex-shrink: 0;
+}
+
+/* Discrete horizontal resize divider */
+#mainColumnResizer {
+ width: 12px !important;
+ background-color: transparent !important;
+ cursor: ew-resize !important;
+ margin: 0 -6px !important;
+ position: relative;
+ z-index: 10;
+}
+
+#mainColumnResizer:hover::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 4px;
+ height: 60px;
+ background-color: var(--border-accent);
+ border-radius: 2px;
+ transition: background-color 0.15s ease;
+}
+
+/* Ensure consistent layout behavior when sidebar is collapsed */
+.sidebar-container.lg\:col-span-1 {
+ transition: all 0.3s ease;
+}
+
+/* Smooth transition for main content area when sidebar toggles */
+.main-content-area {
+ transition: all 0.3s ease;
+}
+
+/* Preserve column widths during sidebar transitions */
+.content-column {
+ transition: none !important; /* Disable transitions on content columns to prevent layout shifts */
+}
+
+/* Ensure the grid container adapts smoothly */
+.grid.grid-cols-1.lg\:grid-cols-4.gap-6.flex-grow {
+ transition: all 0.3s ease;
+}
+
+/* Force consistent internal layout regardless of parent grid span */
+.main-content-area #mainContentColumns {
+ width: 100% !important;
+ max-width: none !important;
+ min-width: 0 !important;
+}
+
+/* Ensure left column maintains its set width - respect inline styles */
+.main-content-area #leftMainColumn {
+ flex-shrink: 0 !important;
+ min-width: 0 !important;
+ /* Don't override width - let inline style control it */
+}
+
+/* Ensure right column fills remaining space */
+.main-content-area #rightMainColumn {
+ flex: 1 !important;
+ min-width: 0 !important;
+ width: auto !important;
+}
+
+/* Specific rule to ensure inline width styles are respected */
+#leftMainColumn[style*="width"] {
+ flex-basis: auto !important;
+ flex-grow: 0 !important;
+ flex-shrink: 0 !important;
+}
+
+/* Prevent layout shifts when grid span changes */
+.main-content-area .recording-header,
+.main-content-area .participants-section,
+.main-content-area .transcription-section,
+.main-content-area .tab-section,
+.main-content-area .audio-section,
+.main-content-area .chat-section {
+ width: 100% !important;
+ max-width: none !important;
+}
+
+/* Discrete vertical resize divider */
+.resize-handle {
+ height: 12px !important;
+ background-color: transparent !important;
+ cursor: ns-resize !important;
+ margin: 2px 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+}
+
+.resize-handle .w-10 {
+ width: 60px !important;
+ height: 4px !important;
+ background-color: var(--text-light) !important;
+ border-radius: 2px !important;
+ transition: background-color 0.15s ease !important;
+}
+
+.resize-handle:hover .w-10 {
+ background-color: var(--border-accent) !important;
+}
+
+/* Color Scheme Modal Styles */
+.color-scheme-modal {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+ padding: 1rem;
+ backdrop-filter: blur(4px);
+ transition: all 0.3s ease-in-out;
+}
+
+.color-scheme-modal-content {
+ background-color: var(--bg-secondary);
+ border-radius: 1rem;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ width: 100%;
+ max-width: 42rem;
+ max-height: 90vh;
+ overflow: hidden;
+ transform: scale(1);
+ transition: all 0.3s ease-in-out;
+}
+
+.color-scheme-header {
+ background: linear-gradient(135deg, var(--bg-accent), var(--bg-secondary));
+ padding: 1.5rem;
+ border-bottom: 1px solid var(--border-primary);
+}
+
+.color-scheme-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.color-scheme-subtitle {
+ color: var(--text-muted);
+ font-size: 0.875rem;
+ margin-top: 0.5rem;
+ margin-bottom: 0;
+}
+
+.color-scheme-body {
+ padding: 1.5rem;
+ max-height: calc(90vh - 200px);
+ overflow-y: auto;
+}
+
+.color-scheme-section {
+ margin-bottom: 2rem;
+}
+
+.color-scheme-section:last-child {
+ margin-bottom: 0;
+}
+
+.color-scheme-section-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.color-scheme-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1rem;
+}
+
+.color-scheme-option {
+ border: 2px solid var(--border-primary);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ background-color: var(--bg-tertiary);
+ position: relative;
+ overflow: hidden;
+}
+
+.color-scheme-option:hover {
+ border-color: var(--border-accent);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
+}
+
+.color-scheme-option.active {
+ border-color: var(--border-focus);
+ background-color: var(--bg-accent);
+ box-shadow: 0 0 0 3px var(--ring-focus);
+}
+
+.color-scheme-preview {
+ height: 60px;
+ border-radius: 0.5rem;
+ margin-bottom: 0.75rem;
+ display: flex;
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.color-scheme-preview-segment {
+ flex: 1;
+ transition: all 0.2s ease;
+}
+
+.color-scheme-option:hover .color-scheme-preview-segment {
+ transform: scale(1.02);
+}
+
+.color-scheme-name {
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.25rem;
+ font-size: 0.875rem;
+}
+
+.color-scheme-description {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ line-height: 1.4;
+}
+
+.color-scheme-check {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius: 50%;
+ background-color: var(--bg-button);
+ color: var(--text-button);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.75rem;
+ opacity: 0;
+ transform: scale(0.8);
+ transition: all 0.2s ease;
+}
+
+.color-scheme-option.active .color-scheme-check {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.color-scheme-footer {
+ background-color: var(--bg-tertiary);
+ padding: 1rem 1.5rem;
+ border-top: 1px solid var(--border-primary);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.color-scheme-close-btn {
+ padding: 0.5rem 1rem;
+ background-color: var(--bg-secondary);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-secondary);
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.color-scheme-close-btn:hover {
+ background-color: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.color-scheme-reset-btn {
+ padding: 0.5rem 1rem;
+ background-color: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border-secondary);
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.color-scheme-reset-btn:hover {
+ background-color: var(--bg-danger-light);
+ color: var(--text-danger);
+ border-color: var(--border-danger);
+}
+
+/* Color scheme preview colors */
+.preview-blue-primary { background-color: #2563eb; }
+.preview-blue-secondary { background-color: #dbeafe; }
+.preview-blue-tertiary { background-color: #93c5fd; }
+
+.preview-emerald-primary { background-color: #059669; }
+.preview-emerald-secondary { background-color: #d1fae5; }
+.preview-emerald-tertiary { background-color: #6ee7b7; }
+
+.preview-purple-primary { background-color: #7c3aed; }
+.preview-purple-secondary { background-color: #e9d5ff; }
+.preview-purple-tertiary { background-color: #c4b5fd; }
+
+.preview-rose-primary { background-color: #e11d48; }
+.preview-rose-secondary { background-color: #fce7f3; }
+.preview-rose-tertiary { background-color: #f9a8d4; }
+
+.preview-amber-primary { background-color: #d97706; }
+.preview-amber-secondary { background-color: #fef3c7; }
+.preview-amber-tertiary { background-color: #fcd34d; }
+
+.preview-teal-primary { background-color: #0d9488; }
+.preview-teal-secondary { background-color: #ccfbf1; }
+.preview-teal-tertiary { background-color: #5eead4; }
+
+/* Dark theme preview colors */
+.preview-dark-blue-primary { background-color: #1e3a8a; }
+.preview-dark-blue-secondary { background-color: #1e40af; }
+.preview-dark-blue-tertiary { background-color: #60a5fa; }
+
+.preview-dark-emerald-primary { background-color: #064e3b; }
+.preview-dark-emerald-secondary { background-color: #065f46; }
+.preview-dark-emerald-tertiary { background-color: #6ee7b7; }
+
+.preview-dark-purple-primary { background-color: #581c87; }
+.preview-dark-purple-secondary { background-color: #6b21a8; }
+.preview-dark-purple-tertiary { background-color: #c4b5fd; }
+
+.preview-dark-rose-primary { background-color: #881337; }
+.preview-dark-rose-secondary { background-color: #9f1239; }
+.preview-dark-rose-tertiary { background-color: #fda4af; }
+
+.preview-dark-amber-primary { background-color: #78350f; }
+.preview-dark-amber-secondary { background-color: #92400e; }
+.preview-dark-amber-tertiary { background-color: #fcd34d; }
+
+.preview-dark-teal-primary { background-color: #134e4a; }
+.preview-dark-teal-secondary { background-color: #115e59; }
+.preview-dark-teal-tertiary { background-color: #5eead4; }
+
+/* Line clamp utility for text truncation */
+.line-clamp-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* Responsive adjustments for color scheme modal */
+@media (max-width: 768px) {
+ .color-scheme-modal {
+ padding: 0.5rem;
+ }
+
+ .color-scheme-modal-content {
+ max-width: 95vw;
+ max-height: 95vh;
+ }
+
+ .color-scheme-grid {
+ grid-template-columns: 1fr;
+ gap: 0.75rem;
+ }
+
+ .color-scheme-header {
+ padding: 1rem;
+ }
+
+ .color-scheme-body {
+ padding: 1rem;
+ }
+
+ .color-scheme-footer {
+ padding: 1rem;
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+}
+
+/* Enhanced Audio Player Styling for Better Dark Mode Visibility */
+.audio-player-container {
+ background: var(--bg-audio-player);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ transition: all 0.3s ease;
+}
+
+.dark .audio-player-container {
+ border: 1px solid var(--border-secondary);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+/* Audio player controls styling */
+.audio-player-container audio {
+ width: 100%;
+ height: 40px;
+ background-color: var(--bg-tertiary);
+ border-radius: 0.5rem;
+ border: 1px solid var(--border-primary);
+ outline: none;
+ /* Use theme accent color for controls (play button, progress bar, etc.) */
+ accent-color: var(--bg-button);
+}
+
+.dark .audio-player-container audio {
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border-secondary);
+}
+
+/* Audio player controls for WebKit browsers */
+.audio-player-container audio::-webkit-media-controls-panel {
+ background-color: var(--bg-tertiary);
+ border-radius: 0.5rem;
+}
+
+.dark .audio-player-container audio::-webkit-media-controls-panel {
+ background-color: var(--bg-secondary);
+}
+
+.audio-player-container audio::-webkit-media-controls-enclosure {
+ border: none;
+ background: none;
+}
+
+.audio-player-container audio::-webkit-media-controls-play-button,
+.audio-player-container audio::-webkit-media-controls-pause-button {
+ background-color: var(--bg-button);
+ border-radius: 50%;
+ margin: 0 0.25rem;
+}
+
+.dark .audio-player-container audio::-webkit-media-controls-play-button,
+.dark .audio-player-container audio::-webkit-media-controls-pause-button {
+}
+
+.audio-player-container audio::-webkit-media-controls-timeline {
+ background-color: var(--border-primary);
+ border-radius: 0.25rem;
+ margin: 0 0.5rem;
+}
+
+.dark .audio-player-container audio::-webkit-media-controls-timeline {
+ background-color: var(--border-secondary);
+}
+
+.audio-player-container audio::-webkit-media-controls-current-time-display,
+.audio-player-container audio::-webkit-media-controls-time-remaining-display {
+ color: var(--text-secondary);
+ font-size: 0.75rem;
+}
+
+.dark .audio-player-container audio::-webkit-media-controls-current-time-display,
+.dark .audio-player-container audio::-webkit-media-controls-time-remaining-display {
+ color: var(--text-primary);
+}
+
+/* Audio player volume controls */
+.audio-player-container audio::-webkit-media-controls-volume-slider {
+ background-color: var(--border-primary);
+ border-radius: 0.25rem;
+}
+
+.dark .audio-player-container audio::-webkit-media-controls-volume-slider {
+ background-color: var(--border-secondary);
+}
+
+.audio-player-container audio::-webkit-media-controls-mute-button {
+ background-color: transparent;
+}
+
+.dark .audio-player-container audio::-webkit-media-controls-mute-button {
+}
+
+/* Custom audio player wrapper for better control */
+.custom-audio-wrapper {
+ position: relative;
+ background: var(--bg-audio-player);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.dark .custom-audio-wrapper {
+ border: 1px solid var(--border-secondary);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ background: var(--bg-audio-player);
+}
+
+/* Audio player title/metadata styling */
+.audio-player-title {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ font-weight: 500;
+ margin-bottom: 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.dark .audio-player-title {
+ color: var(--text-primary);
+}
+
+.audio-player-icon {
+ color: var(--text-accent);
+ font-size: 1rem;
+}
+
+/* Audio player duration/time display */
+.audio-player-time {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ margin-top: 0.5rem;
+ text-align: center;
+}
+
+.dark .audio-player-time {
+ color: var(--text-secondary);
+}
+
+/* ASR Editor Table Styles */
+.asr-editor-table {
+ width: 100%;
+ border-collapse: collapse; /* Changed to collapse */
+}
+
+.asr-editor-table th {
+ padding: 0.75rem 1rem;
+ text-align: left;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 2px solid var(--border-primary);
+}
+
+.asr-editor-table td {
+ padding: 0.25rem; /* Reduced padding */
+ vertical-align: middle;
+ border: 1px solid var(--border-primary);
+}
+
+/* Custom Number Input with Subtle Spinners */
+.custom-number-input {
+ position: relative;
+}
+
+.custom-number-input input[type="number"] {
+ -moz-appearance: textfield;
+ padding-right: 1.75rem; /* Space for spinners */
+}
+
+.custom-number-input input[type="number"]::-webkit-inner-spin-button,
+.custom-number-input input[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.number-spinners {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.custom-number-input:hover .number-spinners {
+ opacity: 1;
+}
+
+.number-spinners button {
+ height: 50%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+}
+
+.number-spinners button:hover {
+ color: var(--text-primary);
+}
+
+.number-spinners button svg {
+ width: 0.75rem;
+ height: 0.75rem;
+}
+
+/* Speaker Combobox Styles */
+.speaker-combobox {
+ position: relative;
+}
+
+.speaker-combobox-input {
+ width: 100%;
+ padding-right: 2rem; /* Space for dropdown arrow */
+}
+
+.speaker-combobox-arrow {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ color: var(--text-muted);
+ pointer-events: none;
+}
+
+.speaker-combobox-suggestions {
+ position: absolute;
+ z-index: 10;
+ width: 100%;
+ margin-top: 0.25rem;
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ max-height: 12rem;
+ overflow-y: auto;
+}
+
+.speaker-suggestion-item {
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+}
+
+.speaker-suggestion-item:hover {
+ background-color: var(--bg-accent);
+}
+
+/* Markdown Editor Styles */
+.markdown-editor-container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.markdown-editor-container .EasyMDEContainer {
+ background: var(--bg-input);
+ border: none;
+ border-radius: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.markdown-editor-container .EasyMDEContainer .CodeMirror {
+ background: var(--bg-input);
+ color: var(--text-primary);
+ border: none;
+ border-radius: 0 0 0.75rem 0.75rem;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden !important;
+}
+
+/* Fix double scrolling by ensuring only the wrapper scrolls */
+.markdown-editor-container .EasyMDEContainer .CodeMirror-scroll {
+ overflow: auto !important;
+ height: 100% !important;
+}
+
+.markdown-editor-container .EasyMDEContainer .CodeMirror-sizer {
+ min-height: 100% !important;
+}
+
+.markdown-editor-container .EasyMDEContainer .CodeMirror-focused {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--ring-focus);
+}
+
+.markdown-editor-container .EasyMDEContainer .editor-toolbar {
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-secondary);
+ border-radius: 0.75rem 0.75rem 0 0;
+ padding: 0.5rem;
+ flex-shrink: 0;
+}
+
+.markdown-editor-container .EasyMDEContainer .editor-toolbar button {
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--text-secondary);
+ border-radius: 0.25rem;
+ padding: 0.25rem;
+ margin: 0 0.125rem;
+ transition: all 0.2s ease;
+}
+
+.markdown-editor-container .EasyMDEContainer .editor-toolbar button:hover {
+ background: var(--bg-accent);
+ color: var(--text-accent);
+ border-color: var(--border-accent);
+}
+
+.markdown-editor-container .EasyMDEContainer .editor-toolbar button.active {
+ background: var(--bg-accent);
+ color: var(--text-accent);
+ border-color: var(--border-accent);
+}
+
+/* EasyMDE Fullscreen Mode Fixes */
+.EasyMDEContainer .editor-fullscreen {
+ z-index: 9999 !important;
+ /* Add top padding to ensure toolbar is visible below the app header */
+ padding-top: 0 !important; /* Reset padding - we'll handle this in the CodeMirror element */
+}
+
+/* Force hide speaker legend during any fullscreen mode - comprehensive approach */
+.editor-fullscreen .speaker-legend,
+.CodeMirror-fullscreen .speaker-legend,
+body:has(.editor-fullscreen) .speaker-legend,
+body:has(.CodeMirror-fullscreen) .speaker-legend {
+ display: none !important;
+}
+
+/* Alternative: Lower z-index approach for speaker legend during fullscreen */
+.editor-fullscreen ~ .speaker-legend,
+.CodeMirror-fullscreen ~ .speaker-legend,
+.editor-fullscreen + .speaker-legend,
+.CodeMirror-fullscreen + .speaker-legend {
+ z-index: -1 !important;
+}
+
+/* Target the specific fullscreen container that EasyMDE creates */
+.CodeMirror-fullscreen-wrapper {
+ z-index: 9999 !important;
+}
+
+/* Ensure fullscreen editor is above everything */
+.CodeMirror-fullscreen {
+ z-index: 99998 !important; /* Just below the toolbar */
+ /* Add top padding to ensure content starts below the toolbar */
+ padding-top: 20px !important; /* Increased padding to match toolbar height */
+ margin-top: 0 !important; /* Remove any margin */
+ box-sizing: border-box !important; /* Ensure padding is included in height */
+}
+
+/* Ensure the editor toolbar is visible in fullscreen mode */
+.editor-toolbar.fullscreen {
+ position: fixed !important;
+ top: 0 !important; /* Position at the very top of the screen */
+ z-index: 99999 !important; /* Extremely high z-index to be above everything */
+ background-color: var(--bg-tertiary) !important;
+ border-bottom: 1px solid var(--border-secondary) !important;
+ width: 100% !important; /* Full width */
+ padding: 5px !important; /* Reduced padding */
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; /* Add shadow for better visibility */
+ height: 60px !important; /* Further increased height to ensure buttons are fully visible */
+ display: flex !important;
+ align-items: center !important;
+ justify-content: flex-start !important;
+ box-sizing: border-box !important; /* Ensure padding is included in height */
+}
+
+/* Ensure the editor preview is properly positioned in fullscreen mode */
+.editor-preview-side.editor-preview-active-side {
+ top: 60px !important; /* Match the padding-top of the editor */
+ padding-top: 0 !important; /* Remove default padding */
+ background-color: var(--bg-input) !important; /* Match editor background */
+ margin-top: 0 !important; /* Remove any margin */
+ z-index: 99997 !important; /* Below the editor but above other elements */
+}
+
+/* Ensure the editor preview in split-screen mode is properly positioned */
+.CodeMirror-sided.CodeMirror-fullscreen {
+ padding-top: 20px !important; /* Match the padding of regular fullscreen mode */
+ width: 50% !important; /* Ensure editor takes exactly half the width */
+ background-color: var(--bg-input) !important; /* Match editor background */
+ margin-top: 0 !important; /* Remove any margin */
+ box-sizing: border-box !important; /* Ensure padding is included in height */
+}
+
+/* Fix the split-screen preview to match the editor */
+.editor-preview-active-side.editor-preview-side {
+ top: 60px !important; /* Match the editor padding */
+ padding-top: 0 !important; /* Remove default padding */
+ margin-top: 0 !important; /* Remove any margin */
+ height: calc(100% - 60px) !important; /* Adjust height to account for top position */
+ border-top: none !important; /* Remove top border */
+ background-color: var(--bg-input) !important; /* Match editor background */
+ z-index: 99997 !important; /* Below the editor but above other elements */
+ box-sizing: border-box !important; /* Ensure padding is included in height */
+}
+
+/* Hide speaker legend when any fullscreen editor is active */
+body.CodeMirror-fullscreen .speaker-legend,
+.CodeMirror-fullscreen .speaker-legend {
+ display: none !important;
+}
+
+/* Ensure the save/cancel buttons are visible in fullscreen mode */
+.markdown-editor-container .flex.justify-end.gap-2.mt-2 {
+ position: fixed !important;
+ bottom: 20px !important;
+ right: 20px !important;
+ z-index: 100000 !important; /* Above everything else */
+ background-color: var(--bg-secondary) !important;
+ padding: 10px !important;
+ border-radius: 8px !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
+ border: 1px solid var(--border-primary) !important;
+}
+
+/* Additional fixes for fullscreen mode */
+body.fullscreen-mode {
+ overflow: hidden !important; /* Prevent scrolling when in fullscreen mode */
+}
+
+/* Ensure the editor container is properly positioned in fullscreen mode */
+.CodeMirror-fullscreen-wrapper {
+ z-index: 99998 !important; /* Below the toolbar but above other elements */
+}
+
+/* Ensure the editor is properly positioned in fullscreen mode */
+.CodeMirror-fullscreen .CodeMirror-scroll {
+ padding-top: 0 !important; /* Remove default padding */
+ margin-top: 0 !important; /* Remove any margin */
+}
+
+/* Additional fixes for toolbar buttons to ensure they're fully visible */
+.editor-toolbar.fullscreen button {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ height: 40px !important; /* Increased height for better visibility */
+ width: 40px !important; /* Increased width for better visibility */
+ margin: 0 3px !important; /* Increased margin for better spacing */
+ padding: 0 !important;
+ font-size: 18px !important; /* Increased font size for better visibility */
+ line-height: 1 !important;
+ background-color: var(--bg-secondary) !important; /* Add background color for better visibility */
+ border: 1px solid var(--border-secondary) !important; /* Add border for better visibility */
+ border-radius: 4px !important; /* Add border radius for better visibility */
+}
+
+/* Add hover state for toolbar buttons */
+.editor-toolbar.fullscreen button:hover {
+ background-color: var(--bg-accent) !important;
+ color: var(--text-accent) !important;
+ transform: translateY(-1px) !important;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
+}
+
+/* Add active state for toolbar buttons */
+.editor-toolbar.fullscreen button.active {
+ background-color: var(--bg-accent) !important;
+ color: var(--text-accent) !important;
+ border-color: var(--border-accent) !important;
+}
+
+/* Ensure the editor toolbar wrapper is properly positioned */
+.editor-toolbar-wrapper {
+ position: fixed !important;
+ top: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ z-index: 99999 !important;
+}
+
+.markdown-editor-container .EasyMDEContainer .editor-preview {
+ background: var(--bg-input);
+ color: var(--text-primary);
+ padding: 1rem;
+ border-radius: 0 0 0.5rem 0.5rem;
+}
+
+.markdown-editor-container .EasyMDEContainer .editor-preview-side {
+ background: var(--bg-input);
+ color: var(--text-primary);
+ border-left: 1px solid var(--border-secondary);
+ padding-top: 0 !important; /* Remove default padding */
+ box-sizing: border-box !important; /* Ensure padding is included in height */
+}
+
+/* Dark mode adjustments for markdown editor */
+.dark .markdown-editor-container .EasyMDEContainer .CodeMirror {
+ background: var(--bg-input);
+ color: var(--text-primary);
+}
+
+/* Fix cursor/caret color in dark mode for ALL CodeMirror instances */
+.dark .CodeMirror-cursor {
+ border-left: 2px solid var(--text-primary) !important;
+}
+
+/* Also fix the caret in the actual textarea (when CodeMirror is not active) */
+.dark textarea,
+.dark input[type="text"],
+.dark input[type="search"] {
+ caret-color: var(--text-primary);
+}
+
+/* Ensure cursor is visible in all markdown editors */
+.markdown-editor-container .CodeMirror-cursor,
+.recording-notes-editor .CodeMirror-cursor {
+ border-left: 2px solid var(--text-primary) !important;
+}
+
+.dark .markdown-editor-container .EasyMDEContainer .editor-toolbar {
+ background: var(--bg-tertiary);
+ border-bottom-color: var(--border-secondary);
+}
+
+.dark .markdown-editor-container .EasyMDEContainer .editor-toolbar button {
+ color: var(--text-secondary);
+}
+
+.dark .markdown-editor-container .EasyMDEContainer .editor-toolbar button:hover,
+.dark .markdown-editor-container .EasyMDEContainer .editor-toolbar button.active {
+ background: var(--bg-accent);
+ color: var(--text-accent);
+ border-color: var(--border-accent);
+}
+
+/* Markdown preview styling in notes */
+.notes-preview h1, .notes-preview h2, .notes-preview h3,
+.notes-preview h4, .notes-preview h5, .notes-preview h6 {
+ font-weight: 600;
+ margin-top: 1rem;
+ margin-bottom: 0.5rem;
+ color: var(--text-primary);
+}
+
+.notes-preview h1 { font-size: 1.5rem; }
+.notes-preview h2 { font-size: 1.25rem; }
+.notes-preview h3 { font-size: 1.125rem; }
+
+.notes-preview p {
+ margin-bottom: 0.75rem;
+ line-height: 1.6;
+}
+
+.notes-preview ul, .notes-preview ol {
+ margin-left: 1.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.notes-preview li {
+ margin-bottom: 0.25rem;
+}
+
+.notes-preview code {
+ background-color: var(--bg-tertiary);
+ padding: 0.125rem 0.25rem;
+ border-radius: 0.25rem;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
+ font-size: 0.875rem;
+}
+
+.notes-preview pre {
+ background-color: var(--bg-tertiary);
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ overflow-x: auto;
+ margin-bottom: 0.75rem;
+}
+
+.notes-preview pre code {
+ background-color: transparent;
+ padding: 0;
+}
+
+.notes-preview blockquote {
+ border-left: 4px solid var(--border-accent);
+ padding-left: 1rem;
+ margin-left: 0;
+ margin-bottom: 0.75rem;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.notes-preview table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-bottom: 0.75rem;
+}
+
+.notes-preview th, .notes-preview td {
+ border: 1px solid var(--border-secondary);
+ padding: 0.5rem;
+ text-align: left;
+}
+
+.notes-preview th {
+ background-color: var(--bg-tertiary);
+ font-weight: 600;
+}
+
+/* Recording notes editor styles */
+.recording-notes-editor .EasyMDEContainer {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--border-secondary);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+.summary-editor-container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.recording-notes-editor .CodeMirror,
+.recording-notes-editor .editor-preview,
+.recording-notes-editor .editor-preview-side {
+ flex-grow: 1;
+ background: var(--bg-input);
+ color: var(--text-primary);
+}
+
+.recording-notes-editor .editor-toolbar {
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-secondary);
+}
+
+.recording-notes-editor .editor-toolbar button {
+ color: var(--text-secondary);
+ border: none;
+}
+
+.recording-notes-editor .editor-toolbar button:hover,
+.recording-notes-editor .editor-toolbar button.active {
+ background: var(--bg-accent);
+ color: var(--text-accent);
+ border: none;
+}
+
+.recording-notes-editor .CodeMirror-cursor {
+ border-left: 1px solid var(--text-primary);
+}
+
+/* Recording notes editor - fixed height with internal scrolling */
+.recording-notes-editor {
+ flex: 1 !important; /* Take available space in flex container */
+ display: flex !important;
+ flex-direction: column !important;
+ min-height: 200px !important; /* Minimum usable height */
+ max-height: none !important; /* Allow parent to control max height */
+ overflow: hidden !important; /* Prevent container from growing */
+}
+
+.recording-notes-editor .EasyMDEContainer {
+ height: 100% !important; /* Fill parent container */
+ display: flex !important;
+ flex-direction: column !important;
+ overflow: hidden !important;
+}
+
+.recording-notes-editor .EasyMDEContainer .CodeMirror {
+ flex: 1 !important; /* Take all available space */
+ height: auto !important; /* Let flex control height */
+ min-height: 0 !important; /* Allow flex shrinking */
+ overflow: hidden !important; /* Prevent CodeMirror from growing */
+}
+
+.recording-notes-editor .EasyMDEContainer .CodeMirror-scroll {
+ height: 100% !important; /* Fill CodeMirror container */
+ overflow-y: auto !important; /* Enable internal scrolling */
+ min-height: 0 !important; /* Allow flex shrinking */
+}
+
+.recording-notes-editor .EasyMDEContainer .CodeMirror-sizer {
+ min-height: auto !important; /* Don't force content to fill editor */
+}
+
+/* Recording notes textarea fallback - fixed height with internal scrolling */
+.recording-notes-editor textarea {
+ background-color: var(--bg-input) !important;
+ color: var(--text-primary) !important;
+ border-color: var(--border-secondary) !important;
+ flex: 1 !important; /* Take available space */
+ height: auto !important; /* Let flex control height */
+ min-height: 200px !important; /* Minimum usable height */
+ max-height: none !important; /* No max height - let parent control */
+ overflow-y: auto !important; /* Enable internal scrolling */
+ resize: none !important; /* Prevent manual resizing */
+}
+
+.recording-notes-editor textarea:focus {
+ background-color: var(--bg-input) !important;
+ border-color: var(--border-focus) !important;
+ box-shadow: 0 0 0 2px var(--ring-focus) !important;
+}
+
+/* Ensure all textareas in recording view use theme colors and fixed height */
+.recording-notes-editor textarea,
+textarea[ref="recordingNotesEditor"] {
+ background-color: var(--bg-input) !important;
+ color: var(--text-primary) !important;
+ border: 1px solid var(--border-secondary) !important;
+ flex: 1 !important; /* Take available space */
+ height: auto !important; /* Let flex control height */
+ min-height: 200px !important; /* Minimum usable height */
+ max-height: none !important; /* No max height - let parent control */
+ overflow-y: auto !important; /* Enable internal scrolling */
+ resize: none !important; /* Prevent manual resizing */
+}
+
+/* Dark mode specific adjustments for recording notes */
+.dark .recording-notes-editor textarea,
+.dark textarea[ref="recordingNotesEditor"] {
+ background-color: var(--bg-input) !important;
+ color: var(--text-primary) !important;
+ border-color: var(--border-secondary) !important;
+}
+
+/* Focus states for recording notes textarea */
+.recording-notes-editor textarea:focus,
+textarea[ref="recordingNotesEditor"]:focus {
+ outline: none !important;
+ border-color: var(--border-focus) !important;
+ box-shadow: 0 0 0 2px var(--ring-focus) !important;
+}
+
+.mobile-view {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+.mobile-header {
+ flex-shrink: 0;
+}
+
+.mobile-audio-player {
+ flex-shrink: 0;
+}
+
+.mobile-tabs {
+ flex-shrink: 0;
+ overflow-x: auto;
+}
+
+.mobile-content {
+ flex-grow: 1;
+ overflow-y: auto;
+}
+
+/* --- New Sidebar & Main Content Layout --- */
+
+/* Sidebar Container */
+.sidebar {
+ position: fixed; /* Changed to fixed to overlay correctly */
+ top: 69px; /* Height of the header */
+ left: 0;
+ bottom: 0;
+ width: 320px; /* w-80 */
+ background-color: var(--bg-secondary);
+ border-right: 1px solid var(--border-primary);
+ z-index: 50; /* Higher z-index to be above backdrop */
+ transform: translateX(0);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar.collapsed {
+ transform: translateX(-100%);
+}
+
+/* Wrapper to prevent sidebar's own content from squishing */
+.sidebar-content-wrapper {
+ width: 320px; /* Fixed width */
+ height: 100%;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Main Content Area */
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ transition: padding-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ padding-left: 320px; /* Default space for the sidebar */
+}
+
+.main-content.sidebar-open {
+ padding-left: 320px;
+}
+
+/* When sidebar is collapsed, main content takes full width */
+.main-content:not(.sidebar-open) {
+ padding-left: 0;
+}
+
+/* --- Responsive Styles --- */
+@media (max-width: 1023px) {
+ /* On mobile, main content always takes full width */
+ .main-content {
+ padding-left: 0 !important;
+ }
+
+ /* Sidebar is an overlay on mobile */
+ .sidebar {
+ top: 0; /* Cover full height on mobile */
+ z-index: 60; /* Higher z-index for mobile overlay */
+ }
+
+ /* Make buttons smaller in mobile view */
+ .mobile-action-btn,
+ .p-2.rounded-lg {
+ padding: 0.375rem !important;
+ min-width: 1.5rem !important;
+ height: 1.5rem !important;
+ font-size: 0.75rem !important;
+ }
+
+ /* Ensure icons are properly sized */
+ .mobile-action-btn i,
+ .p-2.rounded-lg i {
+ font-size: 0.875rem !important;
+ }
+}
+
+/* --- Tab Navigation Responsive Styles --- */
+@media (max-width: 640px) {
+ /* Mobile tab navigation */
+ nav.tab-nav {
+ display: flex !important;
+ flex-direction: column !important;
+ gap: 0 !important;
+ border-bottom: none !important;
+ margin-bottom: 0 !important;
+ space: 0 !important;
+ }
+
+ nav.tab-nav a,
+ nav.tab-nav button {
+ width: 100% !important;
+ text-align: left !important;
+ border-bottom: 1px solid var(--border-primary) !important;
+ border-left: 3px solid transparent !important;
+ border-right: none !important;
+ border-top: none !important;
+ padding: 0.75rem 1rem !important;
+ margin-bottom: 0 !important;
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ white-space: nowrap !important;
+ }
+
+ nav.tab-nav a.border-\\[var\\(--border-accent\\)\\],
+ nav.tab-nav button:has(.border-\\[var\\(--border-focus\\)\\]) {
+ border-left: 3px solid var(--border-accent) !important;
+ background-color: var(--bg-tertiary) !important;
+ }
+
+ /* Fix spacing between elements */
+ .tab-nav > * + * {
+ margin-left: 0 !important;
+ }
+}
+
+/* Custom Checkbox Styling for Speaker Modal */
+input[type="checkbox"].speaker-checkbox {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 1.125rem;
+ height: 1.125rem;
+ border: 2px solid var(--border-secondary);
+ border-radius: 0.25rem;
+ background-color: var(--bg-input);
+ cursor: pointer;
+ position: relative;
+ transition: all 0.15s ease;
+ flex-shrink: 0;
+}
+
+input[type="checkbox"].speaker-checkbox:hover {
+ border-color: var(--text-accent);
+ background-color: var(--bg-secondary);
+}
+
+input[type="checkbox"].speaker-checkbox:focus {
+ outline: none;
+ border-color: var(--text-accent);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+input[type="checkbox"].speaker-checkbox:checked {
+ background-color: var(--text-accent);
+ border-color: var(--text-accent);
+}
+
+input[type="checkbox"].speaker-checkbox:checked::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 0.25rem;
+ height: 0.5rem;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: translate(-50%, -60%) rotate(45deg);
+}
+
+input[type="checkbox"].speaker-checkbox:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Dark mode adjustments */
+:root[data-theme="dark"] input[type="checkbox"].speaker-checkbox {
+ border-color: rgba(255, 255, 255, 0.2);
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+:root[data-theme="dark"] input[type="checkbox"].speaker-checkbox:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+:root[data-theme="dark"] input[type="checkbox"].speaker-checkbox:checked {
+ background-color: var(--text-accent);
+ border-color: var(--text-accent);
+}
+
+/* Custom Select Dropdown Styling */
+select {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%236b7280' d='M4.5 5.5L8 9l3.5-3.5L10.75 4.75 8 7.5 5.25 4.75z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.5rem center;
+ background-size: 1.5em 1.5em;
+ padding-right: 2.5rem;
+ cursor: pointer;
+ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, background-color 0.15s ease-in-out;
+}
+
+/* Dark mode select styling */
+.dark select {
+ background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%23cbd5e1' d='M4.5 5.5L8 9l3.5-3.5L10.75 4.75 8 7.5 5.25 4.75z'/%3E%3C/svg%3E");
+ background-color: var(--bg-input);
+ border-color: var(--border-secondary);
+}
+
+/* Hover and focus states for select */
+select:hover {
+ border-color: var(--border-accent);
+}
+
+select:focus {
+ outline: none;
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.dark select:focus {
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
+}
+
+/* Disabled select styling */
+select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background-color: var(--bg-tertiary);
+}
+
+/* Input and textarea improvements for dark mode */
+.dark input[type="text"],
+.dark input[type="email"],
+.dark input[type="password"],
+.dark input[type="date"],
+.dark input[type="number"],
+.dark textarea {
+ background-color: var(--bg-input);
+ border-color: var(--border-secondary);
+ color: var(--text-primary);
+}
+
+.dark input[type="text"]:focus,
+.dark input[type="email"]:focus,
+.dark input[type="password"]:focus,
+.dark input[type="date"]:focus,
+.dark input[type="number"]:focus,
+.dark textarea:focus {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
+}
+
+/* Placeholder text improvements */
+.dark input::placeholder,
+.dark textarea::placeholder {
+ color: var(--text-light);
+ opacity: 0.7;
+}
+
+/* Description text improvements */
+.dark .text-xs.text-gray-500,
+.dark .text-xs.text-gray-400,
+.dark .text-sm.text-gray-500,
+.dark .text-sm.text-gray-400 {
+ color: var(--text-muted) !important;
+}
+
+/* Label text improvements */
+.dark label {
+ color: var(--text-secondary);
+}
diff --git a/speakr/static/img/dark-mode.png b/speakr/static/img/dark-mode.png
new file mode 100644
index 0000000..423c600
Binary files /dev/null and b/speakr/static/img/dark-mode.png differ
diff --git a/speakr/static/img/edit_transcription.png b/speakr/static/img/edit_transcription.png
new file mode 100644
index 0000000..0c28936
Binary files /dev/null and b/speakr/static/img/edit_transcription.png differ
diff --git a/speakr/static/img/favicon.ico b/speakr/static/img/favicon.ico
new file mode 100644
index 0000000..edf51c5
Binary files /dev/null and b/speakr/static/img/favicon.ico differ
diff --git a/speakr/static/img/icon-16x16.png b/speakr/static/img/icon-16x16.png
new file mode 100644
index 0000000..316c938
Binary files /dev/null and b/speakr/static/img/icon-16x16.png differ
diff --git a/speakr/static/img/icon-180x180.png b/speakr/static/img/icon-180x180.png
new file mode 100644
index 0000000..9db1844
Binary files /dev/null and b/speakr/static/img/icon-180x180.png differ
diff --git a/speakr/static/img/icon-192x192.png b/speakr/static/img/icon-192x192.png
new file mode 100644
index 0000000..9069af8
Binary files /dev/null and b/speakr/static/img/icon-192x192.png differ
diff --git a/speakr/static/img/icon-32x32.png b/speakr/static/img/icon-32x32.png
new file mode 100644
index 0000000..bacff1f
Binary files /dev/null and b/speakr/static/img/icon-32x32.png differ
diff --git a/speakr/static/img/icon-512x512.png b/speakr/static/img/icon-512x512.png
new file mode 100644
index 0000000..7def29e
Binary files /dev/null and b/speakr/static/img/icon-512x512.png differ
diff --git a/speakr/static/img/icon-maskable-512x512.png b/speakr/static/img/icon-maskable-512x512.png
new file mode 100644
index 0000000..d867c12
Binary files /dev/null and b/speakr/static/img/icon-maskable-512x512.png differ
diff --git a/speakr/static/img/intuitive-speaker-identification.png b/speakr/static/img/intuitive-speaker-identification.png
new file mode 100644
index 0000000..b73df08
Binary files /dev/null and b/speakr/static/img/intuitive-speaker-identification.png differ
diff --git a/speakr/static/img/light-mode.png b/speakr/static/img/light-mode.png
new file mode 100644
index 0000000..2e4eea9
Binary files /dev/null and b/speakr/static/img/light-mode.png differ
diff --git a/speakr/static/img/main.png b/speakr/static/img/main.png
new file mode 100644
index 0000000..5e43eb0
Binary files /dev/null and b/speakr/static/img/main.png differ
diff --git a/speakr/static/img/main2.png b/speakr/static/img/main2.png
new file mode 100644
index 0000000..5b863a2
Binary files /dev/null and b/speakr/static/img/main2.png differ
diff --git a/speakr/static/img/manual-auto-speaker-identification.png b/speakr/static/img/manual-auto-speaker-identification.png
new file mode 100644
index 0000000..82fff9d
Binary files /dev/null and b/speakr/static/img/manual-auto-speaker-identification.png differ
diff --git a/speakr/static/img/multilingual-support.png b/speakr/static/img/multilingual-support.png
new file mode 100644
index 0000000..c89443d
Binary files /dev/null and b/speakr/static/img/multilingual-support.png differ
diff --git a/speakr/static/img/rec1.png b/speakr/static/img/rec1.png
new file mode 100644
index 0000000..5631e63
Binary files /dev/null and b/speakr/static/img/rec1.png differ
diff --git a/speakr/static/img/rec2.png b/speakr/static/img/rec2.png
new file mode 100644
index 0000000..ec77bc3
Binary files /dev/null and b/speakr/static/img/rec2.png differ
diff --git a/speakr/static/img/rec3.png b/speakr/static/img/rec3.png
new file mode 100644
index 0000000..621ad55
Binary files /dev/null and b/speakr/static/img/rec3.png differ
diff --git a/speakr/static/img/simple-transcription-view.png b/speakr/static/img/simple-transcription-view.png
new file mode 100644
index 0000000..7e9acde
Binary files /dev/null and b/speakr/static/img/simple-transcription-view.png differ
diff --git a/speakr/static/img/speaker-suggestions.png b/speakr/static/img/speaker-suggestions.png
new file mode 100644
index 0000000..7f50c3a
Binary files /dev/null and b/speakr/static/img/speaker-suggestions.png differ
diff --git a/speakr/static/img/tags.png b/speakr/static/img/tags.png
new file mode 100644
index 0000000..a8fedcf
Binary files /dev/null and b/speakr/static/img/tags.png differ
diff --git a/speakr/static/img/transcription-bubble-view.png b/speakr/static/img/transcription-bubble-view.png
new file mode 100644
index 0000000..7d5efad
Binary files /dev/null and b/speakr/static/img/transcription-bubble-view.png differ
diff --git a/speakr/static/img/transcription-chat-bubble-view.png b/speakr/static/img/transcription-chat-bubble-view.png
new file mode 100644
index 0000000..16dbb6c
Binary files /dev/null and b/speakr/static/img/transcription-chat-bubble-view.png differ
diff --git a/speakr/static/js/app copy.js b/speakr/static/js/app copy.js
new file mode 100644
index 0000000..182f1f6
--- /dev/null
+++ b/speakr/static/js/app copy.js
@@ -0,0 +1,9183 @@
+const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
+// let isfilemode = false;
+
+// Wait for the DOM to be fully loaded before mounting the Vue app
+//------------------- 页面启动与 Vue 初始化 start------------------
+document.addEventListener("DOMContentLoaded", async () => {
+ // Initialize i18n before creating Vue app
+ if (window.i18n) {
+ const appElement = document.getElementById("app");
+ const userLang =
+ appElement?.dataset.userLanguage ||
+ localStorage.getItem("preferredLanguage") ||
+ "en";
+ await window.i18n.init(userLang);
+ console.log("i18n initialized with language:", userLang);
+ }
+
+ // CSRF Token Integration with Vue.js
+ const csrfToken = ref(
+ document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
+ );
+
+ // Register Service Worker
+ if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker
+ .register("/tool/speakr/static/sw.js")
+ .then((registration) => {
+ console.log(
+ "ServiceWorker registration successful with scope: ",
+ registration.scope
+ );
+ })
+ .catch((error) => {
+ console.log("ServiceWorker registration failed: ", error);
+ });
+ });
+ }
+
+ // Create a safe t function that's always available
+ const safeT = (key, params = {}) => {
+ if (!window.i18n || !window.i18n.t) {
+ return key; // Return key as fallback without warning during initial render
+ }
+ return window.i18n.t(key, params);
+ };
+ let webReader = null;
+ let webReader2 = null;
+ // ASR Recorder 实例(提升到外层以便跨录音周期清理)
+ let rec = null;
+ let rec2 = null;
+ // openMixedStream 创建的 AudioContext(需跨周期清理)
+ let asrAudioContext = null;
+ let asrAudioContext2 = null;
+ // 用于标记是否需要重置转录状态(暂停/恢复时使用)
+ let resetTranscriptionFlag = false;
+ let pendingCorrectionCount = 0;
+ //--------------------页面启动与 Vue 初始化 end------------------
+
+ //------------------- Vue 应用主体定义 start------------------
+ const app = createApp({
+ setup() {
+ //------------------- 响应式状态与实时转写基础状态 start------------------
+ // --- Core State ---
+ // 新增说话人占位
+ let segmentList = [];
+ let lastSpeaker = "";
+ let configwords = "";
+ const currentView = ref("upload"); // 'upload' or 'recording'
+ const dragover = ref(false);
+ const recordings = ref([]);
+ const selectedRecording = ref(null);
+ const selectedTab = ref("summary"); // 'summary' or 'notes'
+ const searchQuery = ref("");
+ const isLoadingRecordings = ref(true);
+ const globalError = ref(null);
+
+ // Advanced filter state
+ const showAdvancedFilters = ref(false);
+ const filterTags = ref([]); // Selected tag IDs for filtering
+ const filterDateRange = ref({ start: "", end: "" });
+ const filterDatePreset = ref(""); // 'today', 'yesterday', 'week', 'month', etc.
+ const filterTextQuery = ref("");
+
+ // --- Pagination State ---
+ const currentPage = ref(1);
+ const perPage = ref(25);
+ const totalRecordings = ref(0);
+ const totalPages = ref(0);
+ const hasNextPage = ref(false);
+ const hasPrevPage = ref(false);
+ const isLoadingMore = ref(false);
+ const searchDebounceTimer = ref(null);
+
+ // --- Enhanced Search & Organization State ---
+ const sortBy = ref("created_at"); // 'created_at' or 'meeting_date'
+ const selectedTagFilter = ref(null); // For filtering by clicked tag
+
+ // --- UI State ---
+ const browser = ref("unknown");
+ const isSidebarCollapsed = ref(false);
+ const searchTipsExpanded = ref(false);
+ const isUserMenuOpen = ref(false);
+ const isDarkMode = ref(false);
+ const currentColorScheme = ref("blue");
+ const showColorSchemeModal = ref(false);
+ const windowWidth = ref(window.innerWidth);
+ const mobileTab = ref("transcript");
+ const isMetadataExpanded = ref(false);
+
+ // --- i18n State ---
+ const currentLanguage = ref("en");
+ const currentLanguageName = ref("English");
+ const availableLanguages = ref([]);
+ const showLanguageMenu = ref(false);
+
+ // --- Upload State ---
+ const uploadQueue = ref([]);
+ const currentlyProcessingFile = ref(null);
+ const processingProgress = ref(0);
+ const processingMessage = ref("");
+ const isProcessingActive = ref(false);
+ const pollInterval = ref(null);
+ const progressPopupMinimized = ref(false);
+ const progressPopupClosed = ref(false);
+ const maxFileSizeMB = ref(250); // Default value, will be updated from API
+ const chunkingEnabled = ref(true); // Default value, will be updated from API
+ const chunkingMode = ref("size"); // 'size' or 'duration', will be updated from API
+ const chunkingLimit = ref(20); // Value in MB or seconds, will be updated from API
+ const chunkingLimitDisplay = ref("20MB"); // Human readable display, will be updated from API
+ const recordingDisclaimer = ref(""); // Recording disclaimer text from admin settings
+ const showRecordingDisclaimerModal = ref(false); // Controls disclaimer modal visibility
+ const pendingRecordingMode = ref(null); // Stores the recording mode while showing disclaimer
+
+ // --- Audio Recording State ---
+ const editingDraftId = ref(null);
+ const editingDraftName = ref('');
+ const draftNameInput = ref(null);
+
+ const isRecording = ref(false);
+ const isStoppingRecording = ref(false);
+ const mediaRecorder = ref(null);
+ const audioChunks = ref([]);
+ const audioBlobURL = ref(null);
+ const recordingTime = ref(0);
+ const recordingInterval = ref(null);
+ const canRecordAudio = ref(
+ navigator.mediaDevices && navigator.mediaDevices.getUserMedia
+ );
+ const canRecordSystemAudio = ref(false);
+ const systemAudioSupported = ref(false);
+ const systemAudioError = ref("");
+ const recordingNotes = ref("");
+ const showSystemAudioHelp = ref(false);
+ // ASR options for recording view
+ const asrLanguage = ref(""); // Empty string for auto-detect
+ const asrMinSpeakers = ref(""); // Empty string for auto-detect
+ const asrMaxSpeakers = ref(""); // Empty string for auto-detect
+ const audioContext = ref(null);
+ const analyser = ref(null);
+ const micAnalyser = ref(null);
+ const systemAnalyser = ref(null);
+ const visualizer = ref(null);
+ const micVisualizer = ref(null);
+ const systemVisualizer = ref(null);
+ const animationFrameId = ref(null);
+ const recordingMode = ref("microphone"); // 'microphone', 'system', or 'both'
+ const activeStreams = ref([]); // Track active streams for cleanup
+
+ // --- Recording Size Monitoring ---
+ const estimatedFileSize = ref(0);
+ const fileSizeWarningShown = ref(false);
+ const recordingQuality = ref("optimized"); // 'optimized', 'standard', 'high'
+ const actualBitrate = ref(0);
+ const maxRecordingMB = ref(200); // Maximum recording size before auto-stop
+ const sizeCheckInterval = ref(null);
+
+ // --- Draft/Pause Recording State ---
+ const isPaused = ref(false);
+ const currentDraftId = ref(null);
+ const pausedDuration = ref(0); // 暂停前的累计时长
+ const pausedTranscription = ref(''); // 暂停前的转录文本
+ const draftRecordings = ref([]); // 草稿列表
+ const draftsExpanded = ref(true); // 草稿列表是否展开
+
+ // Advanced Options for ASR
+ const showAdvancedOptions = ref(false);
+ const uploadLanguage = ref(""); // Empty string for auto-detect
+ const uploadMinSpeakers = ref(""); // Empty string for auto-detect
+ const uploadMaxSpeakers = ref(""); // Empty string for auto-detect
+
+ // Tag Selection
+ const availableTags = ref([]);
+ const selectedTagIds = ref([]); // Changed to array for multiple selection
+ const uploadTagSearchFilter = ref(""); // For filtering tags in upload view
+ const selectedTags = computed(() => {
+ return selectedTagIds.value
+ .map((tagId) => availableTags.value.find((tag) => tag.id == tagId))
+ .filter(Boolean); // Filter out undefined tags
+ });
+ // 自动判断是否为 Markdown
+ const isMarkdown = computed(() => {
+ const v = analysisResult.value;
+ return (
+ v.includes("#") ||
+ v.includes("**") ||
+ v.includes("```") ||
+ v.includes("* ") ||
+ v.includes("- ")
+ );
+ });
+
+ // 解析 Markdown → HTML
+ const renderedMarkdown = Vue.computed(() => {
+ return window.marked.parse(analysisResult.value || "", {
+ breaks: true,
+ });
+ });
+ // Computed property for filtered available tags in upload view
+ const filteredAvailableTagsForUpload = computed(() => {
+ const availableForSelection = availableTags.value.filter(
+ (tag) => !selectedTagIds.value.includes(tag.id)
+ );
+ if (!uploadTagSearchFilter.value) return availableForSelection;
+
+ const filter = uploadTagSearchFilter.value.toLowerCase();
+ return availableForSelection.filter((tag) =>
+ tag.name.toLowerCase().includes(filter)
+ );
+ });
+
+ // --- Modal State ---
+ const showEditModal = ref(false);
+ const showDeleteModal = ref(false);
+ const showEditTagsModal = ref(false);
+ const selectedNewTagId = ref("");
+ const tagSearchFilter = ref(""); // For filtering tags in the modal
+ const showReprocessModal = ref(false);
+ const showResetModal = ref(false);
+ const showSpeakerModal = ref(false);
+ const showShareModal = ref(false);
+ const showSharesListModal = ref(false);
+ const showTextEditorModal = ref(false);
+ const showAsrEditorModal = ref(false);
+ const editingRecording = ref(null);
+ const editingTranscriptionContent = ref("");
+ const editingSegments = ref([]);
+ const availableSpeakers = ref([]);
+ const recordingToShare = ref(null);
+ const shareOptions = reactive({
+ share_summary: true,
+ share_notes: true,
+ });
+ const generatedShareLink = ref("");
+ const existingShareDetected = ref(false);
+ const userShares = ref([]);
+ const isLoadingShares = ref(false);
+ const shareToDelete = ref(null);
+ const showShareDeleteModal = ref(false);
+ const recordingToDelete = ref(null);
+ const recordingToReset = ref(null);
+ const reprocessType = ref(null); // 'transcription' or 'summary'
+ const reprocessRecording = ref(null);
+ const isAutoIdentifying = ref(false);
+ const asrReprocessOptions = reactive({
+ language: "",
+ min_speakers: null,
+ max_speakers: null,
+ });
+ const speakerMap = ref({});
+ const regenerateSummaryAfterSpeakerUpdate = ref(true);
+ const speakerSuggestions = ref({});
+ const loadingSuggestions = ref({});
+ const activeSpeakerInput = ref(null);
+
+ // --- Inline Editing State ---
+ const editingParticipants = ref(false);
+ const editingMeetingDate = ref(false);
+ const editingSummary = ref(false);
+ const editingNotes = ref(false);
+ const tempNotesContent = ref("");
+ const tempSummaryContent = ref("");
+ const autoSaveTimer = ref(null);
+ const autoSaveDelay = 2000; // 2 seconds debounce
+
+ // --- Markdown Editor State ---
+ const notesMarkdownEditor = ref(null);
+ const markdownEditorInstance = ref(null);
+ const summaryMarkdownEditor = ref(null);
+ const summaryMarkdownEditorInstance = ref(null);
+ const recordingNotesEditor = ref(null);
+ const recordingMarkdownEditorInstance = ref(null);
+
+ // --- Transcription State ---
+ const transcriptionViewMode = ref("simple"); // 'simple' or 'bubble'
+ const legendExpanded = ref(false);
+ const highlightedSpeaker = ref(null);
+
+ // --- Chat State ---
+ const showChat = ref(false);
+ const isChatMaximized = ref(false);
+ const chatMessages = ref([]);
+ const chatInput = ref("");
+ const isChatLoading = ref(false);
+ const chatMessagesRef = ref(null);
+
+ // --- Audio Player State ---
+ const playerVolume = ref(1.0);
+
+ // --- Column Resizing State ---
+ const leftColumnWidth = ref(60); // 60% for left column (transcript)
+ const rightColumnWidth = ref(40); // 40% for right column (summary/chat)
+ const isResizing = ref(false);
+
+ // --- App Configuration ---
+ const useAsrEndpoint = ref(false);
+ const currentUserName = ref("");
+ const asrResult = ref("");
+ const asrResultOnline = ref("");
+ const asrResultOffline = ref("");
+ const asrResultTextarea = ref("");
+ const asrResultTextareaOnline = ref("");
+ const sumupResultTextarea = ref("");
+ const baseInfo = ref({ name: "领导发言稿", fontSize: 40 });
+ const isModalOpen = ref(false);
+
+ const asrTextarea = ref(null);
+ const analysisResult = ref("");
+ const isDialogVisible = ref(false);
+ const isyjDialogVisible = ref(false);
+ const lastTimer = ref(0);
+ const urlmsg = ref("获取链接中...");
+ const urlinfo = ref("");
+ const zjloding = ref(false);
+ const showSpeaker = ref(true);
+
+ // --- 转录文本编辑状态 ---
+ const isEditingTranscription = ref(false);
+ const editableText = ref("");
+ const fullRealtimeTranscription = computed(() => {
+ return (
+ (asrResult.value || "") +
+ (asrResultOffline.value || "") +
+ (asrResultOnline.value || "")
+ );
+ });
+ let segmentRenderStartIndex = 0;
+ let liveTranscriptionEpoch = 0;
+ let realtimeTimestampBarrierMs = -1;
+ let realtimeEditCarryover = null;
+ let clearRealtimePipelineRef = null;
+ let getLatestRealtimeTimestampRef = null;
+ let buildRealtimeEditCarryoverRef = null;
+ let renderFullTextRef = null;
+ let editModeBufferStartIndex = 0;
+
+ function startNewRealtimeEpoch({
+ barrierTimestampMs = null,
+ clearTimestampBarrier = false,
+ clearEditCarryover = true,
+ } = {}) {
+ liveTranscriptionEpoch += 1;
+
+ if (clearEditCarryover) {
+ realtimeEditCarryover = null;
+ }
+
+ if (clearTimestampBarrier) {
+ realtimeTimestampBarrierMs = -1;
+ } else if (Number.isFinite(barrierTimestampMs)) {
+ realtimeTimestampBarrierMs = Math.max(
+ realtimeTimestampBarrierMs,
+ barrierTimestampMs
+ );
+ }
+
+ if (clearRealtimePipelineRef) {
+ clearRealtimePipelineRef({
+ preserveTimestampBarrier: !clearTimestampBarrier,
+ });
+ }
+ }
+
+ //--------------------响应式状态与实时转写基础状态 end------------------
+
+ //------------------- 计算属性与派生展示数据 start------------------
+ // --- Computed Properties ---
+ const isMobileScreen = computed(() => {
+ return windowWidth.value < 1024;
+ });
+
+ const datePresetOptions = computed(() => {
+ return [
+ { value: "today", label: t("sidebar.today") },
+ { value: "yesterday", label: t("sidebar.yesterday") },
+ { value: "thisweek", label: t("sidebar.thisWeek") },
+ { value: "lastweek", label: t("sidebar.lastWeek") },
+ { value: "thismonth", label: t("sidebar.thisMonth") },
+ { value: "lastmonth", label: t("sidebar.lastMonth") },
+ ];
+ });
+
+ const languageOptions = computed(() => {
+ return [
+ { value: "", label: t("form.autoDetect") },
+ { value: "en", label: t("languages.en") },
+ { value: "es", label: t("languages.es") },
+ { value: "fr", label: t("languages.fr") },
+ { value: "de", label: t("languages.de") },
+ { value: "it", label: t("languages.it") },
+ { value: "pt", label: t("languages.pt") },
+ { value: "nl", label: t("languages.nl") },
+ { value: "ru", label: t("languages.ru") },
+ { value: "zh", label: t("languages.zh") },
+ { value: "ja", label: t("languages.ja") },
+ { value: "ko", label: t("languages.ko") },
+ ];
+ });
+
+ const filteredRecordings = computed(() => {
+ return recordings.value;
+ });
+
+ const highlightedTranscript = computed(() => {
+ if (!selectedRecording.value?.transcription) return "";
+ let html = selectedRecording.value.transcription;
+ // Escape HTML to prevent injection, but keep it minimal
+ html = html.replace(//g, ">");
+
+ // 1. Replace newlines with tags for proper line breaks in HTML
+ html = html.replace(/\n/g, " ");
+
+ // 2. Get speaker colors from the speakerMap if available
+ const speakerColors = {};
+ if (speakerMap.value) {
+ Object.keys(speakerMap.value).forEach((speaker, index) => {
+ speakerColors[speaker] =
+ speakerMap.value[speaker].color ||
+ `speaker-color-${(index % 8) + 1}`;
+ });
+ }
+
+ // 3. Wrap each speaker tag in a span for styling and interaction with colors
+ html = html.replace(/\[([^\]]+)\]/g, (match, speakerId) => {
+ const isHighlighted = speakerId === highlightedSpeaker.value;
+ const colorClass = speakerColors[speakerId] || "";
+ // Replace speaker ID with name if available
+ const displayName = speakerMap.value[speakerId]?.name || speakerId;
+ const displayText = `[${displayName}]`;
+ // Use a more specific and stylish class structure with color
+ return `${displayText} `;
+ });
+
+ return html;
+ });
+
+ const activeRecordingMetadata = computed(() => {
+ if (!selectedRecording.value) return [];
+
+ const recording = selectedRecording.value;
+ const metadata = [];
+
+ if (recording.created_at) {
+ metadata.push({
+ icon: "fas fa-history",
+ text: formatDisplayDate(recording.created_at),
+ });
+ }
+
+ if (recording.file_size) {
+ metadata.push({
+ icon: "fas fa-file-audio",
+ text: formatFileSize(recording.file_size),
+ });
+ }
+
+ if (recording.duration) {
+ metadata.push({
+ icon: "fas fa-clock",
+ text: formatDuration(recording.duration),
+ });
+ }
+
+ if (recording.original_filename) {
+ const maxLength = 30;
+ const truncated =
+ recording.original_filename.length > maxLength
+ ? recording.original_filename.substring(0, maxLength) + "..."
+ : recording.original_filename;
+ metadata.push({
+ icon: "fas fa-file",
+ text: truncated,
+ fullText: recording.original_filename,
+ });
+ }
+
+ // Add tags to metadata
+ if (recording.tags && recording.tags.length > 0) {
+ metadata.push({
+ icon: "fas fa-tags",
+ text: "", // Empty text since we'll render tags specially
+ tags: recording.tags, // Pass the tags array
+ isTagItem: true, // Flag to identify this as a tag item
+ });
+ }
+
+ return metadata;
+ });
+
+ const groupedRecordings = computed(() => {
+ // Sort recordings based on the selected sort criteria
+ const sortedRecordings = [...filteredRecordings.value].sort((a, b) => {
+ const dateA = getDateForSorting(a);
+ const dateB = getDateForSorting(b);
+
+ // Handle null dates (put them at the end)
+ if (!dateA && !dateB) return 0;
+ if (!dateA) return 1;
+ if (!dateB) return -1;
+
+ return dateB - dateA; // Most recent first
+ });
+
+ const groups = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ lastWeek: [],
+ thisMonth: [],
+ lastMonth: [],
+ older: [],
+ };
+
+ sortedRecordings.forEach((recording) => {
+ const date = getDateForSorting(recording);
+ if (!date) {
+ groups.older.push(recording);
+ return;
+ }
+
+ if (isToday(date)) {
+ groups.today.push(recording);
+ } else if (isYesterday(date)) {
+ groups.yesterday.push(recording);
+ } else if (isThisWeek(date)) {
+ groups.thisWeek.push(recording);
+ } else if (isLastWeek(date)) {
+ groups.lastWeek.push(recording);
+ } else if (isThisMonth(date)) {
+ groups.thisMonth.push(recording);
+ } else if (isLastMonth(date)) {
+ groups.lastMonth.push(recording);
+ } else {
+ groups.older.push(recording);
+ }
+ });
+
+ return [
+ { title: t("sidebar.today"), items: groups.today },
+ { title: t("sidebar.yesterday"), items: groups.yesterday },
+ { title: t("sidebar.thisWeek"), items: groups.thisWeek },
+ { title: t("sidebar.lastWeek"), items: groups.lastWeek },
+ { title: t("sidebar.thisMonth"), items: groups.thisMonth },
+ { title: t("sidebar.lastMonth"), items: groups.lastMonth },
+ { title: t("sidebar.older"), items: groups.older },
+ ].filter((g) => g.items.length > 0);
+ });
+
+ const totalInQueue = computed(() => uploadQueue.value.length);
+ const completedInQueue = computed(
+ () =>
+ uploadQueue.value.filter(
+ (item) => item.status === "completed" || item.status === "failed"
+ ).length
+ );
+ const finishedFilesInQueue = computed(() =>
+ uploadQueue.value.filter((item) =>
+ ["completed", "failed"].includes(item.status)
+ )
+ );
+ const showRecordingControls = computed(() => {
+ if (currentView.value !== "recording") {
+ return false;
+ }
+
+ const hasCompletedRecording =
+ !isRecording.value && !isPaused.value && !!audioBlobURL.value;
+
+ if (hasCompletedRecording) {
+ return false;
+ }
+
+ if (isRecording.value || isPaused.value || isStoppingRecording.value) {
+ return true;
+ }
+
+ return (
+ !audioBlobURL.value &&
+ (activeStreams.value.length > 0 ||
+ recordingTime.value > 0 ||
+ !!currentDraftId.value)
+ );
+ });
+
+ const clearCompletedUploads = () => {
+ uploadQueue.value = uploadQueue.value.filter(
+ (item) => !["completed", "failed"].includes(item.status)
+ );
+ };
+
+ const identifiedSpeakers = computed(() => {
+ // Ensure we have a valid recording and transcription
+ if (!selectedRecording.value?.transcription) {
+ return [];
+ }
+
+ const transcription = selectedRecording.value.transcription;
+ let transcriptionData;
+
+ try {
+ transcriptionData = JSON.parse(transcription);
+ } catch (e) {
+ transcriptionData = null;
+ }
+
+ // Updated to handle new simplified JSON format (array of segments)
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ // JSON format - extract speakers in order of appearance
+ const speakersInOrder = [];
+ const seenSpeakers = new Set();
+ transcriptionData.forEach((segment) => {
+ if (
+ segment.speaker &&
+ String(segment.speaker).trim() &&
+ !seenSpeakers.has(segment.speaker)
+ ) {
+ seenSpeakers.add(segment.speaker);
+ speakersInOrder.push(segment.speaker);
+ }
+ });
+ return speakersInOrder; // Keep order of appearance, don't sort
+ } else if (typeof transcription === "string") {
+ // Plain text format - find speakers in order of appearance
+ const speakerRegex = /\[([^\]]+)\]:/g;
+ const speakersInOrder = [];
+ const seenSpeakers = new Set();
+ let match;
+ while ((match = speakerRegex.exec(transcription)) !== null) {
+ const speaker = match[1].trim();
+ if (speaker && !seenSpeakers.has(speaker)) {
+ seenSpeakers.add(speaker);
+ speakersInOrder.push(speaker);
+ }
+ }
+ return speakersInOrder; // Keep order of appearance, don't sort
+ }
+ return [];
+ });
+
+ // identifiedSpeakersInOrder is now just an alias since identifiedSpeakers already preserves order
+ const identifiedSpeakersInOrder = computed(() => {
+ return identifiedSpeakers.value;
+ });
+
+ const hasSpeakerNames = computed(() => {
+ // Check if any speaker has a non-empty name
+ return Object.values(speakerMap.value).some(
+ (speakerData) => speakerData.name && speakerData.name.trim() !== ""
+ );
+ });
+
+ const processedTranscription = computed(() => {
+ if (!selectedRecording.value?.transcription) {
+ return {
+ hasDialogue: false,
+ content: "",
+ speakers: [],
+ simpleSegments: [],
+ bubbleRows: [],
+ };
+ }
+
+ const transcription = selectedRecording.value.transcription;
+ let transcriptionData;
+
+ try {
+ transcriptionData = JSON.parse(transcription);
+ } catch (e) {
+ transcriptionData = null;
+ }
+
+ // Handle new simplified JSON format (array of segments)
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ const wasDiarized = transcriptionData.some(
+ (segment) => segment.speaker
+ );
+
+ if (!wasDiarized) {
+ const segments = transcriptionData.map((segment) => ({
+ sentence: segment.sentence,
+ startTime: segment.start_time,
+ }));
+ return {
+ hasDialogue: false,
+ isJson: true,
+ content: segments.map((s) => s.sentence).join("\n"),
+ simpleSegments: segments,
+ speakers: [],
+ bubbleRows: [],
+ };
+ }
+
+ // Extract unique speakers
+ const speakers = [
+ ...new Set(
+ transcriptionData
+ .map((segment) => segment.speaker)
+ .filter(Boolean)
+ ),
+ ];
+ const speakerColors = {};
+ speakers.forEach((speaker, index) => {
+ speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
+ });
+
+ const simpleSegments = transcriptionData.map((segment) => ({
+ speakerId: segment.speaker,
+ speaker: speakerMap.value[segment.speaker]?.name || segment.speaker,
+ sentence: segment.sentence,
+ startTime: segment.start_time || segment.startTime,
+ endTime: segment.end_time || segment.endTime,
+ color: speakerColors[segment.speaker] || "speaker-color-1",
+ }));
+
+ const processedSimpleSegments = [];
+ let lastSpeakerId = null;
+ simpleSegments.forEach((segment) => {
+ processedSimpleSegments.push({
+ ...segment,
+ showSpeaker: segment.speakerId !== lastSpeakerId,
+ });
+ lastSpeakerId = segment.speakerId;
+ });
+
+ const bubbleRows = [];
+ let lastBubbleSpeakerId = null;
+ simpleSegments.forEach((segment) => {
+ if (
+ bubbleRows.length === 0 ||
+ segment.speakerId !== lastBubbleSpeakerId
+ ) {
+ bubbleRows.push({
+ speaker: segment.speaker,
+ color: segment.color,
+ isMe:
+ segment.speaker &&
+ typeof segment.speaker === "string" &&
+ segment.speaker.toLowerCase().includes("me"),
+ bubbles: [],
+ });
+ lastBubbleSpeakerId = segment.speakerId;
+ }
+ bubbleRows[bubbleRows.length - 1].bubbles.push({
+ sentence: segment.sentence,
+ startTime: segment.startTime || segment.start_time,
+ color: segment.color,
+ });
+ });
+
+ return {
+ hasDialogue: true,
+ isJson: true,
+ segments: simpleSegments,
+ simpleSegments: processedSimpleSegments,
+ bubbleRows: bubbleRows,
+ speakers: speakers.map((speaker) => ({
+ name: speakerMap.value[speaker]?.name || speaker,
+ color: speakerColors[speaker],
+ })),
+ };
+ } else {
+ // Fallback for plain text transcription
+ const speakerRegex = /\[([^\]]+)\]:\s*/g;
+ const hasDialogue = speakerRegex.test(transcription);
+
+ if (!hasDialogue) {
+ return {
+ hasDialogue: false,
+ isJson: false,
+ content: transcription,
+ speakers: [],
+ simpleSegments: [],
+ bubbleRows: [],
+ };
+ }
+
+ speakerRegex.lastIndex = 0;
+ const speakers = new Set();
+ let match;
+ while ((match = speakerRegex.exec(transcription)) !== null) {
+ speakers.add(match[1]);
+ }
+
+ const speakerList = Array.from(speakers);
+ const speakerColors = {};
+ speakerList.forEach((speaker, index) => {
+ speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
+ });
+
+ const segments = [];
+ const lines = transcription.split("\n");
+ let currentSpeakerId = null;
+ let currentText = "";
+
+ for (const line of lines) {
+ const speakerMatch = line.match(/^\[([^\]]+)\]:\s*(.*)$/);
+ if (speakerMatch) {
+ if (currentSpeakerId && currentText.trim()) {
+ segments.push({
+ speakerId: currentSpeakerId,
+ speaker:
+ speakerMap.value[currentSpeakerId]?.name ||
+ currentSpeakerId,
+ sentence: currentText.trim(),
+ color: speakerColors[currentSpeakerId] || "speaker-color-1",
+ });
+ }
+ currentSpeakerId = speakerMatch[1];
+ currentText = speakerMatch[2];
+ } else if (currentSpeakerId && line.trim()) {
+ currentText += " " + line.trim();
+ } else if (!currentSpeakerId && line.trim()) {
+ segments.push({
+ speakerId: null,
+ speaker: null,
+ sentence: line.trim(),
+ color: "speaker-color-1",
+ });
+ }
+ }
+
+ if (currentSpeakerId && currentText.trim()) {
+ segments.push({
+ speakerId: currentSpeakerId,
+ speaker:
+ speakerMap.value[currentSpeakerId]?.name || currentSpeakerId,
+ sentence: currentText.trim(),
+ color: speakerColors[currentSpeakerId] || "speaker-color-1",
+ });
+ }
+
+ const simpleSegments = [];
+ let lastSpeakerId = null;
+ segments.forEach((segment) => {
+ simpleSegments.push({
+ ...segment,
+ showSpeaker: segment.speakerId !== lastSpeakerId,
+ sentence: segment.sentence || segment.text,
+ });
+ lastSpeakerId = segment.speakerId;
+ });
+
+ const bubbleRows = [];
+ let currentRow = null;
+ segments.forEach((segment) => {
+ if (!currentRow || currentRow.speakerId !== segment.speakerId) {
+ if (currentRow) bubbleRows.push(currentRow);
+ currentRow = {
+ speakerId: segment.speakerId,
+ speaker: segment.speaker,
+ color: segment.color,
+ bubbles: [],
+ isMe:
+ segment.speaker &&
+ segment.speaker.toLowerCase().includes("me"),
+ };
+ }
+ currentRow.bubbles.push({
+ sentence: segment.sentence,
+ color: segment.color,
+ });
+ });
+ if (currentRow) bubbleRows.push(currentRow);
+
+ return {
+ hasDialogue: true,
+ isJson: false,
+ segments: segments,
+ simpleSegments: simpleSegments,
+ bubbleRows: bubbleRows,
+ speakers: speakerList.map((speaker) => ({
+ name: speakerMap.value[speaker]?.name || speaker,
+ color: speakerColors[speaker] || "speaker-color-1",
+ })),
+ };
+ }
+ });
+
+ //--------------------计算属性与派生展示数据 end------------------
+
+ //------------------- 主题与配色配置 start------------------
+ // --- Color Scheme Management ---
+ const colorSchemes = {
+ light: [
+ {
+ id: "blue",
+ name: "Ocean Blue",
+ description: "Classic blue theme with professional appeal",
+ class: "",
+ },
+ {
+ id: "emerald",
+ name: "Forest Emerald",
+ description: "Fresh green theme for a natural feel",
+ class: "theme-light-emerald",
+ },
+ {
+ id: "purple",
+ name: "Royal Purple",
+ description: "Elegant purple theme with sophistication",
+ class: "theme-light-purple",
+ },
+ {
+ id: "rose",
+ name: "Sunset Rose",
+ description: "Warm pink theme with gentle energy",
+ class: "theme-light-rose",
+ },
+ {
+ id: "amber",
+ name: "Golden Amber",
+ description: "Warm yellow theme for brightness",
+ class: "theme-light-amber",
+ },
+ {
+ id: "teal",
+ name: "Ocean Teal",
+ description: "Cool teal theme for tranquility",
+ class: "theme-light-teal",
+ },
+ ],
+ dark: [
+ {
+ id: "blue",
+ name: "Midnight Blue",
+ description: "Deep blue theme for focused work",
+ class: "",
+ },
+ {
+ id: "emerald",
+ name: "Dark Forest",
+ description: "Rich green theme for comfortable viewing",
+ class: "theme-dark-emerald",
+ },
+ {
+ id: "purple",
+ name: "Deep Purple",
+ description: "Mysterious purple theme for creativity",
+ class: "theme-dark-purple",
+ },
+ {
+ id: "rose",
+ name: "Dark Rose",
+ description: "Muted pink theme with subtle warmth",
+ class: "theme-dark-rose",
+ },
+ {
+ id: "amber",
+ name: "Dark Amber",
+ description: "Warm brown theme for cozy sessions",
+ class: "theme-dark-amber",
+ },
+ {
+ id: "teal",
+ name: "Deep Teal",
+ description: "Dark teal theme for calm focus",
+ class: "theme-dark-teal",
+ },
+ ],
+ };
+
+ //--------------------主题与配色配置 end------------------
+
+ //------------------- 通用工具方法与格式化逻辑 start------------------
+ // --- Utility Methods ---
+ const openmodel = async (e) => {
+ urlmsg.value = "获取链接中...";
+ isModalOpen.value = true;
+ let urls = `https://${wssBaseUrl.SCREEN_PUSH_IP}/external/summarize_text`;
+ const response = await fetch(urls, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: asrResult.value,
+ prompt_id: e ? e.id : "",
+ }),
+ });
+ const data = await response.json();
+
+ if (response) {
+ urlinfo.value = data.string;
+ urlmsg.value = "点击打开投屏页面";
+ console.log(data.value, "urlinfo.value", response, data);
+ }
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to reset status");
+ }
+ };
+ const openzjmodel = async () => {
+ zjloding.value = true;
+ let urls = `https://${wssBaseUrl.SCREEN_PUSH_IP}/external/submit`;
+ try {
+ const response = await fetch(urls, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: analysisResult.value,
+ }),
+ });
+ const data = await response.json();
+
+ if (response) {
+ window.open(data.string, "_blank");
+ zjloding.value = false;
+ }
+ if (!response.ok) {
+ zjloding.value = false;
+
+ throw new Error(data.error || "Failed to reset status");
+ }
+ } catch (error) {
+ console.error("接口请求失败:", error);
+ zjloding.value = false;
+ }
+ };
+ const closeModal = () => {
+ isModalOpen.value = false;
+ };
+ const setGlobalError = (message, duration = 7000) => {
+ globalError.value = message;
+ if (duration > 0) {
+ setTimeout(() => {
+ if (globalError.value === message) globalError.value = null;
+ }, duration);
+ }
+ };
+
+ const formatFileSize = (bytes) => {
+ if (bytes == null || bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ if (bytes < 0) bytes = 0;
+ const i =
+ bytes === 0
+ ? 0
+ : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
+ const size =
+ i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
+ return size + " " + sizes[i];
+ };
+
+ const formatDisplayDate = (dateString) => {
+ if (!dateString) return "";
+ try {
+ // Try to parse the date string directly first (it might already be formatted)
+ let date = new Date(dateString);
+ // If that fails or results in invalid date, try different approaches
+ if (isNaN(date.getTime())) {
+ // Try appending time if it looks like a date-only string (YYYY-MM-DD format)
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
+ date = new Date(dateString + "T00:00:00");
+ } else {
+ // If it's already a formatted string, just return it
+ return dateString;
+ }
+ }
+
+ // If we still have an invalid date, return the original string
+ if (isNaN(date.getTime())) {
+ return dateString;
+ }
+
+ return date.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ } catch (e) {
+ console.error("Error formatting date:", e);
+ return dateString;
+ }
+ };
+
+ const formatStatus = (status) => {
+ if (!status || status === "COMPLETED") return "";
+ const statusMap = {
+ PENDING: t("status.queued"),
+ PROCESSING: t("status.processing"),
+ TRANSCRIBING: t("status.transcribing"),
+ SUMMARIZING: t("status.summarizing"),
+ FAILED: t("status.failed"),
+ UPLOADING: t("status.uploading"),
+ };
+ return (
+ statusMap[status] ||
+ status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()
+ );
+ };
+
+ const getStatusClass = (status) => {
+ switch (status) {
+ case "PENDING":
+ return "status-pending";
+ case "PROCESSING":
+ return "status-processing";
+ case "SUMMARIZING":
+ return "status-summarizing";
+ case "COMPLETED":
+ return "";
+ case "FAILED":
+ return "status-failed";
+ default:
+ return "status-pending";
+ }
+ };
+
+ const formatTime = (seconds) => {
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${minutes.toString().padStart(2, "0")}:${secs
+ .toString()
+ .padStart(2, "0")}`;
+ };
+
+ const formatDuration = (totalSeconds) => {
+ if (totalSeconds == null || totalSeconds < 0) return "N/A";
+
+ if (totalSeconds < 1) {
+ return `${totalSeconds.toFixed(2)} seconds`;
+ }
+
+ totalSeconds = Math.round(totalSeconds);
+
+ if (totalSeconds < 60) {
+ return `${totalSeconds} sec`;
+ }
+
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ let parts = [];
+ if (hours > 0) {
+ parts.push(`${hours} hr`);
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes} min`);
+ }
+ // Only show seconds if duration is less than an hour
+ if (hours === 0 && seconds > 0) {
+ parts.push(`${seconds} sec`);
+ }
+
+ return parts.join(" ");
+ };
+
+ const createLocalizedRecordingFilename = () => {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ return `录音_${timestamp}.webm`;
+ };
+
+ const getDownloadFilenameFromHeaders = (
+ contentDisposition,
+ fallbackFilename
+ ) => {
+ if (!contentDisposition) {
+ return fallbackFilename;
+ }
+
+ const utf8Match = /filename\*=utf-8''([^;]+)/i.exec(contentDisposition);
+ if (utf8Match && utf8Match[1]) {
+ return decodeURIComponent(utf8Match[1]);
+ }
+
+ const regularMatch = /filename="([^"]+)"/i.exec(contentDisposition);
+ if (regularMatch && regularMatch[1]) {
+ return regularMatch[1];
+ }
+
+ return fallbackFilename;
+ };
+
+ //--------------------通用工具方法与格式化逻辑 end------------------
+
+ //------------------- 录音体积监控逻辑 start------------------
+ // --- Recording Size Monitoring Functions ---
+ const updateFileSizeEstimate = () => {
+ if (!isRecording.value || !actualBitrate.value) return;
+
+ // Calculate estimated size based on recording time and bitrate
+ const recordingTimeSeconds = recordingTime.value;
+ const estimatedBits = actualBitrate.value * recordingTimeSeconds;
+ const estimatedBytes = estimatedBits / 8;
+ estimatedFileSize.value = estimatedBytes;
+
+ // Check if we're approaching the size limit
+ const sizeMB = estimatedBytes / (1024 * 1024);
+ const warningThresholdMB = maxRecordingMB.value * 0.8; // 80% of max size
+
+ if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) {
+ fileSizeWarningShown.value = true;
+ showToast(
+ `Recording size is ${formatFileSize(
+ estimatedBytes
+ )}. Consider stopping soon to avoid auto-stop at ${
+ maxRecordingMB.value
+ }MB.`,
+ "fa-exclamation-triangle",
+ 5000
+ );
+ }
+
+ // Auto-stop if we exceed the maximum size
+ if (sizeMB > maxRecordingMB.value) {
+ console.log(
+ `Auto-stopping recording: size ${formatFileSize(
+ estimatedBytes
+ )} exceeds limit of ${maxRecordingMB.value}MB`
+ );
+ stopRecording();
+ showToast(
+ `Recording automatically stopped at ${formatFileSize(
+ estimatedBytes
+ )} to prevent excessive file size.`,
+ "fa-stop-circle",
+ 7000
+ );
+ }
+ };
+
+ const startSizeMonitoring = () => {
+ if (sizeCheckInterval.value) {
+ clearInterval(sizeCheckInterval.value);
+ }
+
+ // Reset size monitoring state
+ estimatedFileSize.value = 0;
+ fileSizeWarningShown.value = false;
+
+ // Start monitoring every 5 seconds
+ sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 5000);
+ };
+
+ const stopSizeMonitoring = () => {
+ if (sizeCheckInterval.value) {
+ clearInterval(sizeCheckInterval.value);
+ sizeCheckInterval.value = null;
+ }
+ };
+ //--------------------录音体积监控逻辑 end------------------
+
+ //------------------- 总结提示词与外部分析流程 start------------------
+ const promptVisible = ref(false);
+ const promptList = ref([]);
+ const recommendPromptId = ref(null);
+ const recommendPromptName = ref("");
+ const recommendPromptReason = ref("");
+ const selectedPromptId = ref(
+ localStorage.getItem("selected_prompt_id") || null
+ );
+ // const selectedPromptId = ref(null)
+ let modelTypeForP = "";
+ /** 打开弹框 */
+ const openPromptModal = (e) => {
+ modelTypeForP = e;
+ recommendPromptId.value = null;
+ recommendPromptName.value = "";
+ recommendPromptReason.value = "";
+ promptVisible.value = true;
+ getPromptList(e);
+ getRecommend();
+ };
+
+ /** 关闭弹框 */
+ const closePromptModal = () => {
+ promptVisible.value = false;
+ };
+
+ /** 获取 Prompt 列表 */
+ const getPromptList = async (e) => {
+ try {
+ const res = await fetch(
+ `https://${wssBaseUrl.SCREEN_PUSH_IP}/external/prompts/list`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ skip: 0, limit: 999 }),
+ }
+ );
+
+ const data = await res.json();
+ promptList.value = data.prompt_list || [];
+ } catch (e) {
+ console.error("提示词加载失败", e);
+ }
+ };
+ /** 根据文本获取Prompt推荐 */
+ const getRecommend = async (e) => {
+ try {
+ const res = await fetch(
+ `https://${wssBaseUrl.SCREEN_PUSH_IP}/external/recommend_prompts`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ input_str: asrResult.value }),
+ }
+ );
+
+ const data = await res.json();
+ recommendPromptId.value = data.id;
+ recommendPromptName.value = data.name;
+ recommendPromptReason.value = data.reason;
+ } catch (e) {
+ console.error("获取推荐失败", e);
+ }
+ };
+ /** 确认选择 */
+ const confirmPromptSelection = () => {
+ if (selectedPromptId.value) {
+ localStorage.setItem("selected_prompt_id", selectedPromptId.value);
+ }
+
+ const selectedPrompt = promptList.value.find(
+ (i) => i.id == selectedPromptId.value
+ );
+ console.log("选中的提示词:", selectedPrompt);
+ if (modelTypeForP === "zj") {
+ sumupResult(selectedPrompt);
+ } else {
+ openmodel(selectedPrompt);
+ }
+
+ // emit("promptSelected", selectedPrompt)
+
+ promptVisible.value = false;
+ };
+ const sumupResult = async (e) => {
+ isDialogVisible.value = true;
+ // if (!asrResult.value) return;
+
+ try {
+ analysisResult.value = "思考中...";
+
+ const response = await fetch(
+ `https://${wssBaseUrl.SCREEN_PUSH_IP}/external/summarize_text_stream`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: asrResult.value,
+ // input_str: '香港火灾情况更新** 香港特区政府召开新闻发布会,通报火灾的搜救与安置最新进展。警方表示,已完成5座大厦的搜索,其余2座仍在进行中。至当天16时,火灾已造成 **156人遇难**,目前有约35人参与搜救工作,其中5人连续工作。2. **对话内容提及天气** 有用户询问“今天天气怎么样”,地点为“安康”,表明对话中涉及对当地天气的关注。3. **对话片段含非正式表达** 有用户发言“我是走开始啊不是”,语句不通顺,可能是口语化表达或输入错误,语义不明确。',
+
+ prompt_id: e ? e.id : "",
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to create share link");
+ }
+
+ // 重置结果并开始流式处理
+ analysisResult.value = "";
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ // 解码并处理流数据
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split("\n");
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.content) {
+ analysisResult.value += data.content;
+ }
+ if (data.finished) {
+ console.log(analysisResult.value, "analysisResult.value");
+
+ showToast("总结完成!", "fa-check-circle");
+ break;
+ }
+ } catch (e) {
+ console.log("解析流数据出错:", e);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ setGlobalError(`Failed to create share link: ${error.message}`);
+ }
+ };
+ //--------------------总结提示词与外部分析流程 end------------------
+
+ //------------------- 日期分组与排序工具 start------------------
+ // --- Enhanced Date Utility Functions ---
+ const getDateForSorting = (recording) => {
+ const dateStr =
+ sortBy.value === "meeting_date"
+ ? recording.meeting_date
+ : recording.created_at;
+ if (!dateStr) return null;
+ return new Date(dateStr);
+ };
+
+ const isToday = (date) => {
+ const today = new Date();
+ return isSameDay(date, today);
+ };
+
+ const isYesterday = (date) => {
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ return isSameDay(date, yesterday);
+ };
+
+ const isThisWeek = (date) => {
+ const now = new Date();
+ const startOfWeek = new Date(now);
+ const day = now.getDay();
+ const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
+ startOfWeek.setDate(diff);
+ startOfWeek.setHours(0, 0, 0, 0);
+
+ const endOfWeek = new Date(startOfWeek);
+ endOfWeek.setDate(startOfWeek.getDate() + 6);
+ endOfWeek.setHours(23, 59, 59, 999);
+
+ return date >= startOfWeek && date <= endOfWeek;
+ };
+
+ const isLastWeek = (date) => {
+ const now = new Date();
+ const startOfLastWeek = new Date(now);
+ const day = now.getDay();
+ const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
+ startOfLastWeek.setDate(diff);
+ startOfLastWeek.setHours(0, 0, 0, 0);
+
+ const endOfLastWeek = new Date(startOfLastWeek);
+ endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
+ endOfLastWeek.setHours(23, 59, 59, 999);
+
+ return date >= startOfLastWeek && date <= endOfLastWeek;
+ };
+
+ const isThisMonth = (date) => {
+ const now = new Date();
+ return (
+ date.getFullYear() === now.getFullYear() &&
+ date.getMonth() === now.getMonth()
+ );
+ };
+
+ const isLastMonth = (date) => {
+ const now = new Date();
+ const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ return (
+ date.getFullYear() === lastMonth.getFullYear() &&
+ date.getMonth() === lastMonth.getMonth()
+ );
+ };
+
+ const isSameDay = (date1, date2) => {
+ return (
+ date1.getFullYear() === date2.getFullYear() &&
+ date1.getMonth() === date2.getMonth() &&
+ date1.getDate() === date2.getDate()
+ );
+ };
+
+ //--------------------日期分组与排序工具 end------------------
+
+ //------------------- 录音详情高级操作 start------------------
+ const reprocessTranscription = (recordingId) => {
+ const recording =
+ recordings.value.find((r) => r.id === recordingId) ||
+ selectedRecording.value;
+ confirmReprocess("transcription", recording);
+ };
+
+ const reprocessSummary = (recordingId) => {
+ const recording =
+ recordings.value.find((r) => r.id === recordingId) ||
+ selectedRecording.value;
+ confirmReprocess("summary", recording);
+ };
+
+ const generateSummary = async () => {
+ if (!selectedRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/generate_summary`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken,
+ },
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to generate summary");
+ }
+
+ // Update the recording status to show it's being processed
+ selectedRecording.value.status = "SUMMARIZING";
+
+ // Also update in recordings list if it exists
+ const recordingInList = recordings.value.find(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (recordingInList) {
+ recordingInList.status = "SUMMARIZING";
+ }
+
+ showToast("Summary generation started", "success");
+ } catch (error) {
+ console.error("Error generating summary:", error);
+ setGlobalError(`Failed to generate summary: ${error.message}`);
+ }
+ };
+
+ const resetRecordingStatus = async (recordingId) => {
+ const recording = recordings.value.find((r) => r.id === recordingId);
+ if (!recording) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/reset_status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to reset status");
+ }
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ if (selectedRecording.value?.id === recording.id) {
+ selectedRecording.value = data.recording;
+ }
+ showToast("Recording status has been reset.", "fa-check-circle");
+ } catch (error) {
+ setGlobalError(`Failed to reset status: ${error.message}`);
+ }
+ };
+
+ const confirmReprocess = (type, recording) => {
+ reprocessType.value = type;
+ reprocessRecording.value = recording;
+ showReprocessModal.value = true;
+ };
+
+ const openTranscriptionEditor = () => {
+ if (processedTranscription.value.isJson) {
+ openAsrEditorModal();
+ } else {
+ openTextEditorModal();
+ }
+ };
+
+ const openTextEditorModal = () => {
+ if (!selectedRecording.value) return;
+ editingTranscriptionContent.value =
+ selectedRecording.value.transcription;
+ showTextEditorModal.value = true;
+ };
+
+ const closeTextEditorModal = () => {
+ showTextEditorModal.value = false;
+ editingTranscriptionContent.value = "";
+ };
+
+ const saveTranscription = async () => {
+ if (!selectedRecording.value) return;
+ await saveTranscriptionContent(editingTranscriptionContent.value);
+ closeTextEditorModal();
+ };
+
+ const openAsrEditorModal = async () => {
+ if (!selectedRecording.value) return;
+ try {
+ const segments = JSON.parse(selectedRecording.value.transcription);
+ editingSegments.value = segments.map((s, i) => ({
+ ...s,
+ id: i,
+ showSuggestions: false,
+ filteredSpeakers: [],
+ }));
+
+ // Populate available speakers
+ const speakersInTranscript = [
+ ...new Set(segments.map((s) => s.speaker)),
+ ];
+ const response = await fetch("/tool/speakr/speakers");
+ const speakersFromDb = await response.json();
+ const speakerNamesFromDb = speakersFromDb.map((s) => s.name);
+ availableSpeakers.value = [
+ ...new Set([...speakersInTranscript, ...speakerNamesFromDb]),
+ ].sort();
+
+ showAsrEditorModal.value = true;
+ } catch (e) {
+ console.error(
+ "Could not parse transcription as JSON for ASR editor:",
+ e
+ );
+ setGlobalError(
+ "This transcription is not in the correct format for the ASR editor."
+ );
+ }
+ };
+
+ const closeAsrEditorModal = () => {
+ showAsrEditorModal.value = false;
+ editingSegments.value = [];
+ availableSpeakers.value = [];
+ };
+
+ const saveAsrTranscription = async () => {
+ const contentToSave = JSON.stringify(
+ editingSegments.value.map(
+ ({ id, showSuggestions, filteredSpeakers, ...rest }) => rest
+ )
+ );
+ await saveTranscriptionContent(contentToSave);
+ closeAsrEditorModal();
+ };
+
+ const adjustTime = (index, field, amount) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index][field] = parseFloat(
+ (editingSegments.value[index][field] + amount).toFixed(3)
+ );
+ }
+ };
+
+ const filterSpeakers = (index) => {
+ const segment = editingSegments.value[index];
+ if (segment) {
+ const query = segment.speaker.toLowerCase();
+ segment.filteredSpeakers = availableSpeakers.value.filter((s) =>
+ s.toLowerCase().includes(query)
+ );
+ }
+ };
+
+ const openSpeakerSuggestions = (index) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].showSuggestions = true;
+ filterSpeakers(index);
+ }
+ };
+
+ const closeSpeakerSuggestions = (index) => {
+ if (editingSegments.value[index]) {
+ window.setTimeout(() => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].showSuggestions = false;
+ }
+ }, 200); // Delay to allow click event to register
+ }
+ };
+
+ const selectSpeaker = (index, speaker) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].speaker = speaker;
+ editingSegments.value[index].showSuggestions = false;
+ }
+ };
+
+ const addSegment = () => {
+ const lastSegment =
+ editingSegments.value[editingSegments.value.length - 1];
+ editingSegments.value.push({
+ id: Date.now(),
+ speaker: lastSegment ? lastSegment.speaker : "SPEAKER_00",
+ start_time: lastSegment ? lastSegment.end_time : 0,
+ end_time: lastSegment ? lastSegment.end_time + 1 : 1,
+ sentence: "",
+ });
+ };
+
+ const removeSegment = (index) => {
+ editingSegments.value.splice(index, 1);
+ };
+
+ const saveTranscriptionContent = async (content) => {
+ if (!selectedRecording.value) return;
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/update_transcription`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ transcription: content }),
+ }
+ );
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update transcription");
+ const index = recordings.value.findIndex(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ selectedRecording.value = data.recording;
+ showToast("Transcription updated successfully!", "fa-check-circle");
+ } catch (error) {
+ console.error("Save Transcription Error:", error);
+ setGlobalError(`Failed to save transcription: ${error.message}`);
+ }
+ };
+
+ const cancelReprocess = () => {
+ showReprocessModal.value = false;
+ reprocessType.value = null;
+ reprocessRecording.value = null;
+ };
+
+ const executeReprocess = async () => {
+ if (!reprocessRecording.value || !reprocessType.value) return;
+
+ const recordingId = reprocessRecording.value.id;
+ const type = reprocessType.value;
+
+ // Close the modal first
+ cancelReprocess();
+
+ if (type === "transcription") {
+ await performReprocessTranscription(
+ recordingId,
+ asrReprocessOptions.language,
+ asrReprocessOptions.min_speakers,
+ asrReprocessOptions.max_speakers
+ );
+ } else if (type === "summary") {
+ await performReprocessSummary(recordingId);
+ }
+ };
+
+ const performReprocessTranscription = async (
+ recordingId,
+ language,
+ minSpeakers,
+ maxSpeakers
+ ) => {
+ if (!recordingId) {
+ setGlobalError("No recording ID provided for reprocessing.");
+ return;
+ }
+
+ try {
+ const payload = {
+ language: language,
+ min_speakers: minSpeakers,
+ max_speakers: maxSpeakers,
+ };
+
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reprocess_transcription`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to start transcription reprocessing"
+ );
+
+ // Ensure the recording status is properly set to PROCESSING
+ if (data.recording && data.recording.status !== "PROCESSING") {
+ console.warn(
+ `Warning: Reprocess transcription returned unexpected status: ${data.recording.status}`
+ );
+ data.recording.status = "PROCESSING";
+ }
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Transcription reprocessing started", "fa-sync-alt");
+
+ // Switch to Summary tab to show progress
+ selectedTab.value = "summary";
+
+ // Show progress modal for reprocessing
+ showProgressModalForReprocessing(recordingId, "transcription");
+
+ // Start polling for status updates
+ startReprocessingPoll(recordingId);
+ } catch (error) {
+ console.error("Reprocess Transcription Error:", error);
+ setGlobalError(
+ `Failed to start transcription reprocessing: ${error.message}`
+ );
+ }
+ };
+
+ const performReprocessSummary = async (recordingId) => {
+ if (!recordingId) {
+ setGlobalError("No recording ID provided for reprocessing.");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reprocess_summary`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to start summary reprocessing"
+ );
+
+ // Ensure the recording status is properly set to SUMMARIZING
+ if (data.recording && data.recording.status !== "SUMMARIZING") {
+ console.warn(
+ `Warning: Reprocess summary returned unexpected status: ${data.recording.status}`
+ );
+ data.recording.status = "SUMMARIZING";
+ }
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Summary reprocessing started", "fa-sync-alt");
+
+ // Switch to Summary tab to show progress
+ selectedTab.value = "summary";
+
+ // Show progress modal for reprocessing
+ showProgressModalForReprocessing(recordingId, "summary");
+
+ // Start polling for status updates
+ startReprocessingPoll(recordingId);
+ } catch (error) {
+ console.error("Reprocess Summary Error:", error);
+ setGlobalError(
+ `Failed to start summary reprocessing: ${error.message}`
+ );
+ }
+ };
+
+ // Show progress modal for reprocessing operations
+ const showProgressModalForReprocessing = (recordingId, type) => {
+ const recording = recordings.value.find((r) => r.id === recordingId);
+ if (!recording) return;
+
+ // Create a mock file item for the progress modal
+ const reprocessItem = {
+ file: {
+ name: recording.title || `Recording ${recordingId}`,
+ size: recording.file_size || 0,
+ },
+ status: "pending",
+ recordingId: recordingId,
+ clientId: `reprocess-${type}-${recordingId}-${Date.now()}`,
+ error: null,
+ isReprocessing: true,
+ reprocessType: type,
+ };
+
+ // Add to upload queue for progress tracking
+ uploadQueue.value.unshift(reprocessItem);
+
+ // Show progress modal
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ // Set as currently processing file
+ currentlyProcessingFile.value = reprocessItem;
+ processingProgress.value = 10;
+ processingMessage.value =
+ type === "transcription"
+ ? "Starting transcription reprocessing..."
+ : "Starting summary reprocessing...";
+ };
+
+ // Polling for reprocessing status updates
+ const reprocessingPolls = ref(new Map()); // Track active polls by recording ID
+
+ const startReprocessingPoll = (recordingId) => {
+ // Clear any existing poll for this recording
+ if (reprocessingPolls.value.has(recordingId)) {
+ clearInterval(reprocessingPolls.value.get(recordingId));
+ }
+
+ // console.log(`Starting reprocessing poll for recording ${recordingId}`);
+
+ const pollInterval = setInterval(async () => {
+ try {
+ const response = await fetch(`/tool/speakr/status/${recordingId}`);
+ if (!response.ok) {
+ console.error(`Status check failed for recording ${recordingId}`);
+ stopReprocessingPoll(recordingId);
+ return;
+ }
+
+ const data = await response.json();
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+ if (index !== -1) {
+ recordings.value[index] = data;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data;
+ }
+
+ const queueItem = uploadQueue.value.find(
+ (item) => item.recordingId === recordingId
+ );
+
+ // Update the item's status and name for intermediate states
+ if (queueItem) {
+ queueItem.status = data.status; // e.g., 'PROCESSING', 'SUMMARIZING'
+ queueItem.file.name = data.title || data.original_filename;
+ }
+
+ // Update progress modal if this is the currently processing item
+ if (
+ queueItem &&
+ currentlyProcessingFile.value?.clientId === queueItem.clientId
+ ) {
+ updateReprocessingProgress(data.status, queueItem);
+ }
+
+ // Stop polling if processing is complete
+ if (data.status === "COMPLETED" || data.status === "FAILED") {
+ console.log(
+ `Reprocessing ${data.status.toLowerCase()} for recording ${recordingId}`
+ );
+ stopReprocessingPoll(recordingId);
+
+ // Update queue item status to final lowercase state
+ if (queueItem) {
+ queueItem.status =
+ data.status === "COMPLETED" ? "completed" : "failed";
+ if (data.status === "FAILED") {
+ queueItem.error = data.error_message || "Reprocessing failed";
+ }
+ }
+
+ // Clear current processing if this was the active item
+ if (currentlyProcessingFile.value?.recordingId === recordingId) {
+ resetCurrentFileProcessingState();
+ }
+
+ if (data.status === "COMPLETED") {
+ showToast(
+ "Reprocessing completed successfully",
+ "fa-check-circle"
+ );
+ } else {
+ showToast("Reprocessing failed", "fa-exclamation-circle");
+ }
+ }
+ } catch (error) {
+ console.error(
+ `Error polling status for recording ${recordingId}:`,
+ error
+ );
+ stopReprocessingPoll(recordingId);
+ }
+ }, 3000);
+
+ reprocessingPolls.value.set(recordingId, pollInterval);
+ };
+
+ const updateReprocessingProgress = (status, queueItem) => {
+ switch (status) {
+ case "PENDING":
+ processingProgress.value = 20;
+ processingMessage.value = `Waiting to start ${queueItem.reprocessType} reprocessing...`;
+ break;
+ case "PROCESSING":
+ processingProgress.value = Math.round(
+ Math.min(70, processingProgress.value + Math.random() * 10)
+ );
+ processingMessage.value =
+ queueItem.reprocessType === "transcription"
+ ? "Reprocessing transcription..."
+ : "Processing audio...";
+ break;
+ case "SUMMARIZING":
+ processingProgress.value = Math.round(
+ Math.min(90, processingProgress.value + Math.random() * 10)
+ );
+ processingMessage.value =
+ queueItem.reprocessType === "summary"
+ ? "Regenerating summary..."
+ : "Generating title and summary...";
+ break;
+ case "COMPLETED":
+ processingProgress.value = 100;
+ processingMessage.value = "Reprocessing completed!";
+ break;
+ case "FAILED":
+ processingProgress.value = 100;
+ processingMessage.value = "Reprocessing failed.";
+ break;
+ default:
+ processingProgress.value = 15;
+ processingMessage.value = "Starting reprocessing...";
+ }
+ };
+
+ const stopReprocessingPoll = (recordingId) => {
+ if (reprocessingPolls.value.has(recordingId)) {
+ clearInterval(reprocessingPolls.value.get(recordingId));
+ reprocessingPolls.value.delete(recordingId);
+ console.log(`Stopped reprocessing poll for recording ${recordingId}`);
+ }
+ };
+
+ const confirmReset = (recording) => {
+ recordingToReset.value = recording;
+ showResetModal.value = true;
+ };
+
+ const cancelReset = () => {
+ showResetModal.value = false;
+ recordingToReset.value = null;
+ };
+
+ const executeReset = async () => {
+ if (!recordingToReset.value) return;
+ const recordingId = recordingToReset.value.id;
+
+ // Close the modal first
+ cancelReset();
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reset_status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to reset status");
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reset
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Recording status has been reset.", "fa-check-circle");
+ } catch (error) {
+ console.error("Reset Status Error:", error);
+ setGlobalError(`Failed to reset status: ${error.message}`);
+ }
+ };
+
+ const searchSpeakers = async (query, speakerId) => {
+ if (!query || query.length < 2) {
+ speakerSuggestions.value[speakerId] = [];
+ return;
+ }
+
+ loadingSuggestions.value[speakerId] = true;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/speakers/search?q=${encodeURIComponent(query)}`
+ );
+ if (!response.ok) throw new Error("Failed to search speakers");
+
+ const speakers = await response.json();
+ speakerSuggestions.value[speakerId] = speakers;
+ } catch (error) {
+ console.error("Error searching speakers:", error);
+ speakerSuggestions.value[speakerId] = [];
+ } finally {
+ loadingSuggestions.value[speakerId] = false;
+ }
+ };
+
+ const selectSpeakerSuggestion = (speakerId, suggestion) => {
+ if (speakerMap.value[speakerId]) {
+ speakerMap.value[speakerId].name = suggestion.name;
+ speakerSuggestions.value[speakerId] = [];
+ }
+ };
+
+ const closeSpeakerSuggestionsOnClick = (event) => {
+ // Check if the click was on an input field or dropdown
+ const clickedInput = event.target.closest('input[type="text"]');
+ const clickedDropdown = event.target.closest(".absolute.z-10");
+
+ // If not clicking on input or dropdown, close all suggestions
+ if (!clickedInput && !clickedDropdown) {
+ Object.keys(speakerSuggestions.value).forEach((speakerId) => {
+ speakerSuggestions.value[speakerId] = [];
+ });
+ }
+ };
+
+ // Create a mapping for display-friendly speaker IDs
+ const speakerDisplayMap = ref({});
+ const modalSpeakers = ref([]);
+ const is_final = ref(false);
+ const openSpeakerModal = () => {
+ // Clear any existing speaker map data first
+ speakerMap.value = {};
+ speakerDisplayMap.value = {};
+
+ // Get the same speaker order used in processedTranscription
+ const transcription = selectedRecording.value?.transcription;
+ let speakers = [];
+
+ if (transcription) {
+ try {
+ const transcriptionData = JSON.parse(transcription);
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ // Use the exact same logic as processedTranscription to get speakers
+ speakers = [
+ ...new Set(
+ transcriptionData
+ .map((segment) => segment.speaker)
+ .filter(Boolean)
+ ),
+ ];
+ }
+ } catch (e) {
+ // Fall back to identifiedSpeakers if JSON parsing fails
+ speakers = identifiedSpeakers.value;
+ }
+ }
+
+ // Set modalSpeakers for the template to use
+ modalSpeakers.value = speakers;
+
+ // Initialize speaker map with the same order and colors as the transcript
+ speakerMap.value = speakers.reduce((acc, speaker, index) => {
+ acc[speaker] = {
+ name: "",
+ isMe: false,
+ color: `speaker-color-${(index % 8) + 1}`, // Same color assignment as processedTranscription
+ };
+ // Keep the original speaker ID for display
+ speakerDisplayMap.value[speaker] = speaker;
+ return acc;
+ }, {});
+
+ highlightedSpeaker.value = null;
+ speakerSuggestions.value = {};
+ loadingSuggestions.value = {};
+ isAutoIdentifying.value = false;
+ showSpeakerModal.value = true;
+ };
+
+ const closeSpeakerModal = () => {
+ showSpeakerModal.value = false;
+ highlightedSpeaker.value = null;
+ // Clear the speaker map to prevent stale data from persisting
+ speakerMap.value = {};
+ speakerSuggestions.value = {};
+ loadingSuggestions.value = {};
+
+ // Clean up click handler if it exists
+ if (window.speakerModalClickHandler) {
+ const modalContent = document.querySelector(".modal-content");
+ if (modalContent) {
+ modalContent.removeEventListener(
+ "click",
+ window.speakerModalClickHandler
+ );
+ }
+ delete window.speakerModalClickHandler;
+ }
+ };
+
+ const saveSpeakerNames = async () => {
+ if (!selectedRecording.value) return;
+
+ // Create a filtered speaker map that excludes entries with blank names
+ const filteredSpeakerMap = Object.entries(speakerMap.value).reduce(
+ (acc, [speakerId, speakerData]) => {
+ if (speakerData.name && speakerData.name.trim() !== "") {
+ acc[speakerId] = speakerData;
+ }
+ return acc;
+ },
+ {}
+ );
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/update_speakers`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ speaker_map: filteredSpeakerMap, // Send the filtered map
+ regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value,
+ }),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update speakers");
+
+ // On success, close the modal and clear the speakerMap state *before*
+ // updating the recording data. This prevents a race condition where the
+ // view could re-render using the new data but the old, lingering speakerMap.
+ closeSpeakerModal();
+
+ // The backend returns the fully updated recording object.
+ // We can directly update our local state with this fresh data.
+ const index = recordings.value.findIndex(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ selectedRecording.value = data.recording;
+
+ showToast("Speaker names updated successfully!", "fa-check-circle");
+
+ // If a summary regeneration was requested, start polling for its status.
+ if (regenerateSummaryAfterSpeakerUpdate.value) {
+ startReprocessingPoll(selectedRecording.value.id);
+ }
+ } catch (error) {
+ console.error("Save Speaker Names Error:", error);
+ setGlobalError(`Failed to save speaker names: ${error.message}`);
+ }
+ };
+
+ // Speaker group navigation state
+ const currentSpeakerGroupIndex = ref(-1);
+ const speakerGroups = ref([]);
+
+ const findSpeakerGroups = (speakerId) => {
+ if (!speakerId) return [];
+
+ const groups = [];
+ const modalTranscript = document.querySelector(
+ "div.speaker-modal-transcript"
+ );
+ const mainTranscript = document.querySelector(
+ ".transcription-simple-view, .transcription-with-speakers, .transcription-content"
+ );
+ const transcriptContainer = modalTranscript || mainTranscript;
+
+ if (!transcriptContainer) return [];
+
+ // For JSON-based transcripts with segments
+ const allSegments =
+ transcriptContainer.querySelectorAll(".speaker-segment");
+ if (allSegments.length > 0) {
+ let currentGroup = null;
+ let lastSpeakerId = null;
+
+ allSegments.forEach((segment) => {
+ const speakerTag = segment.querySelector("[data-speaker-id]");
+ const segmentSpeakerId = speakerTag?.dataset.speakerId;
+
+ if (segmentSpeakerId === speakerId) {
+ // If this is a new group (not consecutive with previous)
+ if (lastSpeakerId !== speakerId) {
+ currentGroup = {
+ startElement: segment,
+ elements: [segment],
+ };
+ groups.push(currentGroup);
+ } else if (currentGroup) {
+ // Add to existing group
+ currentGroup.elements.push(segment);
+ }
+ }
+ lastSpeakerId = segmentSpeakerId;
+ });
+ } else {
+ // For plain text transcripts with speaker tags
+ const allTags =
+ transcriptContainer.querySelectorAll("[data-speaker-id]");
+ let currentGroup = null;
+
+ allTags.forEach((tag) => {
+ if (tag.dataset.speakerId === speakerId) {
+ // Find the parent element that contains this speaker's content
+ const parentSegment =
+ tag.closest(".speaker-segment") || tag.parentElement;
+
+ if (
+ !currentGroup ||
+ !currentGroup.lastElement ||
+ !parentSegment.previousElementSibling ||
+ parentSegment.previousElementSibling !==
+ currentGroup.lastElement
+ ) {
+ // Start a new group
+ currentGroup = {
+ startElement: parentSegment,
+ elements: [parentSegment],
+ lastElement: parentSegment,
+ };
+ groups.push(currentGroup);
+ } else {
+ // Continue the group
+ currentGroup.elements.push(parentSegment);
+ currentGroup.lastElement = parentSegment;
+ }
+ }
+ });
+ }
+
+ return groups;
+ };
+
+ const highlightSpeakerInTranscript = (speakerId) => {
+ highlightedSpeaker.value = speakerId;
+
+ if (speakerId) {
+ // Find all speaker groups for navigation
+ speakerGroups.value = findSpeakerGroups(speakerId);
+ currentSpeakerGroupIndex.value = 0;
+
+ // Scroll to the first group
+ if (speakerGroups.value.length > 0) {
+ nextTick(() => {
+ const firstGroup = speakerGroups.value[0];
+ if (firstGroup && firstGroup.startElement) {
+ firstGroup.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ });
+ }
+ } else {
+ speakerGroups.value = [];
+ currentSpeakerGroupIndex.value = -1;
+ }
+ };
+
+ const navigateToNextSpeakerGroup = () => {
+ if (speakerGroups.value.length === 0) return;
+
+ // Don't reset the speaker groups, just update the index
+ currentSpeakerGroupIndex.value =
+ (currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length;
+ const group = speakerGroups.value[currentSpeakerGroupIndex.value];
+ if (group && group.startElement) {
+ group.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ };
+
+ const navigateToPrevSpeakerGroup = () => {
+ if (speakerGroups.value.length === 0) return;
+
+ // Don't reset the speaker groups, just update the index
+ currentSpeakerGroupIndex.value =
+ currentSpeakerGroupIndex.value <= 0
+ ? speakerGroups.value.length - 1
+ : currentSpeakerGroupIndex.value - 1;
+ const group = speakerGroups.value[currentSpeakerGroupIndex.value];
+ if (group && group.startElement) {
+ group.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ };
+
+ // Enhanced speaker highlighting with focus/blur events for text inputs
+ const focusSpeaker = (speakerId) => {
+ // Set this as the active speaker input
+ activeSpeakerInput.value = speakerId;
+ // Only highlight if not already highlighted (to preserve navigation state)
+ if (highlightedSpeaker.value !== speakerId) {
+ highlightSpeakerInTranscript(speakerId);
+ }
+ };
+
+ const blurSpeaker = () => {
+ // Clear the active speaker input after a delay to allow clicking on suggestions
+ setTimeout(() => {
+ activeSpeakerInput.value = null;
+ speakerSuggestions.value = {};
+ }, 200);
+ clearSpeakerHighlight();
+ };
+
+ const clearSpeakerHighlight = () => {
+ highlightedSpeaker.value = null;
+ };
+
+ const autoIdentifySpeakers = async () => {
+ if (!selectedRecording.value) {
+ showToast("No recording selected.", "fa-exclamation-circle");
+ return;
+ }
+
+ isAutoIdentifying.value = true;
+ showToast("Starting automatic speaker identification...", "fa-magic");
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/auto_identify_speakers`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ current_speaker_map: speakerMap.value,
+ }),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ data.error || "Unknown error occurred during auto-identification."
+ );
+ }
+
+ // Check if there's a message (e.g., all speakers already identified)
+ if (data.message) {
+ showToast(data.message, "fa-info-circle");
+ return;
+ }
+
+ // Update speakerMap with the identified names (only for unidentified speakers)
+ let identifiedCount = 0;
+ for (const speakerId in data.speaker_map) {
+ const identifiedName = data.speaker_map[speakerId];
+ if (
+ speakerMap.value[speakerId] &&
+ identifiedName &&
+ identifiedName.trim() !== ""
+ ) {
+ speakerMap.value[speakerId].name = identifiedName;
+ identifiedCount++;
+ }
+ }
+
+ if (identifiedCount > 0) {
+ showToast(
+ `${identifiedCount} speaker(s) identified successfully!`,
+ "fa-check-circle"
+ );
+ } else {
+ showToast(
+ "No speakers could be identified from the context.",
+ "fa-info-circle"
+ );
+ }
+ } catch (error) {
+ console.error("Auto Identify Speakers Error:", error);
+ showToast(`Error: ${error.message}`, "fa-exclamation-circle", 5000);
+ } finally {
+ isAutoIdentifying.value = false;
+ }
+ };
+
+ const toggleInbox = async (recording) => {
+ if (!recording || !recording.id) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/toggle_inbox`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to toggle inbox status");
+
+ // Update the recording in the UI
+ recording.is_inbox = data.is_inbox;
+
+ // Update in the recordings list
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].is_inbox = data.is_inbox;
+ }
+
+ showToast(
+ `Recording ${data.is_inbox ? "moved to inbox" : "marked as read"}`
+ );
+ } catch (error) {
+ console.error("Toggle Inbox Error:", error);
+ setGlobalError(`Failed to toggle inbox status: ${error.message}`);
+ }
+ };
+
+ // Toggle highlighted status
+ const toggleHighlight = async (recording) => {
+ if (!recording || !recording.id) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/toggle_highlight`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to toggle highlighted status"
+ );
+
+ // Update the recording in the UI
+ recording.is_highlighted = data.is_highlighted;
+
+ // Update in the recordings list
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].is_highlighted = data.is_highlighted;
+ }
+
+ showToast(
+ `Recording ${data.is_highlighted ? "highlighted" : "unhighlighted"}`
+ );
+ } catch (error) {
+ console.error("Toggle Highlight Error:", error);
+ setGlobalError(
+ `Failed to toggle highlighted status: ${error.message}`
+ );
+ }
+ };
+
+ //--------------------录音详情高级操作 end------------------
+
+ //------------------- 深色模式与主题切换 start------------------
+ // --- Dark Mode ---
+ const toggleDarkMode = () => {
+ isDarkMode.value = !isDarkMode.value;
+ if (isDarkMode.value) {
+ document.documentElement.classList.add("dark");
+ localStorage.setItem("darkMode", "true");
+ } else {
+ document.documentElement.classList.remove("dark");
+ localStorage.setItem("darkMode", "false");
+ }
+ };
+
+ const initializeDarkMode = () => {
+ const prefersDark = window.matchMedia(
+ "(prefers-color-scheme: dark)"
+ ).matches;
+ const savedMode = localStorage.getItem("darkMode");
+ if (savedMode === "true" || (savedMode === null && prefersDark)) {
+ isDarkMode.value = true;
+ document.documentElement.classList.add("dark");
+ } else {
+ isDarkMode.value = false;
+ document.documentElement.classList.remove("dark");
+ }
+ };
+
+ const applyColorScheme = (schemeId, mode = null) => {
+ const targetMode = mode || (isDarkMode.value ? "dark" : "light");
+ const scheme = colorSchemes[targetMode].find((s) => s.id === schemeId);
+
+ if (!scheme) {
+ console.warn(
+ `Color scheme '${schemeId}' not found for mode '${targetMode}'`
+ );
+ return;
+ }
+
+ const allThemeClasses = [
+ ...colorSchemes.light.map((s) => s.class),
+ ...colorSchemes.dark.map((s) => s.class),
+ ].filter((c) => c !== "");
+
+ document.documentElement.classList.remove(...allThemeClasses);
+
+ if (scheme.class) {
+ document.documentElement.classList.add(scheme.class);
+ }
+
+ currentColorScheme.value = schemeId;
+ localStorage.setItem("colorScheme", schemeId);
+ };
+
+ const initializeColorScheme = () => {
+ const savedScheme = localStorage.getItem("colorScheme") || "blue";
+ currentColorScheme.value = savedScheme;
+ applyColorScheme(savedScheme);
+ };
+
+ const openColorSchemeModal = () => {
+ showColorSchemeModal.value = true;
+ };
+
+ const closeColorSchemeModal = () => {
+ showColorSchemeModal.value = false;
+ };
+
+ const selectColorScheme = (schemeId) => {
+ applyColorScheme(schemeId);
+ showToast(
+ `Applied ${
+ colorSchemes[isDarkMode.value ? "dark" : "light"].find(
+ (s) => s.id === schemeId
+ )?.name
+ } theme`,
+ "fa-palette"
+ );
+ };
+
+ const resetColorScheme = () => {
+ applyColorScheme("blue");
+ showToast("Reset to default Ocean Blue theme", "fa-undo");
+ };
+
+ // Watch for dark mode changes to reapply color scheme
+ watch(isDarkMode, () => {
+ applyColorScheme(currentColorScheme.value);
+ });
+ function confirmStopRecording() {
+ return new Promise((resolve) => {
+ // 防止重复创建
+ if (document.getElementById("recording-confirm-mask")) {
+ return;
+ }
+
+ // 遮罩层
+ const mask = document.createElement("div");
+ mask.id = "recording-confirm-mask";
+ mask.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.45);
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ `;
+
+ // 弹框
+ const dialog = document.createElement("div");
+ dialog.style.cssText = `
+ width: 360px;
+ background: #1f2937;
+ color: #fff;
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: 0 10px 30px rgba(0,0,0,.3);
+ font-family: system-ui;
+ `;
+
+ dialog.innerHTML = `
+
+ 确认停止录音
+
+
+ 当前正在录音,切换页面将会停止录音,是否继续?
+
+
+
+ 继续录音
+
+
+ 停止并切换
+
+
+ `;
+
+ mask.appendChild(dialog);
+ document.body.appendChild(mask);
+
+ const cleanup = () => {
+ document.body.removeChild(mask);
+ };
+
+ dialog.querySelector("#confirm-ok").onclick = () => {
+ cleanup();
+ resolve(true);
+ };
+
+ dialog.querySelector("#confirm-cancel").onclick = () => {
+ cleanup();
+ resolve(false);
+ };
+
+ // 点击遮罩关闭 = 取消
+ mask.onclick = (e) => {
+ if (e.target === mask) {
+ cleanup();
+ resolve(false);
+ }
+ };
+ });
+ }
+
+ //--------------------深色模式与主题切换 end------------------
+
+ //------------------- 侧边栏与视图切换控制 start------------------
+ // --- Sidebar Toggle ---
+ const toggleSidebar = () => {
+ isSidebarCollapsed.value = !isSidebarCollapsed.value;
+ };
+
+ //--------------------侧边栏与视图切换控制 end------------------
+
+ //------------------- 页面视图管理 start------------------
+ // --- View Management ---
+ const switchToUploadView = async () => {
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } else {
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ }
+ };
+
+ const selectRecording = async (recording) => {
+ if (currentView.value === "recording" && isRecording.value) {
+ // If we are in the middle of a recording, don't switch views
+ setGlobalError(
+ "Please stop the current recording before selecting another one."
+ );
+ return;
+ }
+ selectedRecording.value = recording;
+
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "detail";
+ } else {
+ currentView.value = "detail";
+ }
+ if (recording && recording.id) {
+ localStorage.setItem("lastSelectedRecordingId", recording.id);
+ } else {
+ localStorage.removeItem("lastSelectedRecordingId");
+ }
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } else {
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "detail";
+ } else {
+ currentView.value = "detail";
+ }
+ if (recording && recording.id) {
+ localStorage.setItem("lastSelectedRecordingId", recording.id);
+ } else {
+ localStorage.removeItem("lastSelectedRecordingId");
+ }
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ }
+ };
+
+ //--------------------页面视图管理 end------------------
+
+ //------------------- 文件上传与上传队列 start------------------
+ // --- File Upload ---
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ dragover.value = true;
+ };
+
+ const handleDragLeave = (e) => {
+ if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) {
+ return;
+ }
+ dragover.value = false;
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ dragover.value = false;
+ addFilesToQueue(e.dataTransfer.files);
+ };
+
+ const handleFileSelect = (e) => {
+ addFilesToQueue(e.target.files);
+ e.target.value = null;
+ };
+
+ const addFilesToQueue = (files) => {
+ let filesAdded = 0;
+ for (const file of files) {
+ const fileObject = file.file ? file.file : file;
+ const notes = file.notes || null;
+ const tags = file.tags || selectedTags.value || [];
+ const asrOptions = file.asrOptions || {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ };
+
+ // Check if it's an audio file or video container with audio
+ const isAudioFile =
+ fileObject &&
+ (fileObject.type.startsWith("audio/") ||
+ fileObject.type === "video/mp4" ||
+ fileObject.type === "video/quicktime" ||
+ fileObject.type === "video/x-msvideo" ||
+ fileObject.type === "video/webm" ||
+ fileObject.name.toLowerCase().endsWith(".amr") ||
+ fileObject.name.toLowerCase().endsWith(".3gp") ||
+ fileObject.name.toLowerCase().endsWith(".3gpp") ||
+ fileObject.name.toLowerCase().endsWith(".mp4") ||
+ fileObject.name.toLowerCase().endsWith(".mov") ||
+ fileObject.name.toLowerCase().endsWith(".avi") ||
+ fileObject.name.toLowerCase().endsWith(".mkv") ||
+ fileObject.name.toLowerCase().endsWith(".webm"));
+
+ if (isAudioFile) {
+ // Only check general file size limit (chunking handles OpenAI 25MB limit automatically)
+ if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) {
+ setGlobalError(
+ `File "${fileObject.name}" exceeds the maximum size of ${maxFileSizeMB.value} MB and was skipped.`
+ );
+ continue;
+ }
+
+ const clientId = `client-${Date.now()}-${Math.random()
+ .toString(36)
+ .substring(2, 9)}`;
+
+ // Auto-summarization will always occur for all uploads
+ const willAutoSummarize = true;
+
+ uploadQueue.value.push({
+ file: fileObject,
+ notes: notes,
+ tags: tags,
+ asrOptions: asrOptions,
+ status: "queued",
+ recordingId: null,
+ clientId: clientId,
+ error: null,
+ willAutoSummarize: willAutoSummarize,
+ });
+ filesAdded++;
+ } else if (fileObject) {
+ setGlobalError(
+ `Invalid file type "${fileObject.name}". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.`
+ );
+ }
+ }
+ if (filesAdded > 0) {
+ console.log(`Added ${filesAdded} file(s) to the queue.`);
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+ }
+ };
+
+ const resetCurrentFileProcessingState = () => {
+ if (pollInterval.value) clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ currentlyProcessingFile.value = null;
+ processingProgress.value = 0;
+ processingMessage.value = "";
+ };
+
+ const startProcessingQueue = async () => {
+ console.log("Attempting to start processing queue...");
+ if (isProcessingActive.value) {
+ console.log("Queue processor already active.");
+ return;
+ }
+
+ isProcessingActive.value = true;
+ resetCurrentFileProcessingState();
+
+ const nextFileItem = uploadQueue.value.find(
+ (item) => item.status === "queued"
+ );
+
+ if (nextFileItem) {
+ console.log(
+ `Processing next file: ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId})`
+ );
+ currentlyProcessingFile.value = nextFileItem;
+
+ // Check if this is a "reload" item (existing recording being tracked)
+ if (nextFileItem.clientId.startsWith("reload-")) {
+ // Skip upload, go directly to polling existing recording
+ console.log(
+ `Skipping upload for existing recording: ${nextFileItem.recordingId}`
+ );
+ nextFileItem.status = "processing";
+ startStatusPolling(nextFileItem, nextFileItem.recordingId);
+ return;
+ }
+
+ nextFileItem.status = "uploading";
+ processingMessage.value = "Preparing upload...";
+ processingProgress.value = 5;
+
+ try {
+ const formData = new FormData();
+ formData.append("file", nextFileItem.file);
+ if (nextFileItem.notes) {
+ formData.append("notes", nextFileItem.notes);
+ }
+
+ // Add tags if selected (multiple tags)
+ // Use tags from the queue item if available, otherwise use global selectedTagIds
+ const tagsToUse = nextFileItem.tags || selectedTags.value || [];
+ tagsToUse.forEach((tag, index) => {
+ const tagId = tag.id || tag; // Handle both tag objects and tag IDs
+ formData.append(`tag_ids[${index}]`, tagId);
+ });
+
+ // Add ASR advanced options if ASR endpoint is enabled
+ if (useAsrEndpoint.value) {
+ // Use ASR options from the queue item if available, otherwise use global values
+ const asrOpts = nextFileItem.asrOptions || {};
+ const language = asrOpts.language || uploadLanguage.value;
+ const minSpeakers =
+ asrOpts.min_speakers || uploadMinSpeakers.value;
+ const maxSpeakers =
+ asrOpts.max_speakers || uploadMaxSpeakers.value;
+
+ if (language) {
+ formData.append("language", language);
+ }
+ // Only send speaker limits if they're actually set
+ if (minSpeakers && minSpeakers !== "") {
+ formData.append("min_speakers", minSpeakers.toString());
+ }
+ if (maxSpeakers && maxSpeakers !== "") {
+ formData.append("max_speakers", maxSpeakers.toString());
+ }
+ }
+
+ processingMessage.value = "Uploading file...";
+ processingProgress.value = 10;
+
+ const response = await fetch("/tool/speakr/upload", {
+ method: "POST",
+ body: formData,
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ let errorMsg =
+ data.error || `Upload failed with status ${response.status}`;
+ if (response.status === 413)
+ errorMsg =
+ data.error ||
+ `File too large. Max: ${
+ data.max_size_mb?.toFixed(0) || maxFileSizeMB.value
+ } MB.`;
+ throw new Error(errorMsg);
+ }
+
+ if (response.status === 202 && data.id) {
+ console.log(
+ `File ${nextFileItem.file.name} uploaded. Recording ID: ${data.id}. Starting status poll.`
+ );
+ nextFileItem.status = "pending";
+ nextFileItem.recordingId = data.id;
+ processingMessage.value =
+ "Upload complete. Waiting for processing...";
+ processingProgress.value = 30;
+
+ recordings.value.unshift(data);
+ totalRecordings.value++; // Update total count
+ pollProcessingStatus(nextFileItem);
+ } else {
+ throw new Error(
+ "Unexpected success response from server after upload."
+ );
+ }
+ } catch (error) {
+ console.error(
+ `Upload/Processing Error for ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId}):`,
+ error
+ );
+ nextFileItem.status = "failed";
+ nextFileItem.error = error.message;
+ const failedRecordIndex = recordings.value.findIndex(
+ (r) => r.id === nextFileItem.recordingId
+ );
+ if (failedRecordIndex !== -1) {
+ recordings.value[failedRecordIndex].status = "FAILED";
+ recordings.value[
+ failedRecordIndex
+ ].transcription = `Upload/Processing failed: ${error.message}`;
+ } else {
+ setGlobalError(
+ `Failed to process "${nextFileItem.file.name}": ${error.message}`
+ );
+ }
+
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ } else {
+ console.log("Upload queue is empty or no files are queued.");
+ isProcessingActive.value = false;
+ }
+ };
+
+ const startStatusPolling = (fileItem, recordingId) => {
+ fileItem.recordingId = recordingId;
+ pollProcessingStatus(fileItem);
+ };
+
+ const pollProcessingStatus = (fileItem) => {
+ if (pollInterval.value) clearInterval(pollInterval.value);
+
+ const recordingId = fileItem.recordingId;
+ if (!recordingId) {
+ console.error(
+ "Cannot poll status without recording ID for",
+ fileItem.file.name
+ );
+ fileItem.status = "failed";
+ fileItem.error = "Internal error: Missing recording ID for polling.";
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ nextTick(startProcessingQueue);
+ return;
+ }
+
+ processingMessage.value = "Waiting for transcription...";
+ processingProgress.value = 40;
+
+ pollInterval.value = setInterval(async () => {
+ // Check if we should stop polling
+ const shouldStopPolling =
+ !currentlyProcessingFile.value ||
+ currentlyProcessingFile.value.clientId !== fileItem.clientId ||
+ fileItem.status === "failed" ||
+ (fileItem.status === "completed" &&
+ (!fileItem.willAutoSummarize || fileItem.summaryCompleted));
+
+ if (shouldStopPolling) {
+ console.log(
+ `Polling stopped for ${fileItem.clientId} as it's no longer active or finished.`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ if (
+ currentlyProcessingFile.value &&
+ currentlyProcessingFile.value.clientId === fileItem.clientId
+ ) {
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ return;
+ }
+
+ try {
+ console.log(
+ `Polling status for recording ID: ${recordingId} (${fileItem.file.name})`
+ );
+ const response = await fetch(`/tool/speakr/status/${recordingId}`);
+ if (!response.ok)
+ throw new Error(
+ `Status check failed with status ${response.status}`
+ );
+
+ const data = await response.json();
+ const galleryIndex = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+
+ if (galleryIndex !== -1) {
+ recordings.value[galleryIndex] = data;
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data;
+ }
+ }
+
+ const previousStatus = fileItem.status;
+ fileItem.status = data.status;
+ fileItem.file.name = data.title || data.original_filename;
+
+ if (data.status === "COMPLETED") {
+ console.log(
+ `Processing COMPLETED for ${fileItem.file.name} (ID: ${recordingId})`
+ );
+
+ // If this was previously summarizing, it's now fully complete
+ if (previousStatus === "summarizing") {
+ console.log(`Auto-summary completed for ${fileItem.file.name}`);
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true;
+
+ // This is final completion - clean up immediately and synchronously
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Completed item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ // Use immediate startProcessingQueue instead of nextTick to avoid duplication
+ startProcessingQueue();
+ return; // Exit early to prevent further processing
+ }
+ // If auto-summarization will occur and hasn't started yet, wait for it
+ else if (
+ fileItem.willAutoSummarize &&
+ !fileItem.hasCheckedForAutoSummary
+ ) {
+ processingMessage.value = "Transcription complete!";
+ processingProgress.value = 85;
+ fileItem.status = "awaiting_summary"; // Use intermediate status to keep it in upload queue
+ // Don't mark as summaryCompleted yet, continue polling
+ }
+ // No auto-summarization expected, complete normally
+ else {
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true; // No summary expected, so consider it complete
+
+ // Complete immediately for files without auto-summarization
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Completed item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ startProcessingQueue();
+ return; // Exit early to prevent further processing
+ }
+
+ // For files with auto-summarization, mark that they've been checked and continue polling
+ if (
+ fileItem.willAutoSummarize &&
+ !fileItem.hasCheckedForAutoSummary
+ ) {
+ fileItem.hasCheckedForAutoSummary = true;
+ fileItem.autoSummaryStartTime = Date.now();
+ console.log(
+ `Auto-summary expected for ${fileItem.file.name}, continuing to poll...`
+ );
+ // Don't complete yet, continue polling
+ return;
+ }
+
+ // If we have auto-summarization and we've been waiting, check if we should timeout
+ if (
+ fileItem.willAutoSummarize &&
+ fileItem.hasCheckedForAutoSummary
+ ) {
+ const waitTime = Date.now() - fileItem.autoSummaryStartTime;
+ const maxWaitTime = 60000; // 60 seconds
+
+ if (waitTime > maxWaitTime) {
+ // Timeout - complete the process
+ console.log(
+ `Auto-summary timeout for ${fileItem.file.name}, completing...`
+ );
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true; // Mark as complete due to timeout
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Timed-out item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ startProcessingQueue();
+ } else {
+ // Still waiting for auto-summary, continue polling
+ return;
+ }
+ }
+
+ // Normal completion path (no auto-summary check needed)
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Remove this item from uploadQueue immediately to prevent duplication
+ const queueIndex = uploadQueue.value.findIndex(
+ (item) => item.clientId === fileItem.clientId
+ );
+ if (queueIndex !== -1) {
+ uploadQueue.value.splice(queueIndex, 1);
+ console.log(
+ `Removed completed item ${fileItem.clientId} from queue immediately`
+ );
+ }
+
+ startProcessingQueue();
+ } else if (data.status === "FAILED") {
+ console.log(
+ `Processing FAILED for ${fileItem.file.name} (ID: ${recordingId})`
+ );
+ processingMessage.value = "Processing failed.";
+ processingProgress.value = 100;
+ fileItem.status = "failed";
+ fileItem.error =
+ data.transcription ||
+ data.summary ||
+ "Processing failed on server.";
+ setGlobalError(
+ `Processing failed for "${data.title || fileItem.file.name}".`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ } else if (data.status === "PROCESSING") {
+ // Check if this file will actually use chunking based on all conditions:
+ // 1. Chunking must be enabled in config
+ // 2. Must NOT be using ASR endpoint (ASR handles large files natively)
+ // 3. For size-based: File size must exceed the limit (can determine immediately)
+ // 4. For time-based: Can't determine client-side, but backend logs show it gets duration
+
+ const couldUseChunking =
+ chunkingEnabled.value && !useAsrEndpoint.value;
+
+ if (couldUseChunking) {
+ if (chunkingMode.value === "size") {
+ // Size-based chunking: we can determine definitively
+ const chunkThresholdBytes = chunkingLimit.value * 1024 * 1024;
+ const willUseChunking =
+ fileItem.file.size > chunkThresholdBytes;
+
+ if (willUseChunking) {
+ processingMessage.value =
+ "Processing large file (chunking in progress)...";
+ // If auto-summarization will occur, cap at 70%, otherwise 80%
+ const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 3
+ )
+ );
+ } else {
+ processingMessage.value = "转录进行中...";
+ // If auto-summarization will occur, cap at 65%, otherwise 75%
+ const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 5
+ )
+ );
+ }
+ } else {
+ // Duration-based chunking: Backend determines this after getting duration
+ // Show a neutral processing message since we can't know client-side
+ processingMessage.value =
+ "Processing file (chunking determined server-side)...";
+ const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 3
+ )
+ );
+ }
+ } else {
+ processingMessage.value = "转录进行中...";
+ const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 5
+ )
+ );
+ }
+ } else if (data.status === "SUMMARIZING") {
+ console.log(`Auto-summary started for ${fileItem.file.name}`);
+ processingMessage.value = "Generating summary...";
+ processingProgress.value = 90;
+ fileItem.status = "summarizing";
+ } else {
+ processingMessage.value = "Waiting in queue...";
+ processingProgress.value = 45;
+ }
+ } catch (error) {
+ console.error(
+ `Polling Error for ${fileItem.file.name} (ID: ${recordingId}):`,
+ error
+ );
+ fileItem.status = "failed";
+ fileItem.error = `Error checking status: ${error.message}`;
+ setGlobalError(
+ `Error checking status for "${fileItem.file.name}": ${error.message}.`
+ );
+ const galleryIndex = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+ if (galleryIndex !== -1)
+ recordings.value[galleryIndex].status = "FAILED";
+
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ }, 5000);
+ };
+
+ //--------------------文件上传与上传队列 end------------------
+
+ //------------------- 录音与标签数据加载 start------------------
+ // --- Data Loading ---
+ const loadRecordings = async (
+ page = 1,
+ append = false,
+ searchQuery = ""
+ ) => {
+ globalError.value = null;
+ if (!append) {
+ isLoadingRecordings.value = true;
+ } else {
+ isLoadingMore.value = true;
+ }
+
+ try {
+ const params = new URLSearchParams({
+ page: page.toString(),
+ per_page: perPage.value.toString(),
+ });
+
+ if (searchQuery.trim()) {
+ params.set("q", searchQuery.trim());
+ }
+
+ const response = await fetch(`/tool/speakr/api/recordings?${params}`);
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to load recordings");
+
+ // Update pagination state
+ currentPage.value = data.pagination.page;
+ totalRecordings.value = data.pagination.total;
+ totalPages.value = data.pagination.total_pages;
+ hasNextPage.value = data.pagination.has_next;
+ hasPrevPage.value = data.pagination.has_prev;
+
+ // Update recordings data
+ if (append) {
+ // Append to existing recordings (infinite scroll)
+ recordings.value = [...recordings.value, ...data.recordings];
+ } else {
+ // Replace recordings (fresh load or search)
+ recordings.value = data.recordings;
+
+ // Try to restore last selected recording
+ const lastRecordingId = localStorage.getItem(
+ "lastSelectedRecordingId"
+ );
+ if (lastRecordingId && data.recordings.length > 0) {
+ const recordingToSelect = data.recordings.find(
+ (r) => r.id == lastRecordingId
+ );
+ if (recordingToSelect) {
+ selectRecording(recordingToSelect);
+ }
+ }
+ }
+
+ // Handle incomplete recordings for processing queue
+ const incompleteRecordings = data.recordings.filter((r) =>
+ ["PENDING", "PROCESSING", "SUMMARIZING"].includes(r.status)
+ );
+ if (incompleteRecordings.length > 0 && !isProcessingActive.value) {
+ console.warn(
+ `Found ${incompleteRecordings.length} incomplete recording(s) on load.`
+ );
+ for (const recording of incompleteRecordings) {
+ let queueItem = uploadQueue.value.find(
+ (item) => item.recordingId === recording.id
+ );
+ if (!queueItem) {
+ queueItem = {
+ file: {
+ name: recording.title || `Recording ${recording.id}`,
+ size: recording.file_size,
+ },
+ status: "queued",
+ recordingId: recording.id,
+ clientId: `reload-${recording.id}`,
+ error: null,
+ };
+ uploadQueue.value.unshift(queueItem);
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Load Recordings Error:", error);
+ setGlobalError(`Failed to load recordings: ${error.message}`);
+ if (!append) {
+ recordings.value = [];
+ }
+ } finally {
+ isLoadingRecordings.value = false;
+ isLoadingMore.value = false;
+ }
+ };
+
+ // Load more recordings (infinite scroll)
+ const loadMoreRecordings = async () => {
+ if (!hasNextPage.value || isLoadingMore.value) return;
+ await loadRecordings(currentPage.value + 1, true, searchQuery.value);
+ };
+
+ // Search with debouncing
+ const performSearch = async (query = "") => {
+ currentPage.value = 1;
+ await loadRecordings(1, false, query);
+ };
+
+ // Debounced search function
+ const debouncedSearch = (query) => {
+ if (searchDebounceTimer.value) {
+ clearTimeout(searchDebounceTimer.value);
+ }
+ searchDebounceTimer.value = setTimeout(() => {
+ performSearch(query);
+ }, 300); // 300ms debounce
+ };
+
+ const loadTags = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/tags");
+ if (response.ok) {
+ availableTags.value = await response.json();
+ } else {
+ console.warn("Failed to load tags:", response.status);
+ availableTags.value = [];
+ }
+ } catch (error) {
+ console.warn("Error loading tags:", error);
+ availableTags.value = [];
+ }
+ };
+
+ const addTagToSelection = (tagId) => {
+ if (!selectedTagIds.value.includes(tagId)) {
+ selectedTagIds.value.push(tagId);
+ applyTagDefaults();
+ }
+ };
+
+ const removeTagFromSelection = (tagId) => {
+ const index = selectedTagIds.value.indexOf(tagId);
+ if (index > -1) {
+ selectedTagIds.value.splice(index, 1);
+ applyTagDefaults();
+ }
+ };
+
+ const applyTagDefaults = () => {
+ // Apply defaults from the first selected tag (highest priority)
+ const firstTag = selectedTags.value[0];
+ if (firstTag && useAsrEndpoint.value) {
+ if (firstTag.default_language) {
+ uploadLanguage.value = firstTag.default_language;
+ }
+ if (firstTag.default_min_speakers) {
+ uploadMinSpeakers.value = firstTag.default_min_speakers;
+ }
+ if (firstTag.default_max_speakers) {
+ uploadMaxSpeakers.value = firstTag.default_max_speakers;
+ }
+ }
+ };
+
+ // Legacy function for backward compatibility
+ const onTagSelected = applyTagDefaults;
+
+ // Tag helper functions
+ const getRecordingTags = (recording) => {
+ if (!recording || !recording.tags) return [];
+ return recording.tags || [];
+ };
+
+ const getAvailableTagsForRecording = (recording) => {
+ if (!recording || !availableTags.value) return [];
+ const recordingTagIds = getRecordingTags(recording).map(
+ (tag) => tag.id
+ );
+ return availableTags.value.filter(
+ (tag) => !recordingTagIds.includes(tag.id)
+ );
+ };
+
+ // Computed property for filtered available tags in the modal
+ const filteredAvailableTagsForModal = computed(() => {
+ if (!editingRecording.value) return [];
+ const availableTags = getAvailableTagsForRecording(
+ editingRecording.value
+ );
+ if (!tagSearchFilter.value) return availableTags;
+
+ const filter = tagSearchFilter.value.toLowerCase();
+ return availableTags.filter((tag) =>
+ tag.name.toLowerCase().includes(filter)
+ );
+ });
+ const filterByTag = (tag) => {
+ // Use advanced filter instead of text-based
+ filterTags.value = [tag.id];
+ applyAdvancedFilters();
+ };
+ const clearTagFilter = () => {
+ searchQuery.value = "";
+ clearAllFilters();
+ };
+
+ // Build search query from advanced filters
+ const buildSearchQuery = () => {
+ let query = [];
+
+ // Add text search
+ if (filterTextQuery.value.trim()) {
+ query.push(filterTextQuery.value.trim());
+ }
+
+ // Add tag filters
+ if (filterTags.value.length > 0) {
+ const tagNames = filterTags.value
+ .map((tagId) => {
+ const tag = availableTags.value.find((t) => t.id === tagId);
+ return tag ? `tag:${tag.name.replace(/\s+/g, "_")}` : "";
+ })
+ .filter(Boolean);
+ query.push(...tagNames);
+ }
+
+ // Add date filter
+ if (filterDatePreset.value) {
+ query.push(`date:${filterDatePreset.value}`);
+ } else if (filterDateRange.value.start || filterDateRange.value.end) {
+ // Custom date range - send as separate parameters
+ // Will be handled differently in the backend
+ if (filterDateRange.value.start) {
+ query.push(`date_from:${filterDateRange.value.start}`);
+ }
+ if (filterDateRange.value.end) {
+ query.push(`date_to:${filterDateRange.value.end}`);
+ }
+ }
+
+ return query.join(" ");
+ };
+
+ const applyAdvancedFilters = () => {
+ searchQuery.value = buildSearchQuery();
+ };
+
+ const clearAllFilters = () => {
+ filterTags.value = [];
+ filterDateRange.value = { start: "", end: "" };
+ filterDatePreset.value = "";
+ filterTextQuery.value = "";
+ searchQuery.value = "";
+ };
+
+ const editRecordingTags = (recording) => {
+ editingRecording.value = recording;
+ selectedNewTagId.value = "";
+ showEditTagsModal.value = true;
+ };
+
+ const closeEditTagsModal = () => {
+ showEditTagsModal.value = false;
+ editingRecording.value = null;
+ selectedNewTagId.value = "";
+ tagSearchFilter.value = ""; // Clear the filter when closing
+ };
+
+ const addTagToRecording = async (tagId = null) => {
+ // Use provided tagId or fall back to selectedNewTagId
+ const tagToAddId = tagId || selectedNewTagId.value;
+ if (!tagToAddId || !editingRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/api/recordings/${editingRecording.value.id}/tags`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken,
+ },
+ body: JSON.stringify({ tag_id: tagToAddId }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to add tag");
+ }
+
+ // Update local recording data
+ const tagToAdd = availableTags.value.find(
+ (tag) => tag.id == tagToAddId
+ );
+ if (tagToAdd) {
+ if (!editingRecording.value.tags) {
+ editingRecording.value.tags = [];
+ }
+ editingRecording.value.tags.push(tagToAdd);
+
+ // Also update in recordings list if it's a different object
+ const recordingInList = recordings.value.find(
+ (r) => r.id === editingRecording.value.id
+ );
+ if (recordingInList && recordingInList !== editingRecording.value) {
+ if (!recordingInList.tags) {
+ recordingInList.tags = [];
+ }
+ recordingInList.tags.push(tagToAdd);
+ }
+ }
+
+ selectedNewTagId.value = "";
+ } catch (error) {
+ console.error("Error adding tag to recording:", error);
+ setGlobalError(`Failed to add tag: ${error.message}`);
+ }
+ };
+
+ const removeTagFromRecording = async (tagId) => {
+ if (!editingRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/api/recordings/${editingRecording.value.id}/tags/${tagId}`,
+ {
+ method: "DELETE",
+ headers: {
+ "X-CSRFToken": csrfToken,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to remove tag");
+ }
+
+ // Update local recording data
+ editingRecording.value.tags = editingRecording.value.tags.filter(
+ (tag) => tag.id !== tagId
+ );
+
+ // Also update in recordings list if it's a different object
+ const recordingInList = recordings.value.find(
+ (r) => r.id === editingRecording.value.id
+ );
+ if (
+ recordingInList &&
+ recordingInList !== editingRecording.value &&
+ recordingInList.tags
+ ) {
+ recordingInList.tags = recordingInList.tags.filter(
+ (tag) => tag.id !== tagId
+ );
+ }
+ } catch (error) {
+ console.error("Error removing tag from recording:", error);
+ setGlobalError(`Failed to remove tag: ${error.message}`);
+ }
+ };
+
+ //--------------------录音与标签数据加载 end------------------
+
+ //------------------- 录音控制与实时采集 start------------------
+ // --- Audio Recording ---
+ const startRecordingWithDisclaimer = async (mode = "microphone") => {
+ debugger;
+ // Check if disclaimer needs to be shown
+ if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) {
+ pendingRecordingMode.value = mode;
+ showRecordingDisclaimerModal.value = true;
+ } else {
+ // No disclaimer configured, proceed directly
+ await startRecordingActual(mode);
+ }
+ };
+
+ const acceptRecordingDisclaimer = async () => {
+ showRecordingDisclaimerModal.value = false;
+ if (pendingRecordingMode.value) {
+ await startRecordingActual(pendingRecordingMode.value);
+ pendingRecordingMode.value = null;
+ }
+ };
+
+ const cancelRecordingDisclaimer = () => {
+ showRecordingDisclaimerModal.value = false;
+ pendingRecordingMode.value = null;
+ };
+
+ const startRecording = startRecordingWithDisclaimer; // Maintain backward compatibility
+ let systemStream = null;
+ let micStream = null;
+
+ const startRecordingActual = async (mode = "microphone") => {
+ recordingMode.value = mode;
+
+ try {
+ // Load tags if not already loaded
+ if (availableTags.value.length === 0) {
+ await loadTags();
+ }
+
+ // Reset state
+ audioChunks.value = [];
+ audioBlobURL.value = null;
+ recordingNotes.value = "";
+ activeStreams.value = [];
+ // Clear previous tag selection and ASR options for fresh recording
+ selectedTags.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+
+ // Reset draft-related state for fresh recording (Bug fix: prevents new recordings from being merged into old drafts)
+ currentDraftId.value = null;
+ isPaused.value = false;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ asrResult.value = '';
+ asrResultOnline.value = '';
+ asrResultOffline.value = '';
+ recordingTime.value = 0;
+ isStoppingRecording.value = false;
+ pendingCorrectionCount = 0;
+ isEditingTranscription.value = false;
+ startNewRealtimeEpoch({ clearTimestampBarrier: true });
+
+ let combinedStream = null;
+
+ // Get microphone stream if needed
+ if (mode === "microphone" || mode === "both") {
+ if (!canRecordAudio.value) {
+ throw new Error(
+ "Microphone recording is not supported by your browser or permission was denied."
+ );
+ }
+ micStream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+ activeStreams.value.push(micStream);
+ showToast("Microphone access granted", "fa-microphone");
+ }
+
+ // Get system audio stream if needed
+ if (mode === "system" || mode === "both") {
+ if (!canRecordSystemAudio.value) {
+ throw new Error(
+ "System audio recording is not supported by your browser."
+ );
+ }
+ try {
+ systemStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: true,
+ video: true, // Request video to enable system audio sharing prompt
+ });
+
+ // Check if the user actually granted audio permission
+ if (systemStream.getAudioTracks().length === 0) {
+ // Stop the video track if it exists, since we didn't get audio
+ systemStream.getVideoTracks().forEach((track) => track.stop());
+ throw new Error(
+ 'System audio permission was not granted. Please ensure you check the "Share system audio" box.'
+ );
+ }
+
+ activeStreams.value.push(systemStream);
+ showToast("System audio access granted", "fa-desktop");
+ } catch (err) {
+ if (mode === "system") {
+ throw err; // Re-throw the original error to be caught by the outer handler
+ } else {
+ // For 'both' mode, fall back to microphone only
+ showToast(
+ "System audio denied, using microphone only",
+ "fa-exclamation-triangle"
+ );
+ mode = "microphone";
+ systemStream = null; // Make sure systemStream is null so it's not used later
+ }
+ }
+ }
+
+ // Combine streams if we have both
+ if (micStream && systemStream) {
+ try {
+ audioContext.value = new (window.AudioContext ||
+ window.webkitAudioContext)();
+
+ const micSource =
+ audioContext.value.createMediaStreamSource(micStream);
+ const systemSource =
+ audioContext.value.createMediaStreamSource(systemStream);
+ const destination =
+ audioContext.value.createMediaStreamDestination();
+
+ micSource.connect(destination);
+ systemSource.connect(destination);
+
+ // Create a new MediaStream with only the audio track from the destination
+ const mixedAudioTrack = destination.stream.getAudioTracks()[0];
+ if (!mixedAudioTrack) {
+ throw new Error("Failed to create mixed audio track");
+ }
+
+ combinedStream = new MediaStream([mixedAudioTrack]);
+
+ // Verify the stream has audio tracks
+ if (combinedStream.getAudioTracks().length === 0) {
+ throw new Error("Combined stream has no audio tracks");
+ }
+
+ console.log(
+ "Successfully created combined audio stream with",
+ combinedStream.getAudioTracks().length,
+ "audio tracks"
+ );
+ showToast(
+ "Recording both microphone and system audio",
+ "fa-microphone"
+ );
+ } catch (error) {
+ console.error("Failed to combine audio streams:", error);
+ // Fallback to system audio only
+ if (audioContext.value) {
+ audioContext.value
+ .close()
+ .catch((e) =>
+ console.error("Error closing AudioContext:", e)
+ );
+ audioContext.value = null;
+ }
+ combinedStream = systemStream;
+ showToast(
+ "Failed to combine audio, using system audio only",
+ "fa-exclamation-triangle"
+ );
+ }
+ } else if (systemStream) {
+ // For system audio only, create a new stream with just the audio tracks
+ const audioTracks = systemStream.getAudioTracks();
+ if (audioTracks.length > 0) {
+ combinedStream = new MediaStream(audioTracks);
+ console.log(
+ "Created system audio stream with",
+ audioTracks.length,
+ "audio tracks"
+ );
+ showToast("Recording system audio only", "fa-desktop");
+ } else {
+ throw new Error("System stream has no audio tracks");
+ }
+ } else if (micStream) {
+ combinedStream = micStream;
+ showToast("Recording microphone only", "fa-microphone");
+ } else {
+ throw new Error("No audio streams available for recording.");
+ }
+
+ // Setup MediaRecorder with optimized settings for transcription
+ const getOptimizedRecorderOptions = () => {
+ // Define transcription-optimized options in order of preference
+ const optionsList = [
+ // Best option: Opus codec at 32kbps (excellent compression for speech)
+ {
+ mimeType: "audio/webm;codecs=opus",
+ audioBitsPerSecond: 32000,
+ description: "Optimized (32kbps Opus)",
+ },
+ // Good option: Opus at 64kbps (slightly higher quality)
+ {
+ mimeType: "audio/webm;codecs=opus",
+ audioBitsPerSecond: 64000,
+ description: "Good quality (64kbps Opus)",
+ },
+ // Fallback 1: WebM with reduced bitrate
+ {
+ mimeType: "audio/webm",
+ audioBitsPerSecond: 64000,
+ description: "Standard WebM (64kbps)",
+ },
+ // Fallback 2: MP4 with reduced bitrate
+ {
+ mimeType: "audio/mp4",
+ audioBitsPerSecond: 64000,
+ description: "Standard MP4 (64kbps)",
+ },
+ // Fallback 3: Just the codec without bitrate
+ {
+ mimeType: "audio/webm;codecs=opus",
+ description: "Opus codec (default bitrate)",
+ },
+ // Fallback 4: Just WebM without bitrate
+ {
+ mimeType: "audio/webm",
+ description: "WebM (default bitrate)",
+ },
+ ];
+
+ // Test each option to find the first supported one
+ for (const options of optionsList) {
+ if (MediaRecorder.isTypeSupported(options.mimeType)) {
+ console.log(
+ `Testing audio recording option: ${options.description} - ${options.mimeType}`
+ );
+ return options;
+ }
+ }
+
+ // Final fallback: no options (browser default)
+ console.log("Using browser default audio recording settings");
+ return null;
+ };
+
+ // Try to create MediaRecorder with progressive fallbacks
+ let mediaRecorderCreated = false;
+ let recorderOptions = getOptimizedRecorderOptions();
+ let attemptCount = 0;
+
+ while (!mediaRecorderCreated && attemptCount < 5) {
+ try {
+ attemptCount++;
+
+ if (recorderOptions && attemptCount === 1) {
+ // First attempt: try with full options
+ console.log(
+ `Attempt ${attemptCount}: Trying ${recorderOptions.description}`
+ );
+ mediaRecorder.value = new MediaRecorder(
+ combinedStream,
+ recorderOptions
+ );
+ actualBitrate.value =
+ recorderOptions.audioBitsPerSecond || 64000;
+ showToast(
+ `Recording: ${recorderOptions.description}`,
+ "fa-compress-alt",
+ 3000
+ );
+ } else if (
+ recorderOptions &&
+ attemptCount === 2 &&
+ recorderOptions.audioBitsPerSecond
+ ) {
+ // Second attempt: try same mime type without bitrate constraint
+ console.log(
+ `Attempt ${attemptCount}: Trying ${recorderOptions.mimeType} without bitrate`
+ );
+ mediaRecorder.value = new MediaRecorder(combinedStream, {
+ mimeType: recorderOptions.mimeType,
+ });
+ actualBitrate.value = 128000; // Estimate
+ showToast(
+ `Recording: ${recorderOptions.mimeType} (default bitrate)`,
+ "fa-compress-alt",
+ 3000
+ );
+ } else {
+ // Final attempt: browser default
+ console.log(`Attempt ${attemptCount}: Using browser default`);
+ mediaRecorder.value = new MediaRecorder(combinedStream);
+ actualBitrate.value = 128000; // Estimate browser default
+ showToast(
+ "Recording with browser default settings",
+ "fa-microphone",
+ 3000
+ );
+ }
+
+ mediaRecorderCreated = true;
+ console.log(
+ `MediaRecorder created successfully on attempt ${attemptCount}`
+ );
+ } catch (error) {
+ console.warn(
+ `MediaRecorder creation attempt ${attemptCount} failed:`,
+ error
+ );
+ mediaRecorder.value = null;
+
+ if (attemptCount >= 5) {
+ throw new Error(
+ `Failed to create MediaRecorder after ${attemptCount} attempts. Last error: ${error.message}`
+ );
+ }
+ }
+ }
+
+ if (!mediaRecorder.value) {
+ throw new Error(
+ "Failed to create MediaRecorder with any configuration"
+ );
+ }
+
+ console.log(
+ `Recording with estimated bitrate: ${actualBitrate.value} bps`
+ );
+
+ mediaRecorder.value.ondataavailable = (event) =>
+ audioChunks.value.push(event.data);
+ mediaRecorder.value.onstop = () => {
+ const audioBlob = new Blob(audioChunks.value, {
+ type: "audio/webm",
+ });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+
+ // Stop size monitoring
+ stopSizeMonitoring();
+
+ // Stop all active streams
+ activeStreams.value.forEach((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ });
+ activeStreams.value = [];
+
+ if (audioContext.value) {
+ audioContext.value
+ .close()
+ .catch((e) => console.error("Error closing AudioContext:", e));
+ audioContext.value = null;
+ }
+ cancelAnimationFrame(animationFrameId.value);
+ clearInterval(recordingInterval.value);
+ };
+
+ // --- Visualizer Setup ---
+ if (!audioContext.value) {
+ audioContext.value = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ }
+
+ if (mode === "both" && micStream && systemStream) {
+ // Dual visualizer setup
+ micAnalyser.value = audioContext.value.createAnalyser();
+ micAnalyser.value.fftSize = 256;
+ const micSource =
+ audioContext.value.createMediaStreamSource(micStream);
+ micSource.connect(micAnalyser.value);
+
+ systemAnalyser.value = audioContext.value.createAnalyser();
+ systemAnalyser.value.fftSize = 256;
+ const systemSource =
+ audioContext.value.createMediaStreamSource(systemStream);
+ systemSource.connect(systemAnalyser.value);
+ } else {
+ // Single visualizer setup
+ const visualizerStream = micStream || systemStream;
+ if (visualizerStream) {
+ analyser.value = audioContext.value.createAnalyser();
+ analyser.value.fftSize = 256;
+ const source =
+ audioContext.value.createMediaStreamSource(visualizerStream);
+ source.connect(analyser.value);
+ }
+ }
+
+ // Start recording and timer
+ mediaRecorder.value.start();
+ isRecording.value = true;
+ recordingTime.value = 0;
+ recordingInterval.value = setInterval(
+ () => recordingTime.value++,
+ 1000
+ );
+
+ // Start size monitoring
+ startSizeMonitoring();
+
+ // Switch to recording view
+ currentView.value = "recording";
+
+ // Start visualizer(s)
+ drawVisualizers();
+
+ setGlobalError(null);
+ } catch (err) {
+ console.error("Error starting recording:", err);
+ setGlobalError(`Could not start recording: ${err.message}`);
+ isRecording.value = false;
+
+ // Clean up any streams that were created
+ activeStreams.value.forEach((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ });
+ activeStreams.value = [];
+ }
+ };
+
+ const stopRecording = async () => {
+ if (isStoppingRecording.value) {
+ return;
+ }
+
+ const hasRecordingSession =
+ isRecording.value ||
+ isPaused.value ||
+ audioChunks.value.length > 0 ||
+ !!currentDraftId.value ||
+ activeStreams.value.length > 0;
+
+ if (!hasRecordingSession) {
+ return;
+ }
+
+ isStoppingRecording.value = true;
+ try {
+ isRec = false;
+
+ if (webReader) {
+ webReader.wsStop();
+ }
+ if (webReader2) {
+ webReader2.wsStop();
+ }
+ if (mediaRecorder.value && isRecording.value) {
+ is_final.value = true;
+ mediaRecorder.value.stop();
+ isRecording.value = false;
+ stopSizeMonitoring();
+ cancelAnimationFrame(animationFrameId.value);
+ animationFrameId.value = null;
+ // 停止录音计时器
+ if (recordingInterval.value) {
+ clearInterval(recordingInterval.value);
+ recordingInterval.value = null;
+ }
+ }
+
+ // 等待 MediaRecorder 生成最终数据
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // 如果是草稿模式,上传最后一个片段但不要 finalize
+ if (currentDraftId.value) {
+ try {
+ // 上传最后一个片段 (only if not already paused/uploaded)
+ if (!isPaused.value && audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ const formData = new FormData();
+ formData.append('audio', audioBlob);
+ formData.append('duration', recordingTime.value - pausedDuration.value);
+ formData.append('transcription', asrResult.value + asrResultOffline.value + asrResultOnline.value || '');
+
+ await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
+ method: 'POST',
+ body: formData
+ });
+
+ // 清理 audioChunks 防止重复
+ audioChunks.value = [];
+ }
+
+ // 合并音频片段并获取预览 URL (不 finalize,只是下载合并后的音频用于预览)
+ const audioResp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/download`);
+ if (audioResp.ok) {
+ const audioBlob = await audioResp.blob();
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ }
+
+ // 保持 currentDraftId 状态,以便用户选择上传或放弃
+ isPaused.value = false;
+
+ } catch (err) {
+ console.error('Error saving final segment:', err);
+ setGlobalError(`保存片段失败: ${err.message}`);
+ }
+ } else {
+ // 非草稿模式(直接录音),创建 audioBlobURL
+ if (audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ }
+ }
+
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } finally {
+ isStoppingRecording.value = false;
+ }
+ };
+
+ // 暂停录音
+ const pauseRecording = async () => {
+ if (!isRecording.value || isPaused.value) return;
+
+ try {
+ // 首先停止音频数据发送,防止向已关闭的 WebSocket 发送数据
+ isRec = false;
+
+ // 停止 WebSocket 转录
+ if (webReader) webReader.wsStop();
+ if (webReader2) webReader2.wsStop();
+
+ // 停止当前 MediaRecorder
+ if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
+ mediaRecorder.value.stop();
+ }
+
+ // 等待 MediaRecorder 生成最终数据
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // 如果处于编辑模式,先退出编辑模式
+ if (isEditingTranscription.value) {
+ exitEditMode();
+ }
+
+ // 等待所有进行中的LLM矫正完成(最多8秒超时)
+ if (pendingCorrectionCount > 0) {
+ console.log(`等待 ${pendingCorrectionCount} 个LLM矫正完成...`);
+ const waitStart = Date.now();
+ while (pendingCorrectionCount > 0 && Date.now() - waitStart < 8000) {
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+ if (pendingCorrectionCount > 0) {
+ console.warn(`超时:仍有 ${pendingCorrectionCount} 个LLM矫正未完成`);
+ }
+ }
+
+ // 创建草稿(如果是第一次暂停)
+ if (!currentDraftId.value) {
+ const resp = await fetch('/tool/speakr/api/draft/create', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ recording_mode: recordingMode.value,
+ notes: recordingNotes.value
+ })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ currentDraftId.value = data.draft.id;
+ } else {
+ throw new Error(data.error || '创建草稿失败');
+ }
+ }
+
+ // 上传当前片段
+ if (audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ const formData = new FormData();
+ formData.append('audio', audioBlob);
+ formData.append('duration', recordingTime.value - pausedDuration.value);
+ // 发送完整转录文本(历史 + 离线修正 + 当前在线结果)以防止覆盖历史记录
+ const fullTranscription = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
+ formData.append('transcription', fullTranscription);
+
+ await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
+ method: 'POST',
+ body: formData
+ });
+
+ // Clear chunks after upload to prevent duplication
+ audioChunks.value = [];
+ }
+
+ // 更新状态
+ isPaused.value = true;
+ pausedDuration.value = recordingTime.value;
+ pausedTranscription.value = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
+ isRecording.value = false;
+
+ // 停止计时器和动画
+ clearInterval(recordingInterval.value);
+ cancelAnimationFrame(animationFrameId.value);
+ stopSizeMonitoring();
+
+ // 停止所有媒体流
+ activeStreams.value.forEach(stream => {
+ stream.getTracks().forEach(track => track.stop());
+ });
+ activeStreams.value = [];
+ micStream = null;
+ systemStream = null;
+
+ showToast('录音已暂停', 'fa-pause');
+
+ // 刷新草稿列表
+ loadDrafts();
+ } catch (err) {
+ console.error('Error pausing recording:', err);
+ setGlobalError(`暂停录音失败: ${err.message}`);
+ }
+ };
+
+ // 恢复录音
+ const resumeRecording = async () => {
+ if (!isPaused.value || !currentDraftId.value) return;
+
+ try {
+ // 重置音频块
+ audioChunks.value = [];
+
+ // Bug fix: 清空segmentList,防止旧segment与pausedTranscription(历史文本)重复渲染
+ // pausedTranscription已经包含了历史转录,segmentList应该只包含恢复后的新转录
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ pendingCorrectionCount = 0;
+ isEditingTranscription.value = false;
+ startNewRealtimeEpoch({ clearTimestampBarrier: true });
+
+ let combinedStream = null;
+ const mode = recordingMode.value;
+
+ // 重新获取媒体流
+ if (mode === 'microphone' || mode === 'both') {
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ activeStreams.value.push(micStream);
+ }
+
+ if (mode === 'system' || mode === 'both') {
+ try {
+ systemStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: true,
+ video: true
+ });
+ if (systemStream.getAudioTracks().length === 0) {
+ systemStream.getVideoTracks().forEach(track => track.stop());
+ throw new Error('未获取系统音频权限');
+ }
+ activeStreams.value.push(systemStream);
+ } catch (err) {
+ if (mode === 'system') throw err;
+ showToast('系统音频权限被拒绝,仅使用麦克风', 'fa-exclamation-triangle');
+ recordingMode.value = 'microphone';
+ }
+ }
+
+ // 组合音频流
+ if (micStream && systemStream) {
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
+ const micSource = audioContext.value.createMediaStreamSource(micStream);
+ const systemSource = audioContext.value.createMediaStreamSource(systemStream);
+ const destination = audioContext.value.createMediaStreamDestination();
+ micSource.connect(destination);
+ systemSource.connect(destination);
+ combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]);
+ } else if (systemStream) {
+ combinedStream = new MediaStream(systemStream.getAudioTracks());
+ } else if (micStream) {
+ combinedStream = micStream;
+ } else {
+ throw new Error('无可用音频流');
+ }
+
+ // 创建新的 MediaRecorder
+ mediaRecorder.value = new MediaRecorder(combinedStream, { mimeType: 'audio/webm;codecs=opus' });
+ mediaRecorder.value.ondataavailable = (event) => audioChunks.value.push(event.data);
+ mediaRecorder.value.onstop = () => {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ };
+
+ // 重新设置可视化
+ if (!audioContext.value) {
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ if (mode === 'both' && micStream && systemStream) {
+ // Dual visualizer setup
+ micAnalyser.value = audioContext.value.createAnalyser();
+ micAnalyser.value.fftSize = 256;
+ const micSource = audioContext.value.createMediaStreamSource(micStream);
+ micSource.connect(micAnalyser.value);
+
+ systemAnalyser.value = audioContext.value.createAnalyser();
+ systemAnalyser.value.fftSize = 256;
+ const systemSource = audioContext.value.createMediaStreamSource(systemStream);
+ systemSource.connect(systemAnalyser.value);
+ } else {
+ const visualizerStream = micStream || systemStream;
+ if (visualizerStream) {
+ analyser.value = audioContext.value.createAnalyser();
+ analyser.value.fftSize = 256;
+ const source = audioContext.value.createMediaStreamSource(visualizerStream);
+ source.connect(analyser.value);
+ }
+ }
+
+ // 开始录音
+ mediaRecorder.value.start();
+ isPaused.value = false;
+ isRecording.value = true;
+
+ // 恢复计时器(从暂停时间继续)
+ recordingInterval.value = setInterval(() => recordingTime.value++, 1000);
+
+ // 恢复可视化
+ drawVisualizers();
+ startSizeMonitoring();
+
+ // 恢复 WebSocket 转录
+ console.log("Resuming WebSocket connections...");
+ // 设置重置标志,清空实时转录显示
+ resetTranscriptionFlag = true;
+ asrResultOnline.value = '';
+ if (webReader) {
+ const ret = webReader.wsStart();
+ if (ret === 1) {
+ isRec = true;
+ }
+ }
+ // offline/2pass
+ if (webReader2 && typeof wssBaseUrl !== 'undefined') {
+ try {
+ let urls = `wss://${wssBaseUrl.FUNASR_WEBSOCKET_IP}/websocket_offline`;
+ webReader2.wsStart(urls);
+ isRec = true;
+ } catch(e) {
+ console.warn("Failed to restart offline WS:", e);
+ }
+ }
+
+ showToast('录音已恢复', 'fa-play');
+ } catch (err) {
+ console.error('Error resuming recording:', err);
+ setGlobalError(`恢复录音失败: ${err.message}`);
+ }
+ };
+
+ // 加载草稿列表
+ const loadDrafts = async () => {
+ try {
+ const resp = await fetch('/tool/speakr/api/draft/list');
+ const data = await resp.json();
+ draftRecordings.value = Array.isArray(data) ? data : [];
+ } catch (err) {
+ console.error('Error loading drafts:', err);
+ }
+ };
+
+ // 继续草稿录音
+ const continueDraftRecording = async (draftId) => {
+ try {
+ // 获取草稿详情
+ const resp = await fetch(`/tool/speakr/api/draft/${draftId}`);
+ const draft = await resp.json();
+
+ if (draft.error) {
+ throw new Error(draft.error);
+ }
+
+ // 恢复状态
+ currentDraftId.value = draftId;
+ recordingMode.value = draft.recording_mode || 'microphone';
+ recordingTime.value = Math.round(draft.total_duration || 0);
+ pausedDuration.value = recordingTime.value;
+ recordingNotes.value = draft.notes || '';
+ // Load historical transcription into asrResult
+ console.log('Loading draft transcription:', draft.combined_transcription ? draft.combined_transcription.length : 0, 'chars');
+ asrResult.value = draft.combined_transcription || '';
+ asrResultOnline.value = '';
+ asrResultOffline.value = '';
+ pausedTranscription.value = draft.combined_transcription || '';
+ segmentRenderStartIndex = 0;
+
+ // 设置暂停状态
+ isPaused.value = true;
+ isRecording.value = false;
+
+ // 切换到录音视图
+ currentView.value = 'recording';
+
+ showToast('草稿已加载,点击恢复继续录音', 'fa-folder-open');
+ } catch (err) {
+ console.error('Error continuing draft:', err);
+ setGlobalError(`加载草稿失败: ${err.message}`);
+ }
+ };
+
+ // 删除草稿
+ const deleteDraft = async (draftId) => {
+ if (!confirm('确定要删除这个草稿吗?此操作不可撤销。')) return false;
+
+ try {
+ const resp = await fetch(`/tool/speakr/api/draft/${draftId}`, {
+ method: 'DELETE'
+ });
+ const data = await resp.json();
+
+ if (data.success) {
+ showToast('草稿已删除', 'fa-trash');
+ loadDrafts();
+
+ // 如果删除的是当前草稿,重置状态
+ if (currentDraftId.value === draftId) {
+ currentDraftId.value = null;
+ isPaused.value = false;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentRenderStartIndex = 0;
+ }
+ return true;
+ } else {
+ throw new Error(data.error);
+ }
+ } catch (err) {
+ console.error('Error deleting draft:', err);
+ setGlobalError(`删除草稿失败: ${err.message}`);
+ return false;
+ }
+ };
+
+ // 重命名草稿
+ const startDraftRename = (draft) => {
+ editingDraftId.value = draft.id;
+ editingDraftName.value = draft.name;
+ // 等待 DOM 更新后聚焦输入框
+ nextTick(() => {
+ if (draftNameInput.value && draftNameInput.value[0]) {
+ draftNameInput.value[0].focus();
+ } else if (draftNameInput.value) {
+ draftNameInput.value.focus();
+ }
+ });
+ };
+
+ const saveDraftRename = async (draft) => {
+ if (!editingDraftId.value) return;
+
+ const newName = editingDraftName.value.trim();
+ editingDraftId.value = null; // 退出编辑模式
+
+ if (!newName || newName === draft.name) return;
+
+ try {
+ const resp = await fetch(`/tool/speakr/api/draft/${draft.id}/rename`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newName })
+ });
+ const data = await resp.json();
+
+ if (data.success) {
+ draft.name = newName;
+ showToast('重命名成功', 'fa-check');
+ } else {
+ throw new Error(data.error);
+ }
+ } catch (err) {
+ console.error('Error renaming draft:', err);
+ showToast(`重命名失败: ${err.message}`, 'fa-exclamation-circle');
+ }
+ };
+
+ const uploadRecordedAudio = async () => {
+ // 如果有草稿,先获取合并后的音频文件,然后走统一上传流程
+ if (currentDraftId.value) {
+ try {
+ showToast('正在合并录音片段...', 'fa-spinner fa-spin', 0);
+
+ // 1. 调用 finalize 获取合并后的音频 Blob(不再创建Recording)
+ const resp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/finalize`, {
+ method: 'POST'
+ });
+
+ if (!resp.ok) {
+ // 尝试解析JSON错误消息
+ try {
+ const err = await resp.json();
+ throw new Error(err.error || '合并音频失败');
+ } catch {
+ throw new Error('合并音频失败');
+ }
+ }
+
+ const audioBlob = await resp.blob();
+
+ // 2. 创建 File 对象,走统一上传流程
+ const recordedFile = new File(
+ [audioBlob],
+ createLocalizedRecordingFilename(),
+ { type: "audio/webm" }
+ );
+
+ // 3. 清理草稿状态
+ const savedNotes = recordingNotes.value;
+ const savedTags = [...selectedTags.value];
+ const savedAsrOptions = {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ };
+
+ currentDraftId.value = null;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentRenderStartIndex = 0;
+ loadDrafts();
+
+ // 4. 走统一上传流程(和直接录制一样使用 addFilesToQueue)
+ const previousQueueLength = uploadQueue.value.length;
+
+ addFilesToQueue([{
+ file: recordedFile,
+ notes: savedNotes,
+ tags: savedTags,
+ asrOptions: savedAsrOptions,
+ }]);
+
+ if (uploadQueue.value.length === previousQueueLength) {
+ return;
+ }
+
+ const queueItem = uploadQueue.value[uploadQueue.value.length - 1];
+
+ showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0);
+
+ // 5. 等待上传响应
+ const waitForServerResponse = () => {
+ return new Promise((resolve, reject) => {
+ const checkInterval = setInterval(() => {
+ if (!queueItem || queueItem.status === 'failed') {
+ clearInterval(checkInterval);
+ reject(new Error(queueItem?.error || "Upload failed"));
+ return;
+ }
+ const successStates = ['pending', 'processing', 'transcribing', 'summarizing', 'completed'];
+ if (successStates.includes(queueItem.status)) {
+ clearInterval(checkInterval);
+ resolve(true);
+ }
+ }, 200);
+ });
+ };
+
+ await waitForServerResponse();
+
+ // 6. 清理界面状态
+ if (audioBlobURL.value) {
+ URL.revokeObjectURL(audioBlobURL.value);
+ }
+ audioBlobURL.value = null;
+ audioChunks.value = [];
+ isRecording.value = false;
+ recordingNotes.value = "";
+ selectedTagIds.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+ recordingTime.value = 0;
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+
+ showToast("上传成功,正在处理中...", "fa-check-circle");
+
+ } catch (err) {
+ console.error('Error uploading draft:', err);
+ setGlobalError(`上传失败: ${err.message}`);
+ }
+ return;
+ }
+
+ if (!audioBlobURL.value) {
+ setGlobalError("No recorded audio to upload.");
+ return;
+ }
+
+ const previousQueueLength = uploadQueue.value.length;
+
+ const recordedFile = new File(
+ audioChunks.value,
+ createLocalizedRecordingFilename(),
+ { type: "audio/webm" }
+ );
+
+
+ addFilesToQueue([
+ {
+ file: recordedFile,
+ notes: recordingNotes.value,
+ tags: selectedTags.value,
+ asrOptions: {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ },
+ },
+ ]);
+
+
+ if (uploadQueue.value.length === previousQueueLength) {
+ return;
+ }
+
+ const queueItem = uploadQueue.value[uploadQueue.value.length - 1];
+
+ showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0);
+
+ const waitForServerResponse = () => {
+ return new Promise((resolve, reject) => {
+ const checkInterval = setInterval(() => {
+
+ if (!queueItem || queueItem.status === 'failed') {
+ clearInterval(checkInterval);
+ reject(new Error(queueItem?.error || "Upload failed"));
+ return;
+ }
+
+ const successStates = ['pending', 'processing', 'transcribing', 'summarizing', 'completed'];
+ if (successStates.includes(queueItem.status)) {
+ clearInterval(checkInterval);
+ resolve(true);
+ }
+
+ }, 200);
+ });
+ };
+ try {
+
+ await waitForServerResponse();
+
+ discardRecording();
+
+ if (isRec === true) {
+ if (typeof stopRecording === 'function') stopRecording();
+ }
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+
+ showToast("上传成功,正在处理中...", "fa-check-circle");
+
+ } catch (error) {
+ console.error("Server upload failed:", error);
+ showToast(`上传失败: ${error.message || '请检查网络'}`, "fa-exclamation-triangle", 4000);
+ }
+ };
+const downloadRecordedAudio = () => {
+ if (!audioBlobURL.value) {
+ showToast("No recording available to download", "fa-exclamation-circle");
+ return;
+ }
+
+ const filename = createLocalizedRecordingFilename();
+
+ const a = document.createElement('a');
+ a.style.display = 'none';
+ a.href = audioBlobURL.value;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+
+ // 清理
+ setTimeout(() => {
+ document.body.removeChild(a);
+ }, 100);
+
+ showToast("录音已开始下载", "fa-download");
+ };
+ const discardRecording = async () => {
+ if (window.tempFinalizedRecording) {
+ // 如果是因为停止草稿而生成的临时录音,放弃时需要从服务器删除
+ try {
+ await fetch(`/tool/speakr/api/draft/${window.tempFinalizedRecording.id}`, {
+ method: 'DELETE' // 注意:这里可能需要一个专门删除 Recording 的 API,因为 draft 已经转成 recording 了
+ // 但我们的后端 /api/draft/ DELETE 实际上是删除 DraftRecording
+ // 草稿 finalize 后,DraftRecording 已经被删除了,生成了 Recording
+ // 所以我们需要调用删除 Recording 的 API
+ });
+ // 暂时没有通用的删除 Recording API 暴露给这里?
+ // 我们可以复用 deleteRecording 逻辑,但这需要确认 API
+ // 让我们查看 deleteRecording 的实现
+ const resp = await fetch(`/tool/speakr/recording/${window.tempFinalizedRecording.id}`, {
+ method: 'DELETE'
+ });
+
+ } catch (e) {
+ console.error("Failed to delete temp recording", e);
+ }
+ window.tempFinalizedRecording = null;
+ }
+
+ if (currentDraftId.value) {
+ const deleted = await deleteDraft(currentDraftId.value);
+ if (!deleted) return;
+ }
+
+ if (audioBlobURL.value) {
+ URL.revokeObjectURL(audioBlobURL.value);
+ }
+ audioBlobURL.value = null;
+ audioChunks.value = [];
+ isRecording.value = false;
+ recordingTime.value = 0;
+ if (recordingInterval.value) clearInterval(recordingInterval.value);
+ recordingNotes.value = "";
+ // Clear tags and ASR options for fresh start
+ selectedTagIds.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+ };
+
+ const drawSingleVisualizer = (analyserNode, canvasElement) => {
+ if (!analyserNode || !canvasElement) return;
+
+ const bufferLength = analyserNode.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+ analyserNode.getByteFrequencyData(dataArray);
+
+ const canvasCtx = canvasElement.getContext("2d");
+ const WIDTH = canvasElement.width;
+ const HEIGHT = canvasElement.height;
+
+ canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
+
+ const barWidth = (WIDTH / bufferLength) * 1.5;
+ let barHeight;
+ let x = 0;
+
+ // Use theme-specific colors that work with all color schemes
+ const buttonColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--bg-button")
+ .trim();
+ const buttonHoverColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--bg-button-hover")
+ .trim();
+
+ // Create gradient that works in both light and dark modes
+ const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
+ if (isDarkMode.value) {
+ // Dark mode: use button colors with transparency
+ gradient.addColorStop(0, buttonColor);
+ gradient.addColorStop(0.6, buttonHoverColor);
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0.2)");
+ } else {
+ // Light mode: use more saturated colors for visibility
+ gradient.addColorStop(0, buttonColor);
+ gradient.addColorStop(0.5, buttonHoverColor);
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0.1)");
+ }
+
+ for (let i = 0; i < bufferLength; i++) {
+ barHeight = dataArray[i] / 2.5;
+ canvasCtx.fillStyle = gradient;
+ canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
+ x += barWidth + 2;
+ }
+ };
+
+ const drawVisualizers = () => {
+ if (!isRecording.value) {
+ if (animationFrameId.value) {
+ cancelAnimationFrame(animationFrameId.value);
+ animationFrameId.value = null;
+ }
+ return;
+ }
+
+ animationFrameId.value = requestAnimationFrame(drawVisualizers);
+
+ if (recordingMode.value === "both") {
+ drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
+ drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
+ } else {
+ drawSingleVisualizer(analyser.value, visualizer.value);
+ }
+ };
+
+ //--------------------录音控制与实时采集 end------------------
+
+ //------------------- 录音保存与详情管理 start------------------
+ // --- Recording Management ---
+ const saveMetadata = async (recordingDataToSave) => {
+ globalError.value = null;
+ if (!recordingDataToSave || !recordingDataToSave.id) return null;
+ console.log("Saving metadata for:", recordingDataToSave.id);
+ try {
+ const payload = {
+ id: recordingDataToSave.id,
+ title: recordingDataToSave.title,
+ participants: recordingDataToSave.participants,
+ notes: recordingDataToSave.notes,
+ summary: recordingDataToSave.summary,
+ meeting_date: recordingDataToSave.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to save metadata");
+
+ console.log("Save successful:", data.recording.id);
+ const index = recordings.value.findIndex(
+ (r) => r.id === data.recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].title = payload.title;
+ recordings.value[index].participants = payload.participants;
+ recordings.value[index].notes = payload.notes;
+ recordings.value[index].notes_html = data.recording.notes_html;
+ recordings.value[index].summary = payload.summary;
+ recordings.value[index].summary_html = data.recording.summary_html;
+ recordings.value[index].meeting_date = payload.meeting_date;
+ }
+ if (selectedRecording.value?.id === data.recording.id) {
+ selectedRecording.value.title = payload.title;
+ selectedRecording.value.participants = payload.participants;
+ selectedRecording.value.notes = payload.notes;
+ selectedRecording.value.notes_html = data.recording.notes_html;
+ selectedRecording.value.summary = payload.summary;
+ selectedRecording.value.summary_html = data.recording.summary_html;
+ selectedRecording.value.meeting_date = payload.meeting_date;
+ }
+ return data.recording;
+ } catch (error) {
+ console.error("Save Metadata Error:", error);
+ setGlobalError(`Save failed: ${error.message}`);
+ return null;
+ }
+ };
+
+ const editRecording = (recording) => {
+ editingRecording.value = JSON.parse(JSON.stringify(recording));
+ showEditModal.value = true;
+ };
+
+ const cancelEdit = () => {
+ showEditModal.value = false;
+ editingRecording.value = null;
+ };
+
+ const saveEdit = async () => {
+ const success = await saveMetadata(editingRecording.value);
+ if (success) {
+ cancelEdit();
+ }
+ };
+
+ const confirmDelete = (recording) => {
+ recordingToDelete.value = recording;
+ showDeleteModal.value = true;
+ };
+
+ const cancelDelete = () => {
+ showDeleteModal.value = false;
+ recordingToDelete.value = null;
+ };
+
+ const deleteRecording = async () => {
+ globalError.value = null;
+ if (!recordingToDelete.value) return;
+ const idToDelete = recordingToDelete.value.id;
+ const titleToDelete = recordingToDelete.value.title;
+ try {
+ const response = await fetch(`/tool/speakr/recording/${idToDelete}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to delete recording");
+
+ recordings.value = recordings.value.filter(
+ (r) => r.id !== idToDelete
+ );
+ totalRecordings.value--; // Update total count
+
+ const queueIndex = uploadQueue.value.findIndex(
+ (item) => item.recordingId === idToDelete
+ );
+ if (queueIndex !== -1) {
+ const deletedItem = uploadQueue.value.splice(queueIndex, 1)[0];
+ console.log(`Removed item ${deletedItem.clientId} from queue.`);
+ if (
+ currentlyProcessingFile.value?.clientId === deletedItem.clientId
+ ) {
+ console.log(
+ `Deleting currently processing file: ${titleToDelete}. Stopping poll and moving to next.`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ }
+
+ if (selectedRecording.value?.id === idToDelete)
+ selectedRecording.value = null;
+ cancelDelete();
+ console.log(
+ `Successfully deleted recording ${idToDelete} (${titleToDelete})`
+ );
+ } catch (error) {
+ console.error("Delete Error:", error);
+ setGlobalError(
+ `Failed to delete recording "${titleToDelete}": ${error.message}`
+ );
+ cancelDelete();
+ }
+ };
+
+ //--------------------录音保存与详情管理 end------------------
+
+ //------------------- 详情页内联编辑 start------------------
+ // --- Inline Editing ---
+ const toggleEditParticipants = () => {
+ editingParticipants.value = !editingParticipants.value;
+ if (!editingParticipants.value) {
+ saveInlineEdit("participants");
+ }
+ };
+
+ const toggleEditMeetingDate = () => {
+ editingMeetingDate.value = !editingMeetingDate.value;
+ if (!editingMeetingDate.value) {
+ saveInlineEdit("meeting_date");
+ }
+ };
+
+ const toggleEditSummary = () => {
+ editingSummary.value = !editingSummary.value;
+ if (editingSummary.value) {
+ // Store current content in temp variable
+ tempSummaryContent.value = selectedRecording.value.summary || "";
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ };
+
+ const cancelEditSummary = () => {
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ editingSummary.value = false;
+ // Restore original content
+ if (selectedRecording.value) {
+ selectedRecording.value.summary = tempSummaryContent.value;
+ }
+ };
+
+ const saveEditSummary = async () => {
+ if (summaryMarkdownEditorInstance.value) {
+ selectedRecording.value.summary =
+ summaryMarkdownEditorInstance.value.value();
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ editingSummary.value = false;
+ await saveInlineEdit("summary");
+ };
+
+ const toggleEditNotes = () => {
+ editingNotes.value = !editingNotes.value;
+ if (editingNotes.value) {
+ // Store current content in temp variable
+ tempNotesContent.value = selectedRecording.value.notes || "";
+ // Initialize markdown editor when entering edit mode
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ };
+
+ const cancelEditNotes = () => {
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ editingNotes.value = false;
+ // Restore original content
+ if (selectedRecording.value) {
+ selectedRecording.value.notes = tempNotesContent.value;
+ }
+ };
+
+ const saveEditNotes = async () => {
+ if (markdownEditorInstance.value) {
+ // Get the markdown content from the editor
+ selectedRecording.value.notes = markdownEditorInstance.value.value();
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ editingNotes.value = false;
+ await saveInlineEdit("notes");
+ };
+
+ const clickToEditNotes = () => {
+ // Allow clicking on empty notes area to start editing
+ if (
+ !editingNotes.value &&
+ (!selectedRecording.value?.notes ||
+ selectedRecording.value.notes.trim() === "")
+ ) {
+ toggleEditNotes();
+ }
+ };
+
+ const clickToEditSummary = () => {
+ // Allow clicking on empty summary area to start editing
+ if (
+ !editingSummary.value &&
+ (!selectedRecording.value?.summary ||
+ selectedRecording.value.summary.trim() === "")
+ ) {
+ toggleEditSummary();
+ }
+ };
+
+ const autoSaveNotes = async () => {
+ if (markdownEditorInstance.value && editingNotes.value) {
+ // Just save the content to the model, don't exit edit mode
+ selectedRecording.value.notes = markdownEditorInstance.value.value();
+ // Silently save to backend without changing UI state
+ try {
+ const payload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (response.ok && data.recording) {
+ // Update the HTML rendered versions if they exist
+ if (data.recording.notes_html) {
+ selectedRecording.value.notes_html = data.recording.notes_html;
+ }
+ } else {
+ console.error("Failed to auto-save notes");
+ }
+ } catch (error) {
+ console.error("Error auto-saving notes:", error);
+ }
+ }
+ };
+
+ const autoSaveSummary = async () => {
+ if (summaryMarkdownEditorInstance.value && editingSummary.value) {
+ // Just save the content to the model, don't exit edit mode
+ selectedRecording.value.summary =
+ summaryMarkdownEditorInstance.value.value();
+ // Silently save to backend without changing UI state
+ try {
+ const payload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (response.ok && data.recording) {
+ // Update the HTML rendered versions if they exist
+ if (data.recording.summary_html) {
+ selectedRecording.value.summary_html =
+ data.recording.summary_html;
+ }
+ } else {
+ console.error("Failed to auto-save summary");
+ }
+ } catch (error) {
+ console.error("Error auto-saving summary:", error);
+ }
+ }
+ };
+
+ const initializeMarkdownEditor = () => {
+ if (!notesMarkdownEditor.value) return;
+
+ try {
+ markdownEditorInstance.value = new EasyMDE({
+ element: notesMarkdownEditor.value,
+ spellChecker: false,
+ autofocus: true,
+ placeholder: "以Markdown格式输入笔记...",
+ initialValue: selectedRecording.value?.notes || "",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "image",
+ "|",
+ "preview",
+ "side-by-side",
+ "fullscreen",
+ "|",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ });
+
+ // Add auto-save functionality
+ markdownEditorInstance.value.codemirror.on("change", () => {
+ if (autoSaveTimer.value) {
+ clearTimeout(autoSaveTimer.value);
+ }
+ autoSaveTimer.value = setTimeout(() => {
+ autoSaveNotes();
+ }, autoSaveDelay);
+ });
+ } catch (error) {
+ console.error("Failed to initialize markdown editor:", error);
+ // Fallback to regular textarea editing
+ editingNotes.value = true;
+ }
+ };
+ const getBaseInfo = async () => {
+ try {
+ const response = await fetch(`getUserConfig`, {
+ method: "GET",
+ });
+ const data = await response.json();
+ if (data.LEADER_SPEECH_BUTTON) {
+ baseInfo.value = {
+ name: data.LEADER_SPEECH_BUTTON,
+ fontSize: data.LEADER_SPEECH_TEXT_SIZE,
+ };
+ }
+ configwords = data.END_PHRASE_PATTERNS || "";
+ } catch (error) {
+ console.error("获取基础配置出错:", error);
+ configwords = "";
+ }
+ };
+ const initializeRecordingMarkdownEditor = () => {
+ if (!recordingNotesEditor.value) {
+ console.log("Recording notes editor ref not found");
+ return;
+ }
+
+ // Check if EasyMDE is available
+ if (typeof EasyMDE === "undefined") {
+ console.error("EasyMDE is not loaded");
+ return;
+ }
+
+ // Clean up existing instance if any
+ if (recordingMarkdownEditorInstance.value) {
+ recordingMarkdownEditorInstance.value.toTextArea();
+ recordingMarkdownEditorInstance.value = null;
+ }
+
+ try {
+ console.log("Initializing recording markdown editor");
+ recordingMarkdownEditorInstance.value = new EasyMDE({
+ element: recordingNotesEditor.value,
+ spellChecker: false,
+ autofocus: false,
+ placeholder: "Type your notes in Markdown format...",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "|",
+ "preview",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ initialValue: recordingNotes.value || "",
+ maxHeight: "300px", // Add height constraint to prevent unlimited growth
+ minHeight: "150px", // Minimum height for usability
+ });
+
+ // Sync changes back to the reactive variable
+ recordingMarkdownEditorInstance.value.codemirror.on("change", () => {
+ recordingNotes.value =
+ recordingMarkdownEditorInstance.value.value();
+ });
+
+ console.log("Recording markdown editor initialized successfully");
+ } catch (error) {
+ console.error(
+ "Failed to initialize recording markdown editor:",
+ error
+ );
+ // Keep as regular textarea if EasyMDE fails
+ }
+ };
+ // 识别启动、停止、清空操作
+
+ const toggleTranscriptionViewMode = () => {
+ transcriptionViewMode.value =
+ transcriptionViewMode.value === "simple" ? "bubble" : "simple";
+ localStorage.setItem(
+ "transcriptionViewMode",
+ transcriptionViewMode.value
+ );
+ };
+
+ const toggleChatMaximize = () => {
+ if (isChatMaximized.value) {
+ // If maximized, restore to normal state
+ isChatMaximized.value = false;
+ } else {
+ // If not maximized, maximize and ensure chat is open
+ isChatMaximized.value = true;
+ if (!showChat.value) {
+ showChat.value = true;
+ }
+ }
+ };
+
+ const saveInlineEdit = async (field) => {
+ if (!selectedRecording.value) return;
+
+ const fullPayload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+
+ try {
+ const updatedRecording = await saveMetadata(fullPayload);
+ if (updatedRecording) {
+ if (field === "notes") {
+ selectedRecording.value.notes_html = updatedRecording.notes_html;
+ } else if (field === "summary") {
+ selectedRecording.value.summary_html =
+ updatedRecording.summary_html;
+ }
+
+ switch (field) {
+ case "participants":
+ editingParticipants.value = false;
+ break;
+ case "meeting_date":
+ editingMeetingDate.value = false;
+ break;
+ case "summary":
+ editingSummary.value = false;
+ break;
+ case "notes":
+ editingNotes.value = false;
+ break;
+ }
+ showToast(
+ `${
+ field.charAt(0).toUpperCase() + field.slice(1).replace("_", " ")
+ } updated successfully`
+ );
+ }
+ } catch (error) {
+ console.error(`Save ${field} Error:`, error);
+ setGlobalError(`Failed to save ${field}: ${error.message}`);
+ }
+ };
+
+ //--------------------详情页内联编辑 end------------------
+
+ //------------------- 聊天问答功能 start------------------
+ // --- Chat Functionality ---
+ // Helper function to check if chat is scrolled to bottom (within bottom 5%)
+ const isChatScrolledToBottom = () => {
+ if (!chatMessagesRef.value) return true;
+ const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
+ const scrollableHeight = scrollHeight - clientHeight;
+ if (scrollableHeight <= 0) return true; // No scrolling possible
+ const scrollPercentage = scrollTop / scrollableHeight;
+ return scrollPercentage >= 0.95; // Within bottom 5%
+ };
+
+ // Helper function to scroll chat to bottom with smooth behavior
+ const scrollChatToBottom = () => {
+ if (chatMessagesRef.value) {
+ requestAnimationFrame(() => {
+ if (chatMessagesRef.value) {
+ chatMessagesRef.value.scrollTop =
+ chatMessagesRef.value.scrollHeight;
+ }
+ });
+ }
+ };
+
+ const sendChatMessage = async () => {
+ if (
+ !chatInput.value.trim() ||
+ isChatLoading.value ||
+ !selectedRecording.value ||
+ selectedRecording.value.status !== "COMPLETED"
+ ) {
+ return;
+ }
+
+ const message = chatInput.value.trim();
+
+ if (!Array.isArray(chatMessages.value)) {
+ chatMessages.value = [];
+ }
+
+ chatMessages.value.push({ role: "user", content: message });
+ chatInput.value = "";
+ isChatLoading.value = true;
+
+ await nextTick();
+ // Always scroll to bottom when user sends a new message
+ scrollChatToBottom();
+
+ let assistantMessage = null;
+
+ try {
+ const messageHistory = chatMessages.value
+ .slice(0, -1)
+ .map((msg) => ({ role: msg.role, content: msg.content }));
+
+ const response = await fetch("/tool/speakr/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ recording_id: selectedRecording.value.id,
+ message: message,
+ message_history: messageHistory,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to get chat response");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ const processStream = async () => {
+ let isFirstChunk = true;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop();
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ const jsonStr = line.substring(6);
+ if (jsonStr) {
+ try {
+ const data = JSON.parse(jsonStr);
+ if (data.thinking) {
+ // Check scroll position BEFORE updating content
+ const shouldScroll = isChatScrolledToBottom();
+
+ if (isFirstChunk) {
+ isChatLoading.value = false;
+ assistantMessage = reactive({
+ role: "assistant",
+ content: "",
+ html: "",
+ thinking: data.thinking,
+ thinkingExpanded: false,
+ });
+ chatMessages.value.push(assistantMessage);
+ isFirstChunk = false;
+ } else if (assistantMessage) {
+ // Append to existing thinking content
+ if (assistantMessage.thinking) {
+ assistantMessage.thinking += "\n\n" + data.thinking;
+ } else {
+ assistantMessage.thinking = data.thinking;
+ }
+ }
+
+ // Scroll if we were at bottom before the update
+ if (shouldScroll) {
+ await nextTick();
+ scrollChatToBottom();
+ }
+ }
+ if (data.delta) {
+ // Check scroll position BEFORE updating content
+ const shouldScroll = isChatScrolledToBottom();
+
+ if (isFirstChunk) {
+ isChatLoading.value = false;
+ assistantMessage = reactive({
+ role: "assistant",
+ content: "",
+ html: "",
+ thinking: "",
+ thinkingExpanded: false,
+ });
+ chatMessages.value.push(assistantMessage);
+ isFirstChunk = false;
+ }
+
+ assistantMessage.content += data.delta;
+ assistantMessage.html = marked.parse(
+ assistantMessage.content
+ );
+
+ // Scroll if we were at bottom before the update
+ if (shouldScroll) {
+ await nextTick();
+ scrollChatToBottom();
+ }
+ }
+ if (data.end_of_stream) {
+ return;
+ }
+ if (data.error) {
+ throw new Error(data.error);
+ }
+ } catch (e) {
+ console.error("Error parsing stream data:", e);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ await processStream();
+ } catch (error) {
+ console.error("Chat Error:", error);
+ if (assistantMessage) {
+ assistantMessage.content = `Error: ${error.message}`;
+ assistantMessage.html = `Error: ${error.message} `;
+ } else {
+ chatMessages.value.push({
+ role: "assistant",
+ content: `Error: ${error.message}`,
+ html: `Error: ${error.message} `,
+ });
+ }
+ } finally {
+ isChatLoading.value = false;
+ await nextTick();
+ // Final scroll only if user is at bottom
+ if (isChatScrolledToBottom()) {
+ scrollChatToBottom();
+ }
+ }
+ };
+
+ //--------------------聊天问答功能 end------------------
+
+ //------------------- 分栏拖拽与尺寸持久化 start------------------
+ // --- Column Resizing ---
+ const startColumnResize = (event) => {
+ isResizing.value = true;
+ const startX = event.clientX;
+ const startLeftWidth = leftColumnWidth.value;
+
+ const handleMouseMove = (e) => {
+ if (!isResizing.value) return;
+
+ const container = document.getElementById("mainContentColumns");
+ if (!container) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const deltaX = e.clientX - startX;
+ const containerWidth = containerRect.width;
+ const deltaPercent = (deltaX / containerWidth) * 100;
+
+ let newLeftWidth = startLeftWidth + deltaPercent;
+ newLeftWidth = Math.max(20, Math.min(80, newLeftWidth)); // Constrain between 20% and 80%
+
+ leftColumnWidth.value = newLeftWidth;
+ rightColumnWidth.value = 100 - newLeftWidth;
+ };
+
+ const handleMouseUp = () => {
+ isResizing.value = false;
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+
+ // Save to localStorage
+ localStorage.setItem("transcriptColumnWidth", leftColumnWidth.value);
+ localStorage.setItem("summaryColumnWidth", rightColumnWidth.value);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ event.preventDefault();
+ };
+
+ //--------------------分栏拖拽与尺寸持久化 end------------------
+
+ //------------------- 聊天输入交互 start------------------
+ // --- Chat Input Handling ---
+ const handleChatKeydown = (event) => {
+ if (event.key === "Enter") {
+ if (event.ctrlKey || event.shiftKey) {
+ // Ctrl+Enter or Shift+Enter: add new line (default behavior)
+ return;
+ } else {
+ // Enter: send message
+ event.preventDefault();
+ sendChatMessage();
+ }
+ }
+ };
+
+ //--------------------聊天输入交互 end------------------
+
+ //------------------- 音频播放器辅助逻辑 start------------------
+ // --- Audio Player ---
+ const seekAudio = (time, context = "main") => {
+ let audioPlayer = null;
+ if (context === "modal") {
+ // The audio player in the modal has the class directly on the audio element
+ audioPlayer = document.querySelector(
+ "audio.speaker-modal-transcript"
+ );
+ } else {
+ audioPlayer = document.querySelector(".main-content-area audio");
+ }
+
+ if (audioPlayer) {
+ const wasPlaying = !audioPlayer.paused;
+ audioPlayer.currentTime = time;
+ if (wasPlaying) {
+ audioPlayer.play();
+ }
+ } else {
+ console.warn(`Audio player not found for context: ${context}`);
+ // Fallback to old method if new one fails
+ const oldPlayer = document.querySelector("audio");
+ if (oldPlayer) {
+ const wasPlaying = !oldPlayer.paused;
+ oldPlayer.currentTime = time;
+ if (wasPlaying) {
+ oldPlayer.play();
+ }
+ }
+ }
+ };
+
+ const seekAudioFromEvent = (event) => {
+ const segmentElement = event.target.closest("[data-start-time]");
+ if (!segmentElement) return;
+
+ const time = parseFloat(segmentElement.dataset.startTime);
+ if (isNaN(time)) return;
+
+ // Determine context by checking if we're inside the speaker modal
+ const isInSpeakerModal =
+ event.target.closest(".speaker-modal-transcript") !== null;
+ const context = isInSpeakerModal ? "modal" : "main";
+
+ seekAudio(time, context);
+ };
+
+ const onPlayerVolumeChange = (event) => {
+ const newVolume = event.target.volume;
+ playerVolume.value = newVolume;
+ localStorage.setItem("playerVolume", newVolume);
+ };
+
+ //--------------------音频播放器辅助逻辑 end------------------
+
+ //------------------- 国际化辅助方法 start------------------
+ // --- i18n Helper Functions ---
+ // Use the same safeT function that's globally available
+ const t = safeT;
+
+ const tc = (key, count, params = {}) => {
+ return window.i18n ? window.i18n.tc(key, count, params) : key;
+ };
+
+ const changeLanguage = async (langCode) => {
+ if (window.i18n) {
+ await window.i18n.setLocale(langCode);
+ currentLanguage.value = langCode;
+ const lang = availableLanguages.value.find(
+ (l) => l.code === langCode
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+ showLanguageMenu.value = false;
+ isUserMenuOpen.value = false;
+
+ // Save preference to backend
+ try {
+ await fetch("/tool/speakr/api/user/preferences", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrfToken.value,
+ },
+ body: JSON.stringify({ language: langCode }),
+ });
+ } catch (error) {
+ console.error("Failed to save language preference:", error);
+ }
+ }
+ };
+
+ //--------------------国际化辅助方法 end------------------
+
+ //------------------- Toast 提示与复制下载反馈 start------------------
+ // --- Toast Notifications ---
+ const showToast = (
+ message,
+ icon = "fa-check-circle",
+ duration = 2000
+ ) => {
+ const toastContainer = document.getElementById("toastContainer");
+
+ const toast = document.createElement("div");
+ toast.className = "toast";
+ toast.innerHTML = ` ${message}`;
+
+ toastContainer.appendChild(toast);
+
+ setTimeout(() => {
+ toast.classList.add("show");
+ }, 10);
+
+ setTimeout(() => {
+ toast.classList.remove("show");
+ setTimeout(() => {
+ toastContainer.removeChild(toast);
+ }, 300);
+ }, duration);
+ };
+
+ const animateCopyButton = (button) => {
+ button.classList.add("copy-success");
+
+ const originalContent = button.innerHTML;
+ button.innerHTML = ' ';
+
+ setTimeout(() => {
+ button.classList.remove("copy-success");
+ button.innerHTML = originalContent;
+ }, 1500);
+ };
+
+ const copyMessage = (text, event) => {
+ const button = event.currentTarget;
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ showToast("Copied to clipboard!");
+ animateCopyButton(button);
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(text, button);
+ });
+ } else {
+ fallbackCopyTextToClipboard(text, button);
+ }
+ };
+
+ const fallbackCopyTextToClipboard = (text, button = null) => {
+ try {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+
+ textArea.focus();
+ textArea.select();
+ const successful = document.execCommand("copy");
+
+ document.body.removeChild(textArea);
+
+ if (successful) {
+ showToast("Copied to clipboard!");
+ if (button) animateCopyButton(button);
+ } else {
+ showToast(
+ "Copy failed. Your browser may not support this feature.",
+ "fa-exclamation-circle"
+ );
+ }
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ showToast("Unable to copy: " + err.message, "fa-exclamation-circle");
+ }
+ };
+
+ const copyTranscription = (event) => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.transcription
+ ) {
+ showToast(
+ "No transcription available to copy.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ const button = event.currentTarget;
+ let textToCopy = "";
+
+ try {
+ const transcriptionData = JSON.parse(
+ selectedRecording.value.transcription
+ );
+ if (Array.isArray(transcriptionData)) {
+ const wasDiarized = transcriptionData.some(
+ (segment) => segment.speaker
+ );
+ if (wasDiarized) {
+ textToCopy = transcriptionData
+ .map((segment) => {
+ const speakerName = segment.speaker;
+ return `[${speakerName}]: ${segment.sentence}`;
+ })
+ .join("\n");
+ } else {
+ textToCopy = transcriptionData
+ .map((segment) => segment.sentence)
+ .join("\n");
+ }
+ } else {
+ textToCopy = selectedRecording.value.transcription;
+ }
+ } catch (e) {
+ textToCopy = selectedRecording.value.transcription;
+ }
+
+ animateCopyButton(button);
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("转录内容已复制到剪贴板!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const copySummary = (event) => {
+ if (!selectedRecording.value || !selectedRecording.value.summary) {
+ showToast("No summary available to copy.", "fa-exclamation-circle");
+ return;
+ }
+ const button = event.currentTarget;
+ const textToCopy = selectedRecording.value.summary;
+ animateCopyButton(button);
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("摘要已复制到剪贴板!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const copyNotes = (event) => {
+ if (!selectedRecording.value || !selectedRecording.value.notes) {
+ showToast("No notes available to copy.", "fa-exclamation-circle");
+ return;
+ }
+ const button = event.currentTarget;
+ const textToCopy = selectedRecording.value.notes;
+ animateCopyButton(button);
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("Notes copied to clipboard!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const downloadSummary = async () => {
+ if (!selectedRecording.value || !selectedRecording.value.summary) {
+ showToast(
+ "No summary available to download.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/summary`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download summary",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "摘要.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Summary downloaded successfully!");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download summary", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadTranscript = async () => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.transcription
+ ) {
+ showToast(
+ "No transcription available to download.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ try {
+ // First, fetch available templates
+ const templatesResponse = await fetch(
+ "/tool/speakr/api/transcript-templates"
+ );
+ let templates = [];
+ if (templatesResponse.ok) {
+ templates = await templatesResponse.json();
+ }
+
+ // If there are templates, show a selection dialog
+ let templateId = null;
+ if (templates.length > 0) {
+ // Create a simple modal for template selection
+ const modal = document.createElement("div");
+ modal.className =
+ "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50";
+ modal.innerHTML = `
+
+
Select Template
+
+ ${templates
+ .map(
+ (t) => `
+
+ ${
+ t.name
+ }
+ ${
+ t.description
+ ? `${t.description}
`
+ : ""
+ }
+ ${
+ t.is_default
+ ? ' Default
'
+ : ""
+ }
+
+ `
+ )
+ .join("")}
+
+
+ 取消
+ Download without template
+
+
+ `;
+ document.body.appendChild(modal);
+
+ // Wait for user selection
+ await new Promise((resolve) => {
+ modal.querySelectorAll(".template-option").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ templateId = btn.dataset.templateId;
+ modal.remove();
+ resolve();
+ });
+ });
+
+ modal
+ .querySelector(".cancel-btn")
+ .addEventListener("click", () => {
+ templateId = "cancelled"; // Mark as cancelled
+ modal.remove();
+ resolve();
+ });
+
+ modal
+ .querySelector(".download-without-template-btn")
+ .addEventListener("click", () => {
+ templateId = "none"; // Use 'none' to indicate no template
+ modal.remove();
+ resolve();
+ });
+
+ modal.addEventListener("click", (e) => {
+ if (e.target === modal) {
+ templateId = "cancelled"; // Mark as cancelled when clicking outside
+ modal.remove();
+ resolve();
+ }
+ });
+ });
+
+ if (
+ templateId === null ||
+ templateId === undefined ||
+ templateId === "cancelled"
+ ) {
+ // User cancelled
+ return;
+ }
+ }
+
+ // Download the transcript with the selected template
+ const url = templateId && templateId !== "none"
+ ? `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}`
+ : `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=none`;
+
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error("Failed to download transcript");
+ }
+
+ const blob = await response.blob();
+ const contentDisposition = response.headers.get(
+ "content-disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "转录稿.docx"
+ );
+
+ // Create download link
+ const downloadUrl = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = downloadUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(downloadUrl);
+
+ showToast("Transcript downloaded successfully!");
+ } catch (error) {
+ console.error("Error downloading transcript:", error);
+ showToast("Failed to download transcript", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadNotes = async () => {
+ if (!selectedRecording.value || !selectedRecording.value.notes) {
+ showToast("No notes available to download.", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/notes`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download notes",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "笔记.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Notes downloaded successfully!", "fa-check-circle");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download notes", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadEventICS = async (event) => {
+ if (!event || !event.id) {
+ showToast("Invalid event data", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/event/${event.id}/ics`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download event",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `${event.title || "event"}.ics`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast(
+ `Event "${event.title}" downloaded. Open the file to add to your calendar.`,
+ "fa-calendar-check",
+ 3000
+ );
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download event", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadICS = async () => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.events ||
+ selectedRecording.value.events.length === 0
+ ) {
+ showToast("No events to export", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${selectedRecording.value.id}/events/ics`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to export events",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `events-${
+ selectedRecording.value.title || selectedRecording.value.id
+ }.ics`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast(
+ `Exported ${selectedRecording.value.events.length} events`,
+ "fa-calendar-check"
+ );
+ } catch (error) {
+ console.error("Download all events ICS error:", error);
+ showToast("Failed to export events", "fa-exclamation-circle");
+ }
+ };
+
+ const formatEventDateTime = (dateTimeStr) => {
+ if (!dateTimeStr) return "";
+ try {
+ const date = new Date(dateTimeStr);
+ const options = {
+ weekday: "short",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ };
+ return date.toLocaleString(undefined, options);
+ } catch (e) {
+ return dateTimeStr;
+ }
+ };
+
+ const downloadChat = async () => {
+ if (!selectedRecording.value || chatMessages.value.length === 0) {
+ showToast("No chat messages to download.", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/chat`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken.value,
+ },
+ body: JSON.stringify({
+ messages: chatMessages.value,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download chat",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "对话记录.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Notes downloaded successfully!");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download notes", "fa-exclamation-circle");
+ }
+ };
+
+ const clearChat = () => {
+ if (chatMessages.value.length > 0) {
+ chatMessages.value = [];
+ showToast("Chat cleared", "fa-broom");
+ }
+ };
+
+ const openShareModal = async (recording) => {
+ recordingToShare.value = recording;
+ shareOptions.share_summary = true;
+ shareOptions.share_notes = true;
+ generatedShareLink.value = "";
+ existingShareDetected.value = false;
+ showShareModal.value = true;
+
+ // Check for existing share
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${recording.id}/share`,
+ {
+ method: "GET",
+ }
+ );
+ const data = await response.json();
+ if (response.ok && data.exists) {
+ generatedShareLink.value = data.share_url;
+ existingShareDetected.value = true;
+ shareOptions.share_summary = data.share.share_summary;
+ shareOptions.share_notes = data.share.share_notes;
+ }
+ } catch (error) {
+ console.error("Error checking for existing share:", error);
+ }
+ };
+
+ const closeShareModal = () => {
+ showShareModal.value = false;
+ recordingToShare.value = null;
+ existingShareDetected.value = false;
+ };
+
+ const createShare = async (forceNew = false) => {
+ if (!recordingToShare.value) return;
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${recordingToShare.value.id}/share`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...shareOptions,
+ force_new: forceNew,
+ }),
+ }
+ );
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to create share link");
+
+ generatedShareLink.value = data.share_url;
+ existingShareDetected.value = data.existing && !forceNew;
+
+ if (data.existing && !forceNew) {
+ // Show that we're using an existing share
+ showToast("Using existing share link", "fa-link");
+ } else {
+ showToast("Share link created successfully!", "fa-check-circle");
+ }
+ } catch (error) {
+ setGlobalError(`Failed to create share link: ${error.message}`);
+ }
+ };
+
+ const copyShareLink = () => {
+ if (!generatedShareLink.value) return;
+ navigator.clipboard.writeText(generatedShareLink.value).then(() => {
+ showToast("Share link copied to clipboard!");
+ });
+ };
+
+ const openSharesList = async () => {
+ isLoadingShares.value = true;
+ showSharesListModal.value = true;
+ try {
+ const response = await fetch("/tool/speakr/api/shares");
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to load shared items");
+ userShares.value = data;
+ } catch (error) {
+ setGlobalError(`Failed to load shared items: ${error.message}`);
+ } finally {
+ isLoadingShares.value = false;
+ }
+ };
+
+ const closeSharesList = () => {
+ showSharesListModal.value = false;
+ userShares.value = [];
+ };
+
+ const updateShare = async (share) => {
+ try {
+ const response = await fetch(`/tool/speakr/api/share/${share.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ share_summary: share.share_summary,
+ share_notes: share.share_notes,
+ }),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update share");
+ showToast("Share permissions updated.", "fa-check-circle");
+ } catch (error) {
+ setGlobalError(`Failed to update share: ${error.message}`);
+ }
+ };
+
+ const confirmDeleteShare = (share) => {
+ shareToDelete.value = share;
+ showShareDeleteModal.value = true;
+ };
+
+ const cancelDeleteShare = () => {
+ shareToDelete.value = null;
+ showShareDeleteModal.value = false;
+ };
+
+ const deleteShare = async () => {
+ if (!shareToDelete.value) return;
+ const shareId = shareToDelete.value.id;
+ try {
+ const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to delete share");
+ userShares.value = userShares.value.filter((s) => s.id !== shareId);
+ showToast("Share link deleted successfully.", "fa-check-circle");
+ showShareDeleteModal.value = false;
+ shareToDelete.value = null;
+ } catch (error) {
+ setGlobalError(`Failed to delete share: ${error.message}`);
+ }
+ };
+
+ //--------------------Toast 提示与复制下载反馈 end------------------
+
+ //------------------- 响应式监听与界面联动 start------------------
+ // --- Watchers ---
+ watch(
+ uploadQueue,
+ (newQueue, oldQueue) => {
+ if (
+ newQueue.length === 0 &&
+ oldQueue.length > 0 &&
+ !isProcessingActive.value
+ ) {
+ console.log("Upload queue processing finished.");
+ setTimeout(() => (progressPopupMinimized.value = true), 500);
+ setTimeout(() => {
+ if (
+ completedInQueue.value === totalInQueue.value &&
+ !isProcessingActive.value
+ ) {
+ progressPopupClosed.value = true;
+ }
+ }, 2000);
+ }
+ },
+ { deep: true }
+ );
+
+ // Watch for changes to speakerMap to handle "This is Me" functionality
+ watch(
+ speakerMap,
+ (newSpeakerMap) => {
+ if (!newSpeakerMap) return;
+
+ Object.keys(newSpeakerMap).forEach((speakerId) => {
+ const speakerData = newSpeakerMap[speakerId];
+ if (
+ speakerData.isMe &&
+ currentUserName.value &&
+ !speakerData.name
+ ) {
+ // Automatically fill in the user's name when "This is Me" is checked
+ speakerData.name = currentUserName.value;
+ } else if (
+ !speakerData.isMe &&
+ speakerData.name === currentUserName.value
+ ) {
+ // Clear the name if "This is Me" is unchecked and the name matches the current user
+ speakerData.name = "";
+ }
+ });
+ },
+ { deep: true }
+ );
+
+ // Watch for tab changes to save content properly
+ watch(selectedTab, (newTab, oldTab) => {
+ // Close maximized chat when switching tabs
+ if (isChatMaximized.value) {
+ isChatMaximized.value = false;
+ }
+
+ // Save content when switching away from notes tab but keep editor open
+ if (
+ oldTab === "notes" &&
+ editingNotes.value &&
+ markdownEditorInstance.value
+ ) {
+ // Save the current content from the editor
+ const currentContent = markdownEditorInstance.value.value();
+ selectedRecording.value.notes = currentContent;
+ // Call auto-save instead of saveInlineEdit to keep editor open
+ autoSaveNotes();
+ // Store that we need to recreate the editor when coming back
+ tempNotesContent.value = currentContent;
+ }
+ // Save content when switching away from summary tab but keep editor open
+ if (
+ oldTab === "summary" &&
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value
+ ) {
+ // Save the current content from the editor
+ const currentContent = summaryMarkdownEditorInstance.value.value();
+ selectedRecording.value.summary = currentContent;
+ // Call auto-save instead of saveInlineEdit to keep editor open
+ autoSaveSummary();
+ // Store that we need to recreate the editor when coming back
+ tempSummaryContent.value = currentContent;
+ }
+
+ // Re-initialize editors when switching back to tabs
+ if (newTab === "notes" && editingNotes.value) {
+ // Destroy old instance if exists
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ // Re-initialize the editor in next tick
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ if (newTab === "summary" && editingSummary.value) {
+ // Destroy old instance if exists
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ // Re-initialize the editor in next tick
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ });
+
+ // Watch for mobile tab changes similarly
+ watch(mobileTab, (newTab, oldTab) => {
+ // Save content when switching away from notes tab but keep editor open
+ if (
+ oldTab === "notes" &&
+ editingNotes.value &&
+ markdownEditorInstance.value
+ ) {
+ const currentContent = markdownEditorInstance.value.value();
+ selectedRecording.value.notes = currentContent;
+ autoSaveNotes(); // Use auto-save instead
+ tempNotesContent.value = currentContent;
+ }
+ // Save content when switching away from summary tab but keep editor open
+ if (
+ oldTab === "summary" &&
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value
+ ) {
+ const currentContent = summaryMarkdownEditorInstance.value.value();
+ selectedRecording.value.summary = currentContent;
+ autoSaveSummary(); // Use auto-save instead
+ tempSummaryContent.value = currentContent;
+ }
+
+ // Re-initialize editors when switching back to tabs on mobile
+ if (newTab === "notes" && editingNotes.value) {
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ if (newTab === "summary" && editingSummary.value) {
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ });
+
+ watch(selectedRecording, (newVal, oldVal) => {
+ if (newVal?.id !== oldVal?.id) {
+ // Save any pending edits before switching recordings
+ if (
+ editingNotes.value &&
+ markdownEditorInstance.value &&
+ oldVal?.id
+ ) {
+ selectedRecording.value = oldVal; // Temporarily restore old recording
+ saveEditNotes(); // This will save and cleanup
+ selectedRecording.value = newVal; // Switch back to new recording
+ }
+ if (
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value &&
+ oldVal?.id
+ ) {
+ selectedRecording.value = oldVal; // Temporarily restore old recording
+ saveEditSummary(); // This will save and cleanup
+ selectedRecording.value = newVal; // Switch back to new recording
+ }
+
+ chatMessages.value = [];
+ showChat.value = false;
+ selectedTab.value = "summary";
+
+ editingParticipants.value = false;
+ editingMeetingDate.value = false;
+ editingSummary.value = false;
+ editingNotes.value = false;
+
+ // Fix WebM duration issue by forcing metadata load
+ if (newVal?.id) {
+ nextTick(() => {
+ const audioElements = document.querySelectorAll("audio");
+ audioElements.forEach((audio) => {
+ if (
+ audio.src &&
+ audio.src.includes(`tool/speakr/audio/${newVal.id}`)
+ ) {
+ // For WebM files, we need to seek to end to get duration
+ const fixDuration = () => {
+ if (!isFinite(audio.duration) || audio.duration === 0) {
+ audio.currentTime = 1e101; // Seek to "infinity" to load duration
+ audio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ audio.currentTime = 0;
+ audio.removeEventListener("timeupdate", resetTime);
+ },
+ { once: true }
+ );
+ }
+ };
+
+ // Try to fix duration when metadata loads
+ audio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+
+ // Also try immediately in case metadata is already loaded
+ if (audio.readyState >= 1) {
+ fixDuration();
+ }
+ }
+ });
+ });
+ }
+ }
+ });
+ // 会议记录页面
+ //------------------- 录音视图切换与实时 ASR 管线 start------------------
+ watch(currentView, (newView) => {
+ if (newView === "recording") {
+ // Initialize recording markdown editor when switching to recording view
+ nextTick(() => {
+ initializeRecordingMarkdownEditor();
+ getBaseInfo();
+ is_final.value = false;
+ // 清空上一次的实时转录文本
+ asrResultOnline.value = "";
+ // ===== 清理旧的 Recorder 实例 =====
+ // 根本原因修复:旧 rec 的 onProcess 回调在旧闭包中持续活跃,
+ // 向已停止的旧 wsconnecter 发送数据,导致缓冲区溢出和 WebSocket 未连接刷屏
+ if (rec) {
+ try { rec.close(); } catch(e) { console.warn('关闭旧rec失败:', e); }
+ rec = null;
+ }
+ if (rec2) {
+ try { rec2.close(); } catch(e) { console.warn('关闭旧rec2失败:', e); }
+ rec2 = null;
+ }
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ asrAudioContext = null;
+ }
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ asrAudioContext2 = null;
+ }
+ //语音实时转换相关
+ //online的ws示例
+ var wsconnecter = new WebSocketConnectMethod({
+ msgHandle: getJsonMessage,
+ stateHandle: getConnState,
+ });
+ //offline的ws示例
+ var wsconnecter2 = new WebSocketConnectMethod({
+ msgHandle: getJsonMessage2,
+ stateHandle: getConnState2,
+ });
+ webReader = wsconnecter;
+ webReader2 = wsconnecter2;
+ var audioBlob;
+ var sampleBuf = new Int16Array();
+ var sampleBuf2 = new Int16Array();
+ const asrChunkSize = 960;
+ const asrBufferWarnSamples = 20000;
+ const asrBufferHardLimitSamples = 16000 * 5;
+ var rec_text = "";
+ var offline_text = "";
+ var rec_textOnline = "";
+ var offlineSentenceHandoffText = "";
+ var rec_textoffline = "";
+ //------------------- 实时 ASR 待矫正句子队列 start------------------
+ // pendingSentenceQueue 是 2pass-offline 文本进入正式转录前的等待队列。
+ // 每条 offline 句子先入队,再等待 /api/asr/correct 的 LLM 矫正结果。
+ // LLM 返回后由 finalizePendingSentence 标记 processed,再按队首顺序 flush 到 segmentList。
+ var pendingSentenceQueue = [];
+ let pendingSentenceSequence = 0;
+ var lastOnlineTimestamp = null; // 记录上次在线转录的时间戳
+
+ // extractTimestampMs: FunASR 返回的 timestamp 通常是二维数组或 JSON 字符串。
+ // 这里取最后一段的结束时间,用它判断消息新旧、辅助说话人回填和去重。
+ function extractTimestampMs(timestampPayload) {
+ if (!timestampPayload) {
+ // 没有 timestamp 时无法做时间屏障判断,后续逻辑会把它当作无时间消息处理。
+ return null;
+ }
+
+ try {
+ const timestampData =
+ typeof timestampPayload === "string"
+ ? JSON.parse(timestampPayload)
+ : timestampPayload;
+
+ if (!Array.isArray(timestampData) || timestampData.length === 0) {
+ return null;
+ }
+
+ // FunASR timestamp 的最后一项代表当前句子末尾附近的时间。
+ const lastItem = timestampData[timestampData.length - 1];
+ if (!Array.isArray(lastItem) || lastItem.length === 0) {
+ return null;
+ }
+
+ // 优先取结束时间;部分返回只有 start 时退回取第一个值。
+ const endOrStart =
+ lastItem.length > 1 ? Number(lastItem[1]) : Number(lastItem[0]);
+ return Number.isFinite(endOrStart) ? endOrStart : null;
+ } catch (error) {
+ // timestamp 解析失败时不阻断转写,只放弃基于时间戳的旧消息判断。
+ return null;
+ }
+ }
+
+ // getLatestRealtimeTimestamp: 手动编辑实时转写时需要建立时间屏障。
+ // 这里从正式 segment、pending 队列和 online 临时文本中取最新时间,避免旧消息回灌。
+ function getLatestRealtimeTimestamp() {
+ const latestSegmentTimestamp = segmentList.reduce(
+ (latest, segment) =>
+ Number.isFinite(segment?.timestampMs)
+ ? Math.max(latest, segment.timestampMs)
+ : latest,
+ -1
+ );
+ const latestPendingTimestamp = pendingSentenceQueue.reduce(
+ (latest, item) =>
+ Number.isFinite(item.timestampMs)
+ ? Math.max(latest, item.timestampMs)
+ : latest,
+ -1
+ );
+ const latestOnlineTimestamp = Number.isFinite(lastOnlineTimestamp)
+ ? lastOnlineTimestamp
+ : -1;
+
+ return Math.max(
+ latestSegmentTimestamp,
+ latestPendingTimestamp,
+ latestOnlineTimestamp
+ );
+ }
+
+ // 只保留尾部文本作为 overlap 参照,避免手动编辑后 offline 结果把已编辑内容重复带回来。
+ function getRealtimeCarryoverTail(text, maxLength = 160) {
+ return (text || "").slice(-maxLength);
+ }
+
+ // getSharedRealtimeSuffix: 找原始实时文本和用户编辑后文本之间仍然相同的尾部。
+ // 该函数用于辅助构造 realtimeEditCarryover,减少后续 offline 文本重复拼接。
+ function getSharedRealtimeSuffix(
+ originalText,
+ editedText,
+ maxLength = 80
+ ) {
+ const original = originalText || "";
+ const edited = editedText || "";
+ const maxSuffixLength = Math.min(
+ maxLength,
+ original.length,
+ edited.length
+ );
+
+ for (let size = maxSuffixLength; size >= 1; size--) {
+ const suffix = edited.slice(-size);
+ if (original.endsWith(suffix)) {
+ return suffix;
+ }
+ }
+
+ return "";
+ }
+
+ // buildRealtimeEditCarryover: 用户手动编辑实时转写后,记录一小段尾巴。
+ // 下一条 2pass-offline 到达时,会用这段尾巴裁掉重复内容。
+ function buildRealtimeEditCarryover(baseText) {
+ const tailText = getRealtimeCarryoverTail(baseText);
+ if (tailText.length < 4) {
+ return null;
+ }
+
+ return {
+ tailText,
+ };
+ }
+
+ // trimOfflineTextWithCarryover: 裁掉 offline 文本中与手动编辑尾巴重叠的部分。
+ // 例:用户已编辑到 "今天会议讨论A",offline 又返回 "会议讨论A和B",则只保留 "和B"。
+ function trimOfflineTextWithCarryover(rawText) {
+ const incomingText = rawText || "";
+ if (!realtimeEditCarryover || !incomingText) {
+ // 没有手动编辑 carryover,或当前 offline 文本为空,直接返回原文。
+ return incomingText;
+ }
+
+ const tailText = realtimeEditCarryover.tailText || "";
+ const minOverlapLength = 4;
+ const maxOverlapLength = Math.min(
+ 80,
+ tailText.length,
+ incomingText.length
+ );
+
+ // 从最长 overlap 开始找,优先裁掉最大重复片段,减少重复显示。
+ for (
+ let size = maxOverlapLength;
+ size >= minOverlapLength;
+ size--
+ ) {
+ const overlapText = tailText.slice(-size);
+ const matchIndex = incomingText.lastIndexOf(overlapText);
+ if (matchIndex === -1) {
+ continue;
+ }
+
+ // 找到重叠片段后,只把重叠之后的新内容作为本次 pending 文本。
+ const remainingText = incomingText.slice(
+ matchIndex + overlapText.length
+ );
+ // 更新 carryover 尾巴,让后续 offline 文本继续用最新上下文去重。
+ realtimeEditCarryover.tailText = getRealtimeCarryoverTail(
+ tailText + remainingText
+ );
+ return remainingText;
+ }
+
+ // 没有找到 overlap,说明后续 offline 文本已经和编辑尾巴接不上,停止 carryover 逻辑。
+ realtimeEditCarryover = null;
+ return incomingText;
+ }
+
+ function clearRealtimePipeline({
+ preserveTimestampBarrier = false,
+ } = {}) {
+ sampleBuf = new Int16Array();
+ sampleBuf2 = new Int16Array();
+ rec_text = "";
+ offline_text = "";
+ rec_textOnline = "";
+ offlineSentenceHandoffText = "";
+ rec_textoffline = "";
+ pendingSentenceQueue = [];
+ pendingSentenceSequence = 0;
+ lastOnlineTimestamp = null;
+ asrResultOnline.value = "";
+ asrResultOffline.value = "";
+
+ if (!preserveTimestampBarrier) {
+ realtimeTimestampBarrierMs = -1;
+ }
+
+ syncRealtimePreview();
+ }
+
+ // isStaleRealtimeMessage: 判断一条 WebSocket 回包是否属于旧录音周期或旧时间段。
+ // 返回 true 时,ensurePendingSentence 会直接丢弃,避免暂停/恢复/手动编辑后的旧消息污染当前文本。
+ function isStaleRealtimeMessage(
+ messageTimestampMs,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ if (messageEpoch !== liveTranscriptionEpoch) {
+ // epoch 不一致表示消息来自上一轮实时转写,必须丢弃。
+ return true;
+ }
+
+ return (
+ // 时间戳小于等于屏障,说明这条消息早于用户编辑/重置点,不能再回灌。
+ Number.isFinite(messageTimestampMs) &&
+ realtimeTimestampBarrierMs >= 0 &&
+ messageTimestampMs <= realtimeTimestampBarrierMs
+ );
+ }
+ clearRealtimePipelineRef = clearRealtimePipeline;
+ getLatestRealtimeTimestampRef = getLatestRealtimeTimestamp;
+ buildRealtimeEditCarryoverRef = buildRealtimeEditCarryover;
+ renderFullTextRef = renderFullText;
+
+ // getPendingSentencePreviewText: pending 队列还未正式落到 segmentList,
+ // 但用户仍需要在实时预览区先看到这些句子。
+ function getPendingSentencePreviewText() {
+ return pendingSentenceQueue
+ .filter((item) => item.epoch === liveTranscriptionEpoch)
+ .map((item) => item.correctedText || item.rawText)
+ .join("");
+ }
+
+ // syncRealtimePreview: 刷新右侧/实时区域的临时展示文本。
+ // 展示顺序:待矫正 offline 句子 + online/offline 交接文本 + 当前 online 临时文本。
+ function syncRealtimePreview() {
+ asrResultOnline.value =
+ getPendingSentencePreviewText() +
+ offlineSentenceHandoffText +
+ rec_textOnline;
+ }
+
+ // findPendingSentenceBySource: 用 sourceText + timestamp + epoch 做幂等去重。
+ // 同一条 offline 句子如果重复返回,直接复用已有 pending/segment,避免重复矫正和重复落句。
+ function findPendingSentenceBySource(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ const normalizedSourceText = sourceText || "";
+ const normalizedTimestamp = pendingTimestamp || "";
+ return (
+ // 先查 pending 队列:句子可能正在等待 LLM 矫正。
+ pendingSentenceQueue.find(
+ (item) =>
+ item.epoch === messageEpoch &&
+ item.sourceText === normalizedSourceText &&
+ item.timestamp === normalizedTimestamp
+ ) ||
+ // 再查正式 segmentList:句子可能已经矫正完成并落入正式转录。
+ segmentList.find(
+ (item) =>
+ item.epoch === messageEpoch &&
+ item.sourceText === normalizedSourceText &&
+ item.timestamp === normalizedTimestamp
+ ) ||
+ null
+ );
+ }
+
+ // ensurePendingSentence: 2pass-offline 文本进入 LLM 矫正前的统一入口。
+ // 它负责:丢弃旧消息、去重、裁掉手动编辑造成的重复尾巴、创建 pending item、刷新预览。
+ // 注意:这里不会正式写入 segmentList,正式落句在 finalizePendingSentence/flushProcessedPendingSentences。
+ function ensurePendingSentence(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ // 先把 FunASR timestamp 归一化为毫秒数,用于旧消息过滤和后续 speaker 对齐。
+ const timestampMs = extractTimestampMs(pendingTimestamp);
+ if (isStaleRealtimeMessage(timestampMs, messageEpoch)) {
+ // 旧录音周期或旧时间屏障之前的消息直接丢弃,不进入 pending 队列。
+ return null;
+ }
+
+ // 同一 sourceText/timestamp/epoch 已经存在时直接复用,保证重复 WebSocket 回包幂等。
+ const existingItem = findPendingSentenceBySource(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch
+ );
+ if (existingItem) {
+ // 返回已有 item,调用方会看到它已请求/已处理状态,从而避免重复请求 LLM。
+ return existingItem;
+ }
+
+ // 裁掉与手动编辑尾巴重叠的部分,得到真正需要进入矫正流程的新文本。
+ const trimmedText = trimOfflineTextWithCarryover(sourceText || "");
+ if (!trimmedText) {
+ // 裁剪后没有新内容,说明这条 offline 文本只是重复片段,不入队。
+ syncRealtimePreview();
+ return null;
+ }
+
+ // pendingItem 是 LLM 矫正流程的最小状态单元。
+ const pendingItem = {
+ id: pendingSentenceSequence++, // 本地递增 ID,LLM 返回时用它定位这条 pending 句子。
+ sourceText: sourceText || "", // FunASR 原始文本,用于去重和保留来源。
+ rawText: trimmedText, // 裁剪后的文本,后续会作为 /api/asr/correct 的输入。
+ timestamp: pendingTimestamp || "", // 原始 timestamp 字符串/对象,用于去重和渲染时断句。
+ timestampMs, // 毫秒时间点,用于旧消息过滤和 offline-speaker 对齐。
+ correctedText: "", // LLM/标准词映射后的文本,finalize 前保持为空。
+ processed: false, // false 表示还不能 flush 到正式 segmentList。
+ correctionRequested: false, // 防止同一句重复发起 LLM 矫正请求。
+ epoch: messageEpoch, // 当前实时转写周期,防止上一轮录音的异步结果混入。
+ };
+ // 新 pending 先进入等待队列,让实时预览能立即显示,同时等待 LLM 矫正结果。
+ pendingSentenceQueue.push(pendingItem);
+ // 刷新临时预览;正式主文本 asrResult 要等 processed 后由 flushProcessedPendingSentences 更新。
+ syncRealtimePreview();
+ return pendingItem;
+ }
+ //--------------------实时 ASR 待矫正句子队列 end------------------
+ function flushProcessedPendingSentences() {
+ let didFlush = false;
+ while (pendingSentenceQueue.length > 0) {
+ if (pendingSentenceQueue[0].epoch !== liveTranscriptionEpoch) {
+ pendingSentenceQueue.shift();
+ continue;
+ }
+ if (!pendingSentenceQueue[0].processed) {
+ break;
+ }
+
+ const item = pendingSentenceQueue.shift();
+ segmentList.push({
+ text: item.correctedText || item.rawText,
+ sourceText: item.sourceText,
+ timestamp: item.timestamp,
+ timestampMs: item.timestampMs,
+ speaker: null,
+ processed: true,
+ epoch: item.epoch,
+ });
+ didFlush = true;
+ }
+
+ if (didFlush) {
+ renderFullText();
+ } else {
+ syncRealtimePreview();
+ }
+ }
+ function finalizePendingSentence(
+ pendingSentenceId,
+ finalText,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ const target = pendingSentenceQueue.find(
+ (item) =>
+ item.id === pendingSentenceId && item.epoch === messageEpoch
+ );
+ if (!target) {
+ return;
+ }
+
+ target.correctedText = finalText || target.rawText || "";
+ target.processed = true;
+ target.correctionRequested = false;
+ flushProcessedPendingSentences();
+ }
+ async function record() {
+ // rec.open(function () {
+ // rec.start();
+ // });
+ // await startRecording();
+ await openMixedStream();
+ }
+ async function record2() {
+ // rec2.open(function () {
+ // rec2.start();
+ // });
+ // await startRecording();
+ await openMixedStream2();
+ }
+ //------------------- 实时 ASR online PCM 发送通道 start------------------
+ // rec 和 rec2 采集的是同一份混合音频,PCM 重采样和 960-sample 切片逻辑一致。
+ // rec 只负责 online 链路:wsconnecter.wsStart() 不传 URL 参数,
+ // wsconnecter.js 会在握手 JSON 中发送 mode:"online",用于页面实时预览文本。
+ // ********************每收到一帧音频时的回调********************
+ function recProcess(
+ buffer, // Recorder 已经采集到的 PCM 缓冲数组,最后一项是本次新增的音频片段
+
+ powerLevel, // 当前音量强度,当前逻辑没有使用
+ bufferDuration, // 已录制的总时长,当前逻辑没有使用
+ bufferSampleRate, // 浏览器采集到的原始采样率,常见是 48000Hz
+ newBufferIdx, // 本次新增音频片段在 buffer 中的起始索引,当前逻辑没有使用
+ asyncEnd // 异步处理结束回调,当前逻辑没有使用
+ ) {
+ // 调试断点:打开浏览器开发者工具时,会在每次音频回调处暂停
+ // 只有处于录音状态时才处理音频,避免停止后旧回调继续发送数据
+ if (isRec === true) {
+ // 取出本次新增的最后一段 PCM 音频数据
+ var data_48k = buffer[buffer.length - 1];
+
+ // Recorder.SampleData 需要传入“音频片段数组”,所以把单段数据包一层数组
+ var array_48k = new Array(data_48k);
+ // 将浏览器原始采样率的音频重采样到 16000Hz,符合 ASR 服务输入要求
+ var data_16k = Recorder.SampleData(
+ array_48k,
+ bufferSampleRate,
+ 16000
+ ).data;
+
+ // 把新来的 16k PCM 数据追加到本地待发送缓冲区
+ sampleBuf = Int16Array.from([...sampleBuf, ...data_16k]);
+
+ // 如果缓冲超过硬限制,说明发送速度跟不上采集速度,需要丢掉最旧的数据
+ if (sampleBuf.length > asrBufferHardLimitSamples) {
+ // 计算本次超过硬限制的 sample 数量;Int16Array 每个 sample 约占 2 字节
+ const overflowSamples =
+ sampleBuf.length - asrBufferHardLimitSamples;
+ console.warn(
+ `⚠️ [Online] 丢弃过量本地音频缓冲: ${overflowSamples} samples`
+ );
+ // 只保留最后 asrBufferHardLimitSamples 个 sample,尽量保留最新的声音
+ sampleBuf = sampleBuf.slice(-asrBufferHardLimitSamples);
+ }
+
+ // 只要缓冲区够一个 ASR 分片,就持续切片发送
+ while (sampleBuf.length >= asrChunkSize) {
+ // 从缓冲区头部取一个固定大小的音频块,发送给在线识别 WebSocket
+ const sendBuf = sampleBuf.slice(0, asrChunkSize);
+ const sendAccepted = wsconnecter.wsSend(sendBuf);
+ // 如果 WebSocket 当前不可发送,保留剩余缓冲,等待下一次 onProcess 再尝试
+ if (!sendAccepted) {
+ break;
+ }
+ // 发送成功后,从本地缓冲中移除已经发送的这一段
+ sampleBuf = sampleBuf.slice(asrChunkSize);
+ }
+
+ // 超过软告警阈值时只打印警告,不丢数据;真正丢弃由上面的硬限制控制
+ if (sampleBuf.length > asrBufferWarnSamples) {
+ console.warn(
+ `⚠️ [Online] 音频缓冲区溢出: ${sampleBuf.length} samples (${(
+ sampleBuf.length / 16000
+ ).toFixed(2)}s)`
+ );
+ }
+ }
+ }
+ //--------------------实时 ASR online PCM 发送通道 end------------------
+ //------------------- 实时 ASR offline PCM 发送通道 start------------------
+ // rec2 与 rec 使用同样的 PCM 切片方式,但发送到独立的 wsconnecter2。
+ // wsconnecter2.wsStart(urls) 会在握手 JSON 中发送 mode:"offline",
+ // 用于 2pass-offline 正式落句、offline-speaker 说话人回填和后续 LLM 矫正。
+ function recProcess2(
+ buffer,
+ powerLevel,
+ bufferDuration,
+ bufferSampleRate,
+ newBufferIdx,
+ asyncEnd
+ ) {
+ if (isRec === true) {
+ var data_48k = buffer[buffer.length - 1];
+
+ var array_48k = new Array(data_48k);
+ var data_16k = Recorder.SampleData(
+ array_48k,
+ bufferSampleRate,
+ 16000
+ ).data;
+
+ sampleBuf2 = Int16Array.from([...sampleBuf2, ...data_16k]);
+
+ if (sampleBuf2.length > asrBufferHardLimitSamples) {
+ const overflowSamples =
+ sampleBuf2.length - asrBufferHardLimitSamples;
+ console.warn(
+ `⚠️ [Offline] 丢弃过量本地音频缓冲: ${overflowSamples} samples`
+ );
+ sampleBuf2 = sampleBuf2.slice(-asrBufferHardLimitSamples);
+ }
+
+ while (sampleBuf2.length >= asrChunkSize) {
+ const sendBuf2 = sampleBuf2.slice(0, asrChunkSize);
+ const sendAccepted = wsconnecter2.wsSend(sendBuf2);
+ if (!sendAccepted) {
+ break;
+ }
+ sampleBuf2 = sampleBuf2.slice(asrChunkSize);
+ }
+
+ if (sampleBuf2.length > asrBufferWarnSamples) {
+ console.warn(
+ `⚠️ [Offline] 音频缓冲区溢出: ${sampleBuf2.length} samples (${(
+ sampleBuf2.length / 16000
+ ).toFixed(2)}s)`
+ );
+ }
+ }
+ }
+ //--------------------实时 ASR offline PCM 发送通道 end------------------
+ function addNewlineAfterPeriod(str) {
+ return str.replace(/[。?!]/g, "$&\n");
+ }
+
+ function handleWithTimestamp(
+ tmptext,
+ tmptime,
+ keywordStr,
+ timeThreshold
+ ) {
+ if (!tmptime || tmptext.length <= 0) return tmptext;
+
+ const keywordList = keywordStr
+ .split(",")
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0);
+
+ const keywordsAsRegex = keywordList.map((k) => new RegExp(k));
+ const segments = tmptext.match(/[^。!?]+[。!?]?/g) || [];
+ // console.log(segments, "segments");
+
+ const jsontime = JSON.parse(tmptime);
+ let char_index = 0;
+ let text_withtime = "";
+
+ // 辅助函数:去除新行开头的标点符号
+ function removeLeadingPunctuation(text) {
+ // 匹配开头的标点符号(中文和英文标点)
+ return text.replace(/^[,。!?、;:,\.!?;:]+/, "");
+ }
+
+ for (let i = 0; i < segments.length; i++) {
+ let seg = segments[i];
+ if (!seg || seg === "undefined") continue;
+
+ let nowTime = jsontime[char_index][0] / 1000;
+
+ if (
+ lastTimer.value > 0 &&
+ nowTime - lastTimer.value > timeThreshold
+ ) {
+ const lastChar = text_withtime.slice(-1);
+ const endsWithPunc = /[。!?]/.test(lastChar);
+ // 换行后,如果新行开头是标点,去除第一个标点
+ let processedSeg = removeLeadingPunctuation(seg);
+ text_withtime +=
+ (endsWithPunc ? "\n" : "。\n") + processedSeg;
+ } else {
+ let hitKeyword = keywordsAsRegex.some((reg) => reg.test(seg));
+ if (hitKeyword) {
+ // 关键词后换行,如果下一行开头是标点,去除第一个标点
+ let processedSeg = removeLeadingPunctuation(seg);
+ // text_withtime += processedSeg + "。" + "\n";
+ } else {
+ text_withtime += seg;
+ }
+ }
+
+ lastTimer.value = nowTime;
+ char_index++;
+ }
+
+ return text_withtime;
+ }
+ function getSpeakerBeforeIndex(endIndex) {
+ let previousSpeaker = null;
+ for (let i = 0; i < endIndex; i++) {
+ if (segmentList[i] && segmentList[i].speaker) {
+ previousSpeaker = segmentList[i].speaker;
+ }
+ }
+ return previousSpeaker;
+ }
+
+ function buildSegmentText(segments, initialSpeaker = null) {
+ let displayText = "";
+ let lastRenderedSpeaker = initialSpeaker;
+
+ segments.forEach((seg) => {
+ if (showSpeaker.value) {
+ if (
+ seg.speaker &&
+ seg.speaker !== lastRenderedSpeaker &&
+ !seg.speaker.includes("SPK")
+ ) {
+ displayText +=
+ (displayText ? "\n" : "") + seg.speaker + ":\n";
+ lastRenderedSpeaker = seg.speaker;
+ } else if (
+ seg.speaker &&
+ seg.speaker !== lastRenderedSpeaker
+ ) {
+ displayText +=
+ (displayText ? "\n" : "") + seg.speaker + ":\n";
+ lastRenderedSpeaker = seg.speaker;
+ }
+ }
+
+ let textContent = handleWithTimestamp(
+ seg.text,
+ seg.timestamp,
+ configwords,
+ 6
+ );
+ displayText += textContent;
+ });
+
+ return displayText;
+ }
+
+ function renderFullText() {
+ const initialSpeaker =
+ segmentRenderStartIndex > 0
+ ? getSpeakerBeforeIndex(segmentRenderStartIndex)
+ : null;
+ const displayText = buildSegmentText(
+ segmentList.slice(segmentRenderStartIndex),
+ initialSpeaker
+ );
+
+ rec_text = displayText;
+ // 保留历史转录内容(来自暂停/继续草稿)
+ asrResult.value = pausedTranscription.value + rec_text; // 更新主显示区域
+
+ // 更新实时显示,展示剩余的待处理句子 + 当前正在识别的内容
+ syncRealtimePreview();
+ // 移除旧的清空逻辑
+ // asrResultOnline.value = "";
+ // rec_textOnline = "";
+ }
+ // Legacy implementation kept temporarily for reference; the active
+ // realtime path uses the clean getJsonMessage defined below.
+ function getJsonMessageLegacy(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var rectxt = "" + parsedMessage["text"];
+ var asrmodel = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+
+ // 检查是否需要重置转录状态(从暂停/恢复时)
+ if (resetTranscriptionFlag) {
+ console.log("🔄 重置转录状态(暂停/恢复后)");
+ clearRealtimePipeline();
+ resetTranscriptionFlag = false;
+ }
+
+ const messageEpoch = liveTranscriptionEpoch;
+
+ if (asrmodel == "2pass-online") {
+ // ✅ 实时转录换行逻辑
+ // 规则1:凡是换行的,末尾必须是句号(。)
+ // 规则2:不修改标点符号
+ // 规则3:停顿3秒以上才换行
+
+ // 解析时间戳(获取最后一个字符的时间)
+ const currentTimestamp = extractTimestampMs(timestamp);
+ if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
+ syncRealtimePreview();
+ if (is_final.value == true) {
+ wsconnecter.wsStop();
+ webReader2.wsStop();
+ }
+ return;
+ }
+
+ // 检查是否需要换行
+ let shouldBreak = false;
+ if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
+ // 计算时间差(单位:秒)
+ const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
+ // 停顿超过3秒,且当前文本以句号结尾
+ if (timeDiff >= 3 && /。\s*$/.test(rectxt)) {
+ shouldBreak = true;
+ }
+ }
+
+ // 拼接文本(不修改标点)
+ if (shouldBreak) {
+ rec_textOnline = rec_textOnline + rectxt + "\n";
+ } else {
+ rec_textOnline = rec_textOnline + rectxt;
+ }
+
+ // 更新时间戳
+ lastOnlineTimestamp = currentTimestamp;
+
+ } else if (asrmodel == "2pass-offline") {
+ // Bug fix: 使用offline返回的完整文本替代可能不完整的online累积文本
+ // rectxt包含了完整的转录结果,比rec_textOnline更准确
+ ensurePendingSentence(rectxt, timestamp, messageEpoch);
+ rec_textOnline = "";
+ lastOnlineTimestamp = null; // 重置时间戳
+ } else{
+ }
+ // 更新显示:队列中的内容 + 当前实时内容
+ syncRealtimePreview();
+
+ if (is_final.value == true) {
+ wsconnecter.wsStop();
+ webReader2.wsStop();
+ }
+ }
+ function getJsonMessage(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var rectxt = "" + parsedMessage["text"];
+ var asrmodel = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+
+ if (resetTranscriptionFlag) {
+ console.log("重置实时转录状态(暂停/恢复后)");
+ clearRealtimePipeline();
+ resetTranscriptionFlag = false;
+ }
+
+ const messageEpoch = liveTranscriptionEpoch;
+
+ if (asrmodel == "2pass-online") {
+ const currentTimestamp = extractTimestampMs(timestamp);
+ if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
+ syncRealtimePreview();
+ if (is_final.value == true) {
+ wsconnecter.wsStop();
+ webReader2.wsStop();
+ }
+ return;
+ }
+
+ let shouldBreak = false;
+ if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
+ const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
+ if (timeDiff >= 3 && /。\s*$/.test(rectxt)) {
+ shouldBreak = true;
+ }
+ }
+
+ if (shouldBreak) {
+ rec_textOnline = rec_textOnline + rectxt + "\n";
+ } else {
+ rec_textOnline = rec_textOnline + rectxt;
+ }
+
+ lastOnlineTimestamp = currentTimestamp;
+ } else if (asrmodel == "2pass-offline") {
+ // 离线句子的正式入队只走 getJsonMessage2,避免双通路重复落句。
+ rec_textOnline = "";
+ offlineSentenceHandoffText = rectxt || rec_textOnline || "";
+ rec_textOnline = "";
+ lastOnlineTimestamp = null;
+ }
+
+ syncRealtimePreview();
+
+ if (is_final.value == true) {
+ wsconnecter.wsStop();
+ webReader2.wsStop();
+ }
+ }
+ async function getJsonMessage2(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var asrmodeltype = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+ const messageEpoch = liveTranscriptionEpoch;
+ const messageTimestampMs = extractTimestampMs(timestamp); // 获取最后一个时间
+
+ // ============================
+ // 场景 A: 2pass-offline (文本先到)
+ // ============================
+ if (asrmodeltype == "2pass-offline") {
+ if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
+ if (is_final.value == true) {
+ wsconnecter.wsStop();
+ }
+ return;
+ }
+
+ var rectxt = "" + parsedMessage["text"];
+ rec_textOnline = "";
+ lastOnlineTimestamp = null;
+ // 3. 准备进行 LLM 矫正
+ // 使用当前数组里已有的文本 (就是 2pass-offline 的文本)
+ const pendingSentence = ensurePendingSentence(
+ rectxt,
+ timestamp,
+ messageEpoch
+ );
+ offlineSentenceHandoffText = "";
+ syncRealtimePreview();
+ if (
+ !pendingSentence ||
+ pendingSentence.processed ||
+ pendingSentence.correctionRequested
+ ) {
+ return;
+ }
+
+ pendingSentence.correctionRequested = true;
+ pendingCorrectionCount++;
+ try {
+ const response = await fetch(`/tool/speakr/api/asr/correct`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: pendingSentence.rawText }), // 发送当前新增文本去矫正
+ });
+
+ if (!response.ok) {
+ throw new Error("ASR correction failed");
+ }
+
+ const data = await response.json();
+ let correctedText =
+ data.corrected_text || pendingSentence.rawText;
+
+ // 4. LLM 回来后,直接更新文本内容(自动应用)
+ finalizePendingSentence(
+ pendingSentence.id,
+ correctedText,
+ messageEpoch
+ );
+ console.log("LLM矫正完成");
+ } catch (error) {
+ finalizePendingSentence(
+ pendingSentence.id,
+ pendingSentence.rawText,
+ messageEpoch
+ );
+ console.error("LLM矫正出错:", error);
+ } finally {
+ pendingCorrectionCount = Math.max(
+ 0,
+ pendingCorrectionCount - 1
+ );
+ }
+ }
+
+ // ============================
+ // 场景 B: offline-speaker (说话人后到)
+ // ============================
+ else if (asrmodeltype == "offline-speaker") {
+ if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
+ if (is_final.value == true) {
+ wsconnecter.wsStop();
+ }
+ return;
+ }
+ // 【修改点】:这里不再获取 full_text 用于替换
+ // var rectxt = "" + JSON.parse(jsonMsg.data)["full_text"]; // 这行删掉或注释
+
+ let nowSpeaker =
+ (parsedMessage["segments"] &&
+ parsedMessage["segments"][0] &&
+ parsedMessage["segments"][0].speaker) ||
+ "未命名演讲者";
+
+ // 找到最后一个还没认领说话人的句子
+ let targetIndex = -1;
+ for (let i = segmentList.length - 1; i >= 0; i--) {
+ const segment = segmentList[i];
+ if (!segment || segment.epoch !== messageEpoch) {
+ continue;
+ }
+ if (
+ segment.speaker === null &&
+ Number.isFinite(messageTimestampMs) &&
+ Number.isFinite(segment.timestampMs) &&
+ segment.timestampMs === messageTimestampMs
+ ) {
+ targetIndex = i;
+ break;
+ }
+ if (targetIndex === -1 && segment.speaker === null) {
+ targetIndex = i;
+ }
+ }
+
+ if (targetIndex !== -1) {
+ // 1. 只更新说话人
+ segmentList[targetIndex].speaker = nowSpeaker;
+
+ // 【关键】:这里绝对不更新 text,保持 2pass-offline 时的原样
+ // segmentList[targetIndex].text = ...; // 不要这行
+
+ // 2. 立即渲染
+ // 效果:文本没变(不会跳变),只是头上多了个名字
+ renderFullText();
+ }
+ }
+
+ if (is_final.value == true) {
+ wsconnecter.wsStop();
+ }
+ }
+
+ // 连接状态响应
+ function getConnState(connState) {
+ if (connState === 0) {
+ record();
+ } else if (connState === 1) {
+ } else if (connState === 2) {
+ stop();
+ }
+ }
+ // 连接状态响应
+ function getConnState2(connState) {
+ if (connState === 0) {
+ record2();
+ } else if (connState === 1) {
+ } else if (connState === 2) {
+ stop();
+ }
+ }
+ async function getMicrophoneInfo() {
+ if (
+ !navigator.mediaDevices ||
+ !navigator.mediaDevices.enumerateDevices
+ ) {
+ console.warn("当前浏览器不支持获取设备信息");
+ return null;
+ }
+
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const mics = devices.filter((d) => d.kind === "audioinput");
+
+ // 当前默认麦克风(大多数浏览器)
+ const defaultMic =
+ mics.find((d) => d.deviceId === "default") || mics[0];
+
+ return {
+ name: defaultMic?.label || "未知麦克风",
+ deviceId: defaultMic?.deviceId || "",
+ all: mics.map((d) => ({
+ name: d.label,
+ deviceId: d.deviceId,
+ })),
+ };
+ }
+ async function openMixedStream() {
+ try {
+ const micInfo = await getMicrophoneInfo();
+ if (micInfo) {
+ console.log("🎤 当前麦克风:", micInfo.name, micInfo);
+ }
+ // 获取麦克风音频流
+ const micStreams = micStream;
+
+ // 尝试获取系统音频流
+ let systemStreams;
+ try {
+ systemStreams = systemStream;
+ } catch (err) {
+ console.warn("未能获取系统音频,仅使用麦克风录音:", err);
+ }
+
+ // 创建音频上下文和混音目标
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ }
+ asrAudioContext = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ const audioContext = asrAudioContext;
+ const destination = audioContext.createMediaStreamDestination();
+
+ // 把麦克风接入混音输出
+ const micSource =
+ audioContext.createMediaStreamSource(micStreams);
+ micSource.connect(destination);
+
+ // 如果有系统音频,混入
+ if (systemStream && systemStream.getAudioTracks().length > 0) {
+ const sysSource =
+ audioContext.createMediaStreamSource(systemStream);
+ sysSource.connect(destination);
+ } else {
+ console.log("⚠️ 未检测到系统音频,使用麦克风录音");
+ }
+
+ // 打开 recorder 并传入混合后的流
+ rec.open(
+ () => {
+ rec.start(destination.stream);
+ },
+ (msg) => {
+ console.error("❌ 录音权限被拒绝:", msg);
+ }
+ );
+ } catch (err) {
+ console.error("初始化录音失败:", err);
+ }
+ }
+ async function openMixedStream2() {
+ try {
+ // 获取麦克风音频流
+ const micStreams = micStream;
+
+ // 尝试获取系统音频流(Chrome 需要用户手动勾选“共享系统音频”)
+ let systemStreams;
+ try {
+ systemStreams = systemStream;
+ } catch (err) {
+ console.warn("未能获取系统音频,仅使用麦克风录音:", err);
+ }
+
+ // 创建音频上下文和混音目标
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ }
+ asrAudioContext2 = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ const audioContext = asrAudioContext2;
+ const destination = audioContext.createMediaStreamDestination();
+
+ // 把麦克风接入混音输出
+ const micSource =
+ audioContext.createMediaStreamSource(micStreams);
+ micSource.connect(destination);
+
+ // 如果有系统音频,混入
+ if (systemStream && systemStream.getAudioTracks().length > 0) {
+ const sysSource =
+ audioContext.createMediaStreamSource(systemStream);
+ sysSource.connect(destination);
+ } else {
+ console.log("⚠️ 未检测到系统音频,使用麦克风录音");
+ }
+
+ // 打开 recorder 并传入混合后的流
+ rec2.open(
+ () => {
+ rec2.start(destination.stream);
+ },
+ (msg) => {
+ console.error("❌ 录音权限被拒绝:", msg);
+ }
+ );
+ } catch (err) {
+ console.error("初始化录音失败:", err);
+ }
+ }
+
+ //------------------- 实时 ASR 双 WebSocket 启动 start------------------
+ const start = () => {
+ // 清除显示
+ // clear();
+ //控件状态更新
+
+ //启动 online 连接:不传 URL 时仍连接默认 /websocket_offline,
+ //真正区别是 wsconnecter.js 将 modeType 设置为 "online"。
+ var ret = wsconnecter.wsStart();
+ if (ret == 1) {
+ isRec = true;
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+ const start2 = () => {
+ // 清除显示
+ // clear();
+ //控件状态更新
+
+ let urls = `wss://${wssBaseUrl.FUNASR_WEBSOCKET_IP}/websocket_offline`;
+ //启动 offline 连接:URL 与 online 默认地址相同,区别在握手 mode:"offline"。
+ var ret = wsconnecter2.wsStart(urls);
+ if (ret == 1) {
+ isRec = true;
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+ //--------------------实时 ASR 双 WebSocket 启动 end------------------
+ if (!isPaused.value) {
+ start();
+ start2();
+ }
+ //------------------- 实时 ASR 双 Recorder 实例 start------------------
+ // 这里的 rec/rec2 只负责实时 ASR PCM 推流,不是最终保存录音文件的 MediaRecorder。
+ // 两个 Recorder 都监听同一份混合音频流,分别进入 online/offline 两条 WebSocket 链路。
+ rec = Recorder({
+ type: "pcm",
+ bitRate: 16,
+ sampleRate: 16000,
+ onProcess: recProcess,
+ });
+ rec2 = Recorder({
+ type: "pcm",
+ bitRate: 16,
+ sampleRate: 16000,
+ onProcess: recProcess2,
+ });
+ //--------------------实时 ASR 双 Recorder 实例 end------------------
+
+ // Fix audio duration for recorded files
+ if (audioBlobURL.value) {
+ const recordingAudio = document.querySelector(
+ 'audio[src*="blob:"]'
+ );
+ if (recordingAudio) {
+ const fixDuration = () => {
+ if (
+ !isFinite(recordingAudio.duration) ||
+ recordingAudio.duration === 0
+ ) {
+
+ const originalTime = recordingAudio.currentTime;
+ recordingAudio.currentTime = 1e101;
+ recordingAudio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ recordingAudio.currentTime = originalTime;
+ recordingAudio.removeEventListener(
+ "timeupdate",
+ resetTime
+ );
+ },
+ { once: true }
+ );
+ }
+ };
+ recordingAudio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("canplay", fixDuration, {
+ once: true,
+ });
+ if (recordingAudio.readyState >= 1) {
+ setTimeout(fixDuration, 100);
+ }
+ }
+ }
+ });
+ } else {
+ liveTranscriptionEpoch += 1;
+ realtimeTimestampBarrierMs = -1;
+ clearRealtimePipelineRef = null;
+ getLatestRealtimeTimestampRef = null;
+ buildRealtimeEditCarryoverRef = null;
+ renderFullTextRef = null;
+ isRec = false;
+ analysisResult.value = "";
+ asrResult.value = "";
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ isEditingTranscription.value = false;
+ is_final.value = false;
+ if (recordingMarkdownEditorInstance.value) {
+ recordingMarkdownEditorInstance.value.toTextArea();
+ recordingMarkdownEditorInstance.value = null;
+ }
+ if (webReader) {
+ webReader.wsStop();
+ }
+ if (webReader2) {
+ webReader2.wsStop();
+ }
+ // 关闭 Recorder 实例,停止 onProcess 回调
+ if (rec) {
+ try { rec.close(); } catch(e) {}
+ rec = null;
+ }
+ if (rec2) {
+ try { rec2.close(); } catch(e) {}
+ rec2 = null;
+ }
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ asrAudioContext = null;
+ }
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ asrAudioContext2 = null;
+ }
+
+ // Clean up recording markdown editor when leaving recording view
+ }
+ });
+ //--------------------录音视图切换与实时 ASR 管线 end------------------
+
+ // 监听内容变化
+ watch(asrResult, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+ if (asrTextarea.value) {
+ // console.log(asrTextarea.value.scrollHeight,'asrTextarea.value.scrollHeight');
+
+ asrTextarea.value.scrollTop = asrTextarea.value.scrollHeight;
+ }
+ if (asrResultTextarea.value) {
+ asrResultTextarea.value.scrollTop =
+ asrResultTextarea.value.scrollHeight;
+ }
+ });
+ // 监听内容变化
+ watch(asrResultOnline, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+
+ if (asrResultTextareaOnline.value) {
+ asrResultTextareaOnline.value.scrollTop =
+ asrResultTextareaOnline.value.scrollHeight;
+ }
+ });
+ watch(asrResultOffline, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+
+ if (asrResultTextareaOnline.value) {
+ asrResultTextareaOnline.value.scrollTop =
+ asrResultTextareaOnline.value.scrollHeight;
+ }
+ });
+ let realtimeAutoScrollFrame = null;
+ const scrollRealtimeTextareaToBottom = (textarea) => {
+ if (!textarea) return;
+ textarea.scrollTop = textarea.scrollHeight;
+ };
+ const scrollVisibleRealtimeTextareas = () => {
+ [
+ asrTextarea.value,
+ asrResultTextarea.value,
+ asrResultTextareaOnline.value,
+ ].forEach(scrollRealtimeTextareaToBottom);
+ };
+ const scheduleRealtimeAutoScroll = async (force = false) => {
+ if (isEditingTranscription.value && !force) {
+ return;
+ }
+
+ await nextTick();
+ scrollVisibleRealtimeTextareas();
+
+ if (
+ typeof window === "undefined" ||
+ typeof window.requestAnimationFrame !== "function"
+ ) {
+ return;
+ }
+
+ if (realtimeAutoScrollFrame !== null) {
+ window.cancelAnimationFrame(realtimeAutoScrollFrame);
+ realtimeAutoScrollFrame = null;
+ }
+
+ realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
+ scrollVisibleRealtimeTextareas();
+ realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
+ scrollVisibleRealtimeTextareas();
+ realtimeAutoScrollFrame = null;
+ });
+ });
+ };
+ watch(fullRealtimeTranscription, () => {
+ scheduleRealtimeAutoScroll();
+ });
+ watch(isEditingTranscription, () => {
+ scheduleRealtimeAutoScroll(true);
+ });
+ watch(isyjDialogVisible, (visible) => {
+ if (visible) {
+ scheduleRealtimeAutoScroll(true);
+ }
+ });
+ watch(analysisResult, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+ if (sumupResultTextarea.value) {
+ sumupResultTextarea.value.scrollTop =
+ sumupResultTextarea.value.scrollHeight;
+ }
+ });
+ watch(audioBlobURL, (newURL) => {
+ if (newURL) {
+ nextTick(() => {
+ const recordingAudio = document.querySelector(
+ 'audio[src*="blob:"]'
+ );
+
+ if (recordingAudio) {
+ const fixDuration = () => {
+ if (
+ !isFinite(recordingAudio.duration) ||
+ recordingAudio.duration === 0
+ ) {
+ const originalTime = recordingAudio.currentTime;
+ // recordingAudio.currentTime = 1e101;
+ recordingAudio.currentTime = originalTime + 0.01
+
+ recordingAudio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ recordingAudio.currentTime = originalTime;
+ recordingAudio.removeEventListener(
+ "timeupdate",
+ resetTime
+ );
+ },
+ { once: true }
+ );
+ }
+ };
+
+ // Multiple events to ensure we catch the duration
+ recordingAudio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("loadeddata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("canplay", fixDuration, {
+ once: true,
+ });
+
+ // Also try after a delay
+ if (recordingAudio.readyState >= 1) {
+ setTimeout(fixDuration, 100);
+ }
+ }
+ });
+ }
+ });
+
+ watch(playerVolume, (newVolume) => {
+ const audioElements = document.querySelectorAll("audio");
+ audioElements.forEach((audio) => {
+ if (audio.volume !== newVolume) {
+ audio.volume = newVolume;
+ }
+ });
+ });
+
+ // Watch for search query changes
+ watch(searchQuery, (newQuery) => {
+ debouncedSearch(newQuery);
+ });
+
+ // Auto-apply filters when they change (except text query which is debounced)
+ watch(
+ filterTags,
+ () => {
+ applyAdvancedFilters();
+ },
+ { deep: true }
+ );
+
+ watch(filterDatePreset, () => {
+ applyAdvancedFilters();
+ });
+
+ watch(
+ filterDateRange,
+ () => {
+ applyAdvancedFilters();
+ },
+ { deep: true }
+ );
+
+ // Debounce text query changes
+ watch(filterTextQuery, (newValue) => {
+ clearTimeout(searchDebounceTimer.value);
+ searchDebounceTimer.value = setTimeout(() => {
+ applyAdvancedFilters();
+ }, 300);
+ });
+
+ //--------------------响应式监听与界面联动 end------------------
+
+ //------------------- 运行时配置加载 start------------------
+ // --- Configuration Loading ---
+ const loadConfiguration = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/config");
+ if (response.ok) {
+ const config = await response.json();
+ maxFileSizeMB.value = config.max_file_size_mb || 250;
+ chunkingEnabled.value =
+ config.chunking_enabled !== undefined
+ ? config.chunking_enabled
+ : true;
+ chunkingMode.value = config.chunking_mode || "size";
+ chunkingLimit.value = config.chunking_limit || 20;
+ chunkingLimitDisplay.value =
+ config.chunking_limit_display || "20MB";
+ recordingDisclaimer.value = config.recording_disclaimer || "";
+ console.log(
+ `Loaded configuration: max size ${
+ maxFileSizeMB.value
+ }MB, chunking ${
+ chunkingEnabled.value ? "enabled" : "disabled"
+ } (${chunkingLimitDisplay.value})`
+ );
+ } else {
+ console.warn("Failed to load configuration, using default values");
+ }
+ } catch (error) {
+ console.error("Error loading configuration:", error);
+ // Keep default values on error
+ }
+ };
+
+ const initializeSummaryMarkdownEditor = () => {
+ if (!summaryMarkdownEditor.value) return;
+
+ try {
+ summaryMarkdownEditorInstance.value = new EasyMDE({
+ element: summaryMarkdownEditor.value,
+ spellChecker: false,
+ autofocus: true,
+ placeholder: "Enter summary in Markdown format...",
+ initialValue: selectedRecording.value?.summary || "",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "image",
+ "|",
+ "preview",
+ "side-by-side",
+ "fullscreen",
+ "|",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ });
+
+ // Add auto-save functionality
+ summaryMarkdownEditorInstance.value.codemirror.on("change", () => {
+ if (autoSaveTimer.value) {
+ clearTimeout(autoSaveTimer.value);
+ }
+ autoSaveTimer.value = setTimeout(() => {
+ autoSaveSummary();
+ }, autoSaveDelay);
+ });
+ } catch (error) {
+ console.error("Failed to initialize summary markdown editor:", error);
+ editingSummary.value = true;
+ }
+ };
+
+ const pollInboxRecordings = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/inbox_recordings");
+ if (!response.ok) {
+ // Silently fail, as this is a background task
+ return;
+ }
+ const inboxRecordings = await response.json();
+
+ if (inboxRecordings && inboxRecordings.length > 0) {
+ inboxRecordings.forEach((recording) => {
+ // Add to main recordings list if it's not there already
+ const existingRecording = recordings.value.find(
+ (r) => r.id === recording.id
+ );
+ if (!existingRecording) {
+ recordings.value.unshift(recording);
+ totalRecordings.value++; // Update total count
+ }
+
+ // Check if this recording is already in the upload queue or currently being processed
+ const existingItem = uploadQueue.value.find(
+ (item) => item.recordingId === recording.id
+ );
+ const isCurrentlyProcessing =
+ currentlyProcessingFile.value &&
+ currentlyProcessingFile.value.recordingId === recording.id;
+
+ // Don't add if it's already in queue or being processed
+ if (existingItem || isCurrentlyProcessing) {
+ // Update status if the existing item status has changed
+ if (existingItem && existingItem.status !== recording.status) {
+ console.log(
+ `Updating status for existing recording ${recording.original_filename}: ${existingItem.status} -> ${recording.status}`
+ );
+ }
+ return;
+ }
+
+ // Only add recordings that are still processing (not completed)
+ if (recording.status === "COMPLETED") {
+ console.log(
+ `Skipping completed inbox recording: ${recording.original_filename}`
+ );
+ return;
+ }
+
+ console.log(
+ `Found new inbox recording: ${recording.original_filename} (status: ${recording.status})`
+ );
+
+ // Don't add recordings that are already being handled by main processing
+ if (
+ recording.status === "SUMMARIZING" &&
+ isProcessingActive.value
+ ) {
+ console.log(
+ `Skipping ${recording.original_filename} - already in summarization phase of main processing`
+ );
+ return;
+ }
+
+ // Add it to the queue to be displayed in the progress modal
+ const inboxItem = {
+ file: {
+ name:
+ recording.original_filename || `Recording ${recording.id}`,
+ size: recording.file_size || 0,
+ },
+ status: "pending", // It's already being processed on backend
+ recordingId: recording.id,
+ clientId: `inbox-${recording.id}-${Date.now()}`,
+ error: null,
+ isReprocessing: true, // Use reprocessing logic to poll for status
+ reprocessType: "transcription",
+ };
+ uploadQueue.value.unshift(inboxItem);
+
+ // If nothing is currently being processed, make this the active item for the main progress bar
+ if (!isProcessingActive.value && !currentlyProcessingFile.value) {
+ currentlyProcessingFile.value = inboxItem;
+ }
+
+ // Show progress modal if it's hidden
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ // Start polling its status
+ startReprocessingPoll(recording.id);
+ });
+ }
+ } catch (error) {
+ console.error("Error polling for inbox recordings:", error);
+ }
+ };
+
+ //--------------------运行时配置加载 end------------------
+
+ //------------------- 录音格式兼容检测 start------------------
+ // --- Audio Format Detection ---
+ const detectSupportedAudioFormats = () => {
+ const formats = [
+ "audio/webm;codecs=opus",
+ "audio/webm;codecs=vp9",
+ "audio/webm",
+ "audio/mp4;codecs=mp4a.40.2",
+ "audio/mp4",
+ "audio/ogg;codecs=opus",
+ "audio/wav",
+ ];
+
+ const supportedFormats = formats.filter((format) =>
+ MediaRecorder.isTypeSupported(format)
+ );
+ console.log("Supported audio recording formats:", supportedFormats);
+
+ if (supportedFormats.length === 0) {
+ console.warn(
+ "No optimized audio formats supported, will use browser default"
+ );
+ }
+
+ return supportedFormats;
+ };
+
+ //--------------------录音格式兼容检测 end------------------
+
+ //------------------- 系统音频能力检测 start------------------
+ // --- System Audio Detection ---
+ const detectSystemAudioCapabilities = async () => {
+ systemAudioSupported.value = false;
+ canRecordSystemAudio.value = false;
+ systemAudioError.value = "";
+
+ // Check if getDisplayMedia is available
+ if (
+ !navigator.mediaDevices ||
+ !navigator.mediaDevices.getDisplayMedia
+ ) {
+ systemAudioError.value = "getDisplayMedia API not supported";
+ return;
+ }
+
+ try {
+ // Test if we can request system audio (this will prompt user)
+ // We'll do this only when user actually tries to record
+ systemAudioSupported.value = true;
+ canRecordSystemAudio.value = true;
+ systemAudioError.value = "";
+ } catch (error) {
+ systemAudioError.value = error.message;
+ console.warn("System audio detection failed:", error);
+ }
+ };
+
+ const detectBrowser = () => {
+ const userAgent = navigator.userAgent;
+ if (userAgent.includes("Firefox")) {
+ browser.value = "firefox";
+ } else if (userAgent.includes("Chrome")) {
+ browser.value = "chrome";
+ } else if (userAgent.includes("Safari")) {
+ browser.value = "safari";
+ } else if (
+ userAgent.includes("MSIE") ||
+ userAgent.includes("Trident/")
+ ) {
+ browser.value = "ie";
+ } else {
+ browser.value = "unknown";
+ }
+ };
+
+ //--------------------系统音频能力检测 end------------------
+
+ //------------------- 生命周期初始化 start------------------
+ // --- Lifecycle ---
+ onMounted(async () => {
+ const loader = document.getElementById("loader");
+ const app = document.getElementById("app");
+ if (loader) {
+ loader.style.opacity = "0";
+ setTimeout(() => {
+ loader.style.display = "none";
+ }, 500);
+ }
+ if (app) {
+ app.style.opacity = "1";
+ }
+ const appDiv = document.getElementById("app");
+ if (appDiv) {
+ const asrFlag = appDiv.dataset.useAsrEndpoint;
+ useAsrEndpoint.value = asrFlag === "True" || asrFlag === "true";
+ currentUserName.value = appDiv.dataset.currentUserName || "";
+ }
+
+ // Load configuration first
+ await loadConfiguration();
+
+ // Detect system audio capabilities
+ await detectSystemAudioCapabilities();
+
+ detectBrowser();
+
+ // Check if language was changed in account settings
+ if (localStorage.getItem("ui_language_changed") === "true") {
+ localStorage.removeItem("ui_language_changed");
+ // Force reload to apply new language
+ window.location.reload();
+ return;
+ }
+
+ // i18n is already initialized before Vue app creation
+ if (window.i18n) {
+ currentLanguage.value = window.i18n.getLocale();
+ availableLanguages.value = window.i18n.getAvailableLocales();
+ const lang = availableLanguages.value.find(
+ (l) => l.code === currentLanguage.value
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+
+ // Listen for locale changes
+ window.addEventListener("localeChanged", (event) => {
+ currentLanguage.value = event.detail.locale;
+ const lang = availableLanguages.value.find(
+ (l) => l.code === currentLanguage.value
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+ });
+ }
+
+ loadRecordings();
+ loadTags();
+ loadDrafts(); // 加载草稿列表
+ initializeDarkMode();
+ initializeColorScheme();
+
+ const savedVolume = localStorage.getItem("playerVolume");
+ if (savedVolume !== null) {
+ playerVolume.value = parseFloat(savedVolume);
+ }
+
+ const savedTranscriptionViewMode = localStorage.getItem(
+ "transcriptionViewMode"
+ );
+ if (savedTranscriptionViewMode) {
+ transcriptionViewMode.value = savedTranscriptionViewMode;
+ }
+
+ // Load saved column widths
+ const savedLeftWidth = localStorage.getItem("transcriptColumnWidth");
+ const savedRightWidth = localStorage.getItem("summaryColumnWidth");
+ if (savedLeftWidth && savedRightWidth) {
+ leftColumnWidth.value = parseFloat(savedLeftWidth);
+ rightColumnWidth.value = parseFloat(savedRightWidth);
+ }
+
+ const updateMobileStatus = () => {
+ windowWidth.value = window.innerWidth;
+ };
+
+ window.addEventListener("resize", updateMobileStatus);
+ updateMobileStatus();
+
+ // Start polling for inbox recordings
+ setInterval(pollInboxRecordings, 10000);
+
+ const handleEsc = (e) => {
+ if (e.key === "Escape") {
+ if (showColorSchemeModal.value) {
+ closeColorSchemeModal();
+ }
+ if (showEditModal.value) {
+ cancelEdit();
+ }
+ if (showDeleteModal.value) {
+ cancelDelete();
+ }
+ if (showSortOptions.value) {
+ showSortOptions.value = false;
+ }
+ }
+ };
+ document.addEventListener("keydown", handleEsc);
+
+ // Click away handler for dropdowns
+ const handleClickAway = (e) => {
+ // Close user menu if clicking outside
+ if (isUserMenuOpen.value) {
+ // Check if we clicked within the user menu area (button or dropdown)
+ const userMenuButton = e.target.closest(
+ 'button[class*="flex items-center gap"]'
+ );
+ const userMenuDropdown = e.target.closest(
+ 'div[class*="absolute right-0"]'
+ );
+
+ // Check if the click was on the user menu button specifically
+ const isUserMenuButtonClick =
+ userMenuButton &&
+ userMenuButton.querySelector("i.fa-user-circle");
+
+ // If we didn't click on the user menu button or dropdown, close it
+ if (!isUserMenuButtonClick && !userMenuDropdown) {
+ isUserMenuOpen.value = false;
+ }
+ }
+
+ // Close speaker suggestions if clicking outside in the modal
+ if (showSpeakerModal.value) {
+ // If not clicking on an input field or suggestion dropdown
+ const clickedOnInput = e.target.closest('input[type="text"]');
+ const clickedOnSuggestion = e.target.closest(
+ '[class*="absolute z-10"]'
+ );
+
+ if (!clickedOnInput && !clickedOnSuggestion) {
+ // Close all suggestion dropdowns
+ Object.keys(speakerSuggestions.value).forEach((speakerId) => {
+ speakerSuggestions.value[speakerId] = [];
+ });
+ }
+ }
+ };
+ document.addEventListener("click", handleClickAway);
+ });
+
+ //--------------------生命周期初始化 end------------------
+
+ //------------------- 实时转写手动编辑模式 start------------------
+ // --- 转录文本编辑功能 ---
+ // 用户手动编辑后,以编辑结果为准,旧的实时结果不再回灌。
+ let editModeOriginalText = "";
+
+ function enterEditMode() {
+ editModeOriginalText = fullRealtimeTranscription.value || "";
+ editableText.value = editModeOriginalText;
+ editModeBufferStartIndex = segmentList.length;
+ const barrierTimestampMs = getLatestRealtimeTimestampRef
+ ? getLatestRealtimeTimestampRef()
+ : null;
+ startNewRealtimeEpoch({
+ barrierTimestampMs,
+ clearEditCarryover: false,
+ });
+ realtimeEditCarryover = buildRealtimeEditCarryoverRef
+ ? buildRealtimeEditCarryoverRef(editModeOriginalText)
+ : null;
+ isEditingTranscription.value = true;
+ }
+
+ function exitEditMode() {
+ const nextText = editableText.value ?? "";
+
+ isEditingTranscription.value = false;
+
+ pausedTranscription.value = nextText;
+ asrResult.value = nextText;
+ asrResultOnline.value = "";
+ asrResultOffline.value = "";
+ segmentRenderStartIndex = editModeBufferStartIndex;
+ editModeOriginalText = nextText;
+
+ if (renderFullTextRef) {
+ renderFullTextRef();
+ }
+ }
+
+ //--------------------实时转写手动编辑模式 end------------------
+
+ //------------------- 模板暴露与事件出口 start------------------
+ return {
+ // Core State
+ currentView,
+ dragover,
+ recordings,
+ selectedRecording,
+ selectedTab,
+ searchQuery,
+ isLoadingRecordings,
+ globalError,
+ maxFileSizeMB,
+ chunkingEnabled,
+ chunkingMode,
+ chunkingLimit,
+ chunkingLimitDisplay,
+ sortBy,
+ showAdvancedFilters,
+ filterTags,
+ filterDateRange,
+ filterDatePreset,
+ filterTextQuery,
+
+ // Pagination State
+ currentPage,
+ perPage,
+ totalRecordings,
+ totalPages,
+ hasNextPage,
+ hasPrevPage,
+ isLoadingMore,
+
+ // UI State
+ browser,
+ isSidebarCollapsed,
+ searchTipsExpanded,
+ isUserMenuOpen,
+ isDarkMode,
+ currentColorScheme,
+ showColorSchemeModal,
+ windowWidth,
+ isMobileScreen,
+ mobileTab,
+ isMetadataExpanded,
+
+ // i18n State
+ currentLanguage,
+ currentLanguageName,
+ availableLanguages,
+ showLanguageMenu,
+
+ // Upload State
+ uploadQueue,
+ currentlyProcessingFile,
+ processingProgress,
+ processingMessage,
+ isProcessingActive,
+ progressPopupMinimized,
+ progressPopupClosed,
+ totalInQueue,
+ completedInQueue,
+ finishedFilesInQueue,
+ clearCompletedUploads,
+
+ // Audio Recording
+ editingDraftId,
+ editingDraftName,
+
+ isRecording,
+ canRecordAudio,
+ canRecordSystemAudio,
+ systemAudioSupported,
+ systemAudioError,
+ audioBlobURL,
+ recordingTime,
+ recordingNotes,
+ visualizer,
+ micVisualizer,
+ systemVisualizer,
+ recordingMode,
+ recordingDisclaimer,
+ showRecordingDisclaimerModal,
+ acceptRecordingDisclaimer,
+ cancelRecordingDisclaimer,
+ showAdvancedOptions,
+ uploadLanguage,
+ uploadMinSpeakers,
+ uploadMaxSpeakers,
+ asrLanguage,
+ asrMinSpeakers,
+ asrMaxSpeakers,
+ availableTags,
+ selectedTagIds,
+ selectedTags,
+ uploadTagSearchFilter,
+ filteredAvailableTagsForUpload,
+ onTagSelected,
+ addTagToSelection,
+ removeTagFromSelection,
+ showSystemAudioHelp,
+
+ // Draft/Pause Recording
+ isPaused,
+ isStoppingRecording,
+ currentDraftId,
+ showRecordingControls,
+ pausedDuration,
+ pausedTranscription,
+ draftRecordings,
+ draftsExpanded,
+ pauseRecording,
+ resumeRecording,
+ loadDrafts,
+ continueDraftRecording,
+ deleteDraft,
+
+ // Modal State
+ showEditModal,
+ showDeleteModal,
+ showResetModal,
+ editingRecording,
+ recordingToDelete,
+ showEditTagsModal,
+ selectedNewTagId,
+ tagSearchFilter,
+ filteredAvailableTagsForModal,
+ editRecordingTags,
+ closeEditTagsModal,
+ addTagToRecording,
+ removeTagFromRecording,
+ getRecordingTags,
+ getAvailableTagsForRecording,
+ filterByTag,
+ clearTagFilter,
+ applyAdvancedFilters,
+ clearAllFilters,
+ showTextEditorModal,
+ showAsrEditorModal,
+ editingTranscriptionContent,
+ editingSegments,
+ availableSpeakers,
+
+ // Inline Editing
+ editingParticipants,
+ editingMeetingDate,
+ editingSummary,
+ editingNotes,
+
+ // Markdown Editor
+ notesMarkdownEditor,
+ markdownEditorInstance,
+ summaryMarkdownEditor,
+ summaryMarkdownEditorInstance,
+ recordingNotesEditor,
+
+ // Transcription
+ transcriptionViewMode,
+ legendExpanded,
+ highlightedSpeaker,
+ processedTranscription,
+
+ // Chat
+ showChat,
+ isChatMaximized,
+ toggleChatMaximize,
+ chatMessages,
+ chatInput,
+ isChatLoading,
+ chatMessagesRef,
+
+ // Audio Player
+ playerVolume,
+
+ // Column Resizing
+ leftColumnWidth,
+ rightColumnWidth,
+ isResizing,
+
+ // App Configuration
+ useAsrEndpoint,
+ currentUserName,
+
+ // Computed
+ filteredRecordings,
+ groupedRecordings,
+ activeRecordingMetadata,
+ datePresetOptions,
+ languageOptions,
+
+ // Color Schemes
+ colorSchemes,
+ asrResult,
+ asrResultOnline,
+ asrResultOffline,
+ fullRealtimeTranscription,
+ asrTextarea,
+ asrResultTextarea,
+ asrResultTextareaOnline,
+ analysisResult,
+ sumupResultTextarea,
+ isDialogVisible,
+ isyjDialogVisible,
+ baseInfo,
+ isModalOpen,
+ urlinfo,
+ urlmsg,
+ zjloding,
+ // Methods
+ openmodel,
+ openzjmodel,
+ closeModal,
+ setGlobalError,
+ formatFileSize,
+ formatDisplayDate,
+ formatStatus,
+ getStatusClass,
+ formatTime,
+ t,
+ tc,
+ changeLanguage,
+ toggleDarkMode,
+ applyColorScheme,
+ initializeColorScheme,
+ openColorSchemeModal,
+ closeColorSchemeModal,
+ selectColorScheme,
+ resetColorScheme,
+ toggleSidebar,
+ switchToUploadView,
+ selectRecording,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ handleFileSelect,
+ addFilesToQueue,
+ startRecording,
+ stopRecording,
+ uploadRecordedAudio,
+ discardRecording,
+ downloadRecordedAudio,
+ loadRecordings,
+ loadMoreRecordings,
+ performSearch,
+ debouncedSearch,
+ saveMetadata,
+ editRecording,
+ cancelEdit,
+ saveEdit,
+ confirmDelete,
+ cancelDelete,
+ deleteRecording,
+ toggleEditParticipants,
+ toggleEditMeetingDate,
+ toggleEditSummary,
+ cancelEditSummary,
+ saveEditSummary,
+ toggleEditNotes,
+ cancelEditNotes,
+ saveEditNotes,
+ initializeMarkdownEditor,
+ saveInlineEdit,
+ clickToEditNotes,
+ clickToEditSummary,
+ autoSaveNotes,
+ autoSaveSummary,
+ sendChatMessage,
+ isChatScrolledToBottom,
+ scrollChatToBottom,
+ startColumnResize,
+ handleChatKeydown,
+ seekAudio,
+ seekAudioFromEvent,
+ onPlayerVolumeChange,
+ showToast,
+ copyMessage,
+ copyTranscription,
+ copySummary,
+ copyNotes,
+ downloadSummary,
+ downloadTranscript,
+ downloadNotes,
+ downloadChat,
+ clearChat,
+ downloadEventICS,
+ downloadICS,
+ formatEventDateTime,
+ toggleInbox,
+ toggleHighlight,
+ toggleTranscriptionViewMode,
+ reprocessTranscription,
+ reprocessSummary,
+ generateSummary,
+ resetRecordingStatus,
+ confirmReset,
+ cancelReset,
+ executeReset,
+ openTranscriptionEditor,
+ openTextEditorModal,
+ closeTextEditorModal,
+ saveTranscription,
+ openAsrEditorModal,
+ closeAsrEditorModal,
+ saveAsrTranscription,
+ adjustTime,
+ filterSpeakers,
+ openSpeakerSuggestions,
+ closeSpeakerSuggestions,
+ selectSpeaker,
+ addSegment,
+ removeSegment,
+ showReprocessModal,
+ reprocessType,
+ reprocessRecording,
+ cancelReprocess,
+ executeReprocess,
+ asrReprocessOptions,
+ showSpeakerModal,
+ showShareModal,
+ recordingToShare,
+ shareOptions,
+ generatedShareLink,
+ existingShareDetected,
+ userShares,
+ isLoadingShares,
+ showSharesListModal,
+ shareToDelete,
+ showShareDeleteModal,
+ confirmDeleteShare,
+ cancelDeleteShare,
+ speakerMap,
+ speakerDisplayMap,
+ modalSpeakers,
+ regenerateSummaryAfterSpeakerUpdate,
+ identifiedSpeakers,
+ identifiedSpeakersInOrder,
+ hasSpeakerNames,
+ openSpeakerModal,
+ closeSpeakerModal,
+ saveSpeakerNames,
+ highlightedTranscript,
+ highlightedSpeaker,
+ highlightSpeakerInTranscript,
+ focusSpeaker,
+ blurSpeaker,
+ clearSpeakerHighlight,
+ currentSpeakerGroupIndex,
+ speakerGroups,
+ navigateToNextSpeakerGroup,
+ navigateToPrevSpeakerGroup,
+ speakerSuggestions,
+ loadingSuggestions,
+ activeSpeakerInput,
+ searchSpeakers,
+ selectSpeakerSuggestion,
+ closeSpeakerSuggestionsOnClick,
+ autoIdentifySpeakers,
+ isAutoIdentifying,
+ formatDuration,
+ openShareModal,
+ closeShareModal,
+ createShare,
+ openSharesList,
+ closeSharesList,
+ updateShare,
+ deleteShare,
+ copyShareLink,
+ startDraftRename,
+ saveDraftRename,
+ editingDraftId,
+ editingDraftName,
+ draftNameInput,
+ draftRecordings,
+ deleteDraft,
+ continueDraftRecording,
+ loadDrafts,
+
+ // Recording Size Monitoring
+ estimatedFileSize,
+ fileSizeWarningShown,
+ recordingQuality,
+ actualBitrate,
+ maxRecordingMB,
+ sizeCheckInterval,
+ updateFileSizeEstimate,
+ startSizeMonitoring,
+ stopSizeMonitoring,
+ sumupResult,
+ lastTimer,
+ promptVisible,
+ promptList,
+ openPromptModal,
+ closePromptModal,
+ confirmPromptSelection,
+ selectedPromptId,
+ recommendPromptId,
+ recommendPromptName,
+ isMarkdown,
+ renderedMarkdown,
+ recommendPromptReason,
+ showSpeaker,
+ isEditingTranscription,
+ editableText,
+ enterEditMode,
+ exitEditMode,
+ };
+ },
+ delimiters: ["${", "}"],
+ });
+
+ //--------------------模板暴露与事件出口 end------------------
+
+ //--------------------Vue 应用主体定义 end------------------
+
+ //------------------- Vue 全局注入与挂载 start------------------
+ // Add t function as a global property BEFORE mounting so it's available in templates immediately
+ app.config.globalProperties.t = safeT;
+
+ // Also add tc for pluralization
+ app.config.globalProperties.tc = (key, count, params = {}) => {
+ if (!window.i18n || !window.i18n.tc) {
+ return key;
+ }
+ return window.i18n.tc(key, count, params);
+ };
+
+ // Provide t and tc to the app
+ app.provide("t", safeT);
+ app.provide("tc", (key, count, params = {}) => {
+ if (!window.i18n || !window.i18n.tc) {
+ return key;
+ }
+ return window.i18n.tc(key, count, params);
+ });
+
+ // Mount the app
+ app.mount("#app");
+
+ // Hide loading overlay after Vue is mounted and ready
+ Vue.nextTick(() => {
+ const overlay = document.querySelector(".app-loading-overlay");
+ if (overlay) {
+ // Small delay to ensure everything is rendered
+ setTimeout(() => {
+ overlay.classList.add("fade-out");
+ setTimeout(() => {
+ overlay.remove();
+ document.body.classList.remove("app-loading");
+ }, 300);
+ }, 100);
+ } else {
+ document.body.classList.remove("app-loading");
+ }
+ });
+ //--------------------Vue 全局注入与挂载 end------------------
+});
diff --git a/speakr/static/js/app-wsconnecter.js b/speakr/static/js/app-wsconnecter.js
new file mode 100644
index 0000000..e69de29
diff --git a/speakr/static/js/app-后端拆分-加了注释后.js b/speakr/static/js/app-后端拆分-加了注释后.js
new file mode 100644
index 0000000..dbe3a89
--- /dev/null
+++ b/speakr/static/js/app-后端拆分-加了注释后.js
@@ -0,0 +1,9126 @@
+const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
+// let isfilemode = false;
+
+// Wait for the DOM to be fully loaded before mounting the Vue app
+//------------------- 页面启动与 Vue 初始化 start------------------
+document.addEventListener("DOMContentLoaded", async () => {
+ // Initialize i18n before creating Vue app
+ if (window.i18n) {
+ const appElement = document.getElementById("app");
+ const userLang =
+ appElement?.dataset.userLanguage ||
+ localStorage.getItem("preferredLanguage") ||
+ "en";
+ await window.i18n.init(userLang);
+ console.log("i18n initialized with language:", userLang);
+ }
+
+ // CSRF Token Integration with Vue.js
+ const csrfToken = ref(
+ document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
+ );
+
+ // Register Service Worker only when the browser accepts the current TLS context.
+ if ("serviceWorker" in navigator && window.isSecureContext && window.location.hostname !== "localhost") {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker
+ .register("/tool/speakr/static/sw.js")
+ .then((registration) => {
+ console.log(
+ "ServiceWorker registration successful with scope: ",
+ registration.scope
+ );
+ })
+ .catch((error) => {
+ console.log("ServiceWorker registration failed: ", error);
+ });
+ });
+ }
+
+ // Create a safe t function that's always available
+ const safeT = (key, params = {}) => {
+ if (!window.i18n || !window.i18n.t) {
+ return key; // Return key as fallback without warning during initial render
+ }
+ return window.i18n.t(key, params);
+ };
+ let webReader = null;
+ let webReader2 = null;
+ // ASR Recorder 实例(提升到外层以便跨录音周期清理)
+ let rec = null;
+ let rec2 = null;
+ // openMixedStream 创建的 AudioContext(需跨周期清理)
+ let asrAudioContext = null;
+ let asrAudioContext2 = null;
+ // 用于标记是否需要重置转录状态(暂停/恢复时使用)
+ let resetTranscriptionFlag = false;
+ let pendingCorrectionCount = 0;
+ //--------------------页面启动与 Vue 初始化 end------------------
+
+ //------------------- Vue 应用主体定义 start------------------
+ const app = createApp({
+ setup() {
+ //------------------- 响应式状态与实时转写基础状态 start------------------
+ // --- Core State ---
+ // 新增说话人占位
+ let segmentList = [];
+ let lastSpeaker = "";
+ let configwords = "";
+ const currentView = ref("upload"); // 'upload' or 'recording'
+ const dragover = ref(false);
+ const recordings = ref([]);
+ const selectedRecording = ref(null);
+ const selectedTab = ref("summary"); // 'summary' or 'notes'
+ const searchQuery = ref("");
+ const isLoadingRecordings = ref(true);
+ const globalError = ref(null);
+
+ // Advanced filter state
+ const showAdvancedFilters = ref(false);
+ const filterTags = ref([]); // Selected tag IDs for filtering
+ const filterDateRange = ref({ start: "", end: "" });
+ const filterDatePreset = ref(""); // 'today', 'yesterday', 'week', 'month', etc.
+ const filterTextQuery = ref("");
+
+ // --- Pagination State ---
+ const currentPage = ref(1);
+ const perPage = ref(25);
+ const totalRecordings = ref(0);
+ const totalPages = ref(0);
+ const hasNextPage = ref(false);
+ const hasPrevPage = ref(false);
+ const isLoadingMore = ref(false);
+ const searchDebounceTimer = ref(null);
+
+ // --- Enhanced Search & Organization State ---
+ const sortBy = ref("created_at"); // 'created_at' or 'meeting_date'
+ const selectedTagFilter = ref(null); // For filtering by clicked tag
+
+ // --- UI State ---
+ const browser = ref("unknown");
+ const isSidebarCollapsed = ref(false);
+ const searchTipsExpanded = ref(false);
+ const isUserMenuOpen = ref(false);
+ const isDarkMode = ref(false);
+ const currentColorScheme = ref("blue");
+ const showColorSchemeModal = ref(false);
+ const windowWidth = ref(window.innerWidth);
+ const mobileTab = ref("transcript");
+ const isMetadataExpanded = ref(false);
+
+ // --- i18n State ---
+ const currentLanguage = ref("en");
+ const currentLanguageName = ref("English");
+ const availableLanguages = ref([]);
+ const showLanguageMenu = ref(false);
+
+ // --- Upload State ---
+ const uploadQueue = ref([]);
+ const currentlyProcessingFile = ref(null);
+ const processingProgress = ref(0);
+ const processingMessage = ref("");
+ const isProcessingActive = ref(false);
+ const pollInterval = ref(null);
+ const progressPopupMinimized = ref(false);
+ const progressPopupClosed = ref(false);
+ const maxFileSizeMB = ref(250); // Default value, will be updated from API
+ const chunkingEnabled = ref(true); // Default value, will be updated from API
+ const chunkingMode = ref("size"); // 'size' or 'duration', will be updated from API
+ const chunkingLimit = ref(20); // Value in MB or seconds, will be updated from API
+ const chunkingLimitDisplay = ref("20MB"); // Human readable display, will be updated from API
+ const recordingDisclaimer = ref(""); // Recording disclaimer text from admin settings
+ const showRecordingDisclaimerModal = ref(false); // Controls disclaimer modal visibility
+ const pendingRecordingMode = ref(null); // Stores the recording mode while showing disclaimer
+
+ // --- Audio Recording State ---
+ const editingDraftId = ref(null);
+ const editingDraftName = ref('');
+ const draftNameInput = ref(null);
+
+ const isRecording = ref(false);
+ const isStoppingRecording = ref(false);
+ const mediaRecorder = ref(null);
+ const audioChunks = ref([]);
+ const audioBlobURL = ref(null);
+ const recordingTime = ref(0);
+ const recordingInterval = ref(null);
+ const canRecordAudio = ref(
+ navigator.mediaDevices && navigator.mediaDevices.getUserMedia
+ );
+ const canRecordSystemAudio = ref(false);
+ const systemAudioSupported = ref(false);
+ const systemAudioError = ref("");
+ const recordingNotes = ref("");
+ const showSystemAudioHelp = ref(false);
+ // ASR options for recording view
+ const asrLanguage = ref(""); // Empty string for auto-detect
+ const asrMinSpeakers = ref(""); // Empty string for auto-detect
+ const asrMaxSpeakers = ref(""); // Empty string for auto-detect
+ const audioContext = ref(null);
+ const analyser = ref(null);
+ const micAnalyser = ref(null);
+ const systemAnalyser = ref(null);
+ const visualizer = ref(null);
+ const micVisualizer = ref(null);
+ const systemVisualizer = ref(null);
+ const animationFrameId = ref(null);
+ const recordingMode = ref("microphone"); // 'microphone', 'system', or 'both'
+ const activeStreams = ref([]); // Track active streams for cleanup
+
+ // --- Recording Size Monitoring ---
+ const estimatedFileSize = ref(0);
+ const fileSizeWarningShown = ref(false);
+ const recordingQuality = ref("optimized"); // 'optimized', 'standard', 'high'
+ const actualBitrate = ref(0);
+ const maxRecordingMB = ref(200); // Maximum recording size before auto-stop
+ const sizeCheckInterval = ref(null);
+
+ // --- Draft/Pause Recording State ---
+ const isPaused = ref(false);
+ const currentDraftId = ref(null);
+ const pausedDuration = ref(0); // 暂停前的累计时长
+ const pausedTranscription = ref(''); // 暂停前的转录文本
+ const draftRecordings = ref([]); // 草稿列表
+ const draftsExpanded = ref(true); // 草稿列表是否展开
+
+ // Advanced Options for ASR
+ const showAdvancedOptions = ref(false);
+ const uploadLanguage = ref(""); // Empty string for auto-detect
+ const uploadMinSpeakers = ref(""); // Empty string for auto-detect
+ const uploadMaxSpeakers = ref(""); // Empty string for auto-detect
+
+ // Tag Selection
+ const availableTags = ref([]);
+ const selectedTagIds = ref([]); // Changed to array for multiple selection
+ const uploadTagSearchFilter = ref(""); // For filtering tags in upload view
+ const selectedTags = computed(() => {
+ return selectedTagIds.value
+ .map((tagId) => availableTags.value.find((tag) => tag.id == tagId))
+ .filter(Boolean); // Filter out undefined tags
+ });
+ // 自动判断是否为 Markdown
+ const isMarkdown = computed(() => {
+ const v = analysisResult.value;
+ return (
+ v.includes("#") ||
+ v.includes("**") ||
+ v.includes("```") ||
+ v.includes("* ") ||
+ v.includes("- ")
+ );
+ });
+
+ // 解析 Markdown → HTML
+ const renderedMarkdown = Vue.computed(() => {
+ return window.marked.parse(analysisResult.value || "", {
+ breaks: true,
+ });
+ });
+ // Computed property for filtered available tags in upload view
+ const filteredAvailableTagsForUpload = computed(() => {
+ const availableForSelection = availableTags.value.filter(
+ (tag) => !selectedTagIds.value.includes(tag.id)
+ );
+ if (!uploadTagSearchFilter.value) return availableForSelection;
+
+ const filter = uploadTagSearchFilter.value.toLowerCase();
+ return availableForSelection.filter((tag) =>
+ tag.name.toLowerCase().includes(filter)
+ );
+ });
+
+ // --- Modal State ---
+ const showEditModal = ref(false);
+ const showDeleteModal = ref(false);
+ const showEditTagsModal = ref(false);
+ const selectedNewTagId = ref("");
+ const tagSearchFilter = ref(""); // For filtering tags in the modal
+ const showReprocessModal = ref(false);
+ const showResetModal = ref(false);
+ const showSpeakerModal = ref(false);
+ const showShareModal = ref(false);
+ const showSharesListModal = ref(false);
+ const showTextEditorModal = ref(false);
+ const showAsrEditorModal = ref(false);
+ const editingRecording = ref(null);
+ const editingTranscriptionContent = ref("");
+ const editingSegments = ref([]);
+ const availableSpeakers = ref([]);
+ const recordingToShare = ref(null);
+ const shareOptions = reactive({
+ share_summary: true,
+ share_notes: true,
+ });
+ const generatedShareLink = ref("");
+ const existingShareDetected = ref(false);
+ const userShares = ref([]);
+ const isLoadingShares = ref(false);
+ const shareToDelete = ref(null);
+ const showShareDeleteModal = ref(false);
+ const recordingToDelete = ref(null);
+ const recordingToReset = ref(null);
+ const reprocessType = ref(null); // 'transcription' or 'summary'
+ const reprocessRecording = ref(null);
+ const isAutoIdentifying = ref(false);
+ const asrReprocessOptions = reactive({
+ language: "",
+ min_speakers: null,
+ max_speakers: null,
+ });
+ const speakerMap = ref({});
+ const regenerateSummaryAfterSpeakerUpdate = ref(true);
+ const speakerSuggestions = ref({});
+ const loadingSuggestions = ref({});
+ const activeSpeakerInput = ref(null);
+
+ // --- Inline Editing State ---
+ const editingParticipants = ref(false);
+ const editingMeetingDate = ref(false);
+ const editingSummary = ref(false);
+ const editingNotes = ref(false);
+ const tempNotesContent = ref("");
+ const tempSummaryContent = ref("");
+ const autoSaveTimer = ref(null);
+ const autoSaveDelay = 2000; // 2 seconds debounce
+
+ // --- Markdown Editor State ---
+ const notesMarkdownEditor = ref(null);
+ const markdownEditorInstance = ref(null);
+ const summaryMarkdownEditor = ref(null);
+ const summaryMarkdownEditorInstance = ref(null);
+ const recordingNotesEditor = ref(null);
+ const recordingMarkdownEditorInstance = ref(null);
+
+ // --- Transcription State ---
+ const transcriptionViewMode = ref("simple"); // 'simple' or 'bubble'
+ const legendExpanded = ref(false);
+ const highlightedSpeaker = ref(null);
+
+ // --- Chat State ---
+ const showChat = ref(false);
+ const isChatMaximized = ref(false);
+ const chatMessages = ref([]);
+ const chatInput = ref("");
+ const isChatLoading = ref(false);
+ const chatMessagesRef = ref(null);
+
+ // --- Audio Player State ---
+ const playerVolume = ref(1.0);
+
+ // --- Column Resizing State ---
+ const leftColumnWidth = ref(60); // 60% for left column (transcript)
+ const rightColumnWidth = ref(40); // 40% for right column (summary/chat)
+ const isResizing = ref(false);
+
+ // --- App Configuration ---
+ const useAsrEndpoint = ref(false);
+ const currentUserName = ref("");
+ const asrResult = ref("");
+ const asrResultOnline = ref("");
+ const asrResultOffline = ref("");
+ const asrResultTextarea = ref("");
+ const asrResultTextareaOnline = ref("");
+ const sumupResultTextarea = ref("");
+ const baseInfo = ref({ name: "领导发言稿", fontSize: 40 });
+ const isModalOpen = ref(false);
+
+ const asrTextarea = ref(null);
+ const analysisResult = ref("");
+ const isDialogVisible = ref(false);
+ const isyjDialogVisible = ref(false);
+ const lastTimer = ref(0);
+ const urlmsg = ref("获取链接中...");
+ const urlinfo = ref("");
+ const zjloding = ref(false);
+ const showSpeaker = ref(true);
+
+ // --- 转录文本编辑状态 ---
+ const isEditingTranscription = ref(false);
+ const editableText = ref("");
+ const fullRealtimeTranscription = computed(() => {
+ return (
+ (asrResult.value || "") +
+ (asrResultOffline.value || "") +
+ (asrResultOnline.value || "")
+ );
+ });
+ let segmentRenderStartIndex = 0;
+ let liveTranscriptionEpoch = 0;
+ let realtimeTimestampBarrierMs = -1;
+ let realtimeEditCarryover = null;
+ let clearRealtimePipelineRef = null;
+ let getLatestRealtimeTimestampRef = null;
+ let buildRealtimeEditCarryoverRef = null;
+ let renderFullTextRef = null;
+ let editModeBufferStartIndex = 0;
+
+ function startNewRealtimeEpoch({
+ barrierTimestampMs = null,
+ clearTimestampBarrier = false,
+ clearEditCarryover = true,
+ } = {}) {
+ liveTranscriptionEpoch += 1;
+
+ if (clearEditCarryover) {
+ realtimeEditCarryover = null;
+ }
+
+ if (clearTimestampBarrier) {
+ realtimeTimestampBarrierMs = -1;
+ } else if (Number.isFinite(barrierTimestampMs)) {
+ realtimeTimestampBarrierMs = Math.max(
+ realtimeTimestampBarrierMs,
+ barrierTimestampMs
+ );
+ }
+
+ if (clearRealtimePipelineRef) {
+ clearRealtimePipelineRef({
+ preserveTimestampBarrier: !clearTimestampBarrier,
+ });
+ }
+ }
+
+ //--------------------响应式状态与实时转写基础状态 end------------------
+
+ //------------------- 计算属性与派生展示数据 start------------------
+ // --- Computed Properties ---
+ const isMobileScreen = computed(() => {
+ return windowWidth.value < 1024;
+ });
+
+ const datePresetOptions = computed(() => {
+ return [
+ { value: "today", label: t("sidebar.today") },
+ { value: "yesterday", label: t("sidebar.yesterday") },
+ { value: "thisweek", label: t("sidebar.thisWeek") },
+ { value: "lastweek", label: t("sidebar.lastWeek") },
+ { value: "thismonth", label: t("sidebar.thisMonth") },
+ { value: "lastmonth", label: t("sidebar.lastMonth") },
+ ];
+ });
+
+ const languageOptions = computed(() => {
+ return [
+ { value: "", label: t("form.autoDetect") },
+ { value: "en", label: t("languages.en") },
+ { value: "es", label: t("languages.es") },
+ { value: "fr", label: t("languages.fr") },
+ { value: "de", label: t("languages.de") },
+ { value: "it", label: t("languages.it") },
+ { value: "pt", label: t("languages.pt") },
+ { value: "nl", label: t("languages.nl") },
+ { value: "ru", label: t("languages.ru") },
+ { value: "zh", label: t("languages.zh") },
+ { value: "ja", label: t("languages.ja") },
+ { value: "ko", label: t("languages.ko") },
+ ];
+ });
+
+ const filteredRecordings = computed(() => {
+ return recordings.value;
+ });
+
+ const highlightedTranscript = computed(() => {
+ if (!selectedRecording.value?.transcription) return "";
+ let html = selectedRecording.value.transcription;
+ // Escape HTML to prevent injection, but keep it minimal
+ html = html.replace(//g, ">");
+
+ // 1. Replace newlines with tags for proper line breaks in HTML
+ html = html.replace(/\n/g, " ");
+
+ // 2. Get speaker colors from the speakerMap if available
+ const speakerColors = {};
+ if (speakerMap.value) {
+ Object.keys(speakerMap.value).forEach((speaker, index) => {
+ speakerColors[speaker] =
+ speakerMap.value[speaker].color ||
+ `speaker-color-${(index % 8) + 1}`;
+ });
+ }
+
+ // 3. Wrap each speaker tag in a span for styling and interaction with colors
+ html = html.replace(/\[([^\]]+)\]/g, (match, speakerId) => {
+ const isHighlighted = speakerId === highlightedSpeaker.value;
+ const colorClass = speakerColors[speakerId] || "";
+ // Replace speaker ID with name if available
+ const displayName = speakerMap.value[speakerId]?.name || speakerId;
+ const displayText = `[${displayName}]`;
+ // Use a more specific and stylish class structure with color
+ return `${displayText} `;
+ });
+
+ return html;
+ });
+
+ const activeRecordingMetadata = computed(() => {
+ if (!selectedRecording.value) return [];
+
+ const recording = selectedRecording.value;
+ const metadata = [];
+
+ if (recording.created_at) {
+ metadata.push({
+ icon: "fas fa-history",
+ text: formatDisplayDate(recording.created_at),
+ });
+ }
+
+ if (recording.file_size) {
+ metadata.push({
+ icon: "fas fa-file-audio",
+ text: formatFileSize(recording.file_size),
+ });
+ }
+
+ if (recording.duration) {
+ metadata.push({
+ icon: "fas fa-clock",
+ text: formatDuration(recording.duration),
+ });
+ }
+
+ if (recording.original_filename) {
+ const maxLength = 30;
+ const truncated =
+ recording.original_filename.length > maxLength
+ ? recording.original_filename.substring(0, maxLength) + "..."
+ : recording.original_filename;
+ metadata.push({
+ icon: "fas fa-file",
+ text: truncated,
+ fullText: recording.original_filename,
+ });
+ }
+
+ // Add tags to metadata
+ if (recording.tags && recording.tags.length > 0) {
+ metadata.push({
+ icon: "fas fa-tags",
+ text: "", // Empty text since we'll render tags specially
+ tags: recording.tags, // Pass the tags array
+ isTagItem: true, // Flag to identify this as a tag item
+ });
+ }
+
+ return metadata;
+ });
+
+ const groupedRecordings = computed(() => {
+ // Sort recordings based on the selected sort criteria
+ const sortedRecordings = [...filteredRecordings.value].sort((a, b) => {
+ const dateA = getDateForSorting(a);
+ const dateB = getDateForSorting(b);
+
+ // Handle null dates (put them at the end)
+ if (!dateA && !dateB) return 0;
+ if (!dateA) return 1;
+ if (!dateB) return -1;
+
+ return dateB - dateA; // Most recent first
+ });
+
+ const groups = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ lastWeek: [],
+ thisMonth: [],
+ lastMonth: [],
+ older: [],
+ };
+
+ sortedRecordings.forEach((recording) => {
+ const date = getDateForSorting(recording);
+ if (!date) {
+ groups.older.push(recording);
+ return;
+ }
+
+ if (isToday(date)) {
+ groups.today.push(recording);
+ } else if (isYesterday(date)) {
+ groups.yesterday.push(recording);
+ } else if (isThisWeek(date)) {
+ groups.thisWeek.push(recording);
+ } else if (isLastWeek(date)) {
+ groups.lastWeek.push(recording);
+ } else if (isThisMonth(date)) {
+ groups.thisMonth.push(recording);
+ } else if (isLastMonth(date)) {
+ groups.lastMonth.push(recording);
+ } else {
+ groups.older.push(recording);
+ }
+ });
+
+ return [
+ { title: t("sidebar.today"), items: groups.today },
+ { title: t("sidebar.yesterday"), items: groups.yesterday },
+ { title: t("sidebar.thisWeek"), items: groups.thisWeek },
+ { title: t("sidebar.lastWeek"), items: groups.lastWeek },
+ { title: t("sidebar.thisMonth"), items: groups.thisMonth },
+ { title: t("sidebar.lastMonth"), items: groups.lastMonth },
+ { title: t("sidebar.older"), items: groups.older },
+ ].filter((g) => g.items.length > 0);
+ });
+
+ const totalInQueue = computed(() => uploadQueue.value.length);
+ const completedInQueue = computed(
+ () =>
+ uploadQueue.value.filter(
+ (item) => item.status === "completed" || item.status === "failed"
+ ).length
+ );
+ const finishedFilesInQueue = computed(() =>
+ uploadQueue.value.filter((item) =>
+ ["completed", "failed"].includes(item.status)
+ )
+ );
+ const showRecordingControls = computed(() => {
+ if (currentView.value !== "recording") {
+ return false;
+ }
+
+ const hasCompletedRecording =
+ !isRecording.value && !isPaused.value && !!audioBlobURL.value;
+
+ if (hasCompletedRecording) {
+ return false;
+ }
+
+ if (isRecording.value || isPaused.value || isStoppingRecording.value) {
+ return true;
+ }
+
+ return (
+ !audioBlobURL.value &&
+ (activeStreams.value.length > 0 ||
+ recordingTime.value > 0 ||
+ !!currentDraftId.value)
+ );
+ });
+
+ const clearCompletedUploads = () => {
+ uploadQueue.value = uploadQueue.value.filter(
+ (item) => !["completed", "failed"].includes(item.status)
+ );
+ };
+
+ const identifiedSpeakers = computed(() => {
+ // Ensure we have a valid recording and transcription
+ if (!selectedRecording.value?.transcription) {
+ return [];
+ }
+
+ const transcription = selectedRecording.value.transcription;
+ let transcriptionData;
+
+ try {
+ transcriptionData = JSON.parse(transcription);
+ } catch (e) {
+ transcriptionData = null;
+ }
+
+ // Updated to handle new simplified JSON format (array of segments)
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ // JSON format - extract speakers in order of appearance
+ const speakersInOrder = [];
+ const seenSpeakers = new Set();
+ transcriptionData.forEach((segment) => {
+ if (
+ segment.speaker &&
+ String(segment.speaker).trim() &&
+ !seenSpeakers.has(segment.speaker)
+ ) {
+ seenSpeakers.add(segment.speaker);
+ speakersInOrder.push(segment.speaker);
+ }
+ });
+ return speakersInOrder; // Keep order of appearance, don't sort
+ } else if (typeof transcription === "string") {
+ // Plain text format - find speakers in order of appearance
+ const speakerRegex = /\[([^\]]+)\]:/g;
+ const speakersInOrder = [];
+ const seenSpeakers = new Set();
+ let match;
+ while ((match = speakerRegex.exec(transcription)) !== null) {
+ const speaker = match[1].trim();
+ if (speaker && !seenSpeakers.has(speaker)) {
+ seenSpeakers.add(speaker);
+ speakersInOrder.push(speaker);
+ }
+ }
+ return speakersInOrder; // Keep order of appearance, don't sort
+ }
+ return [];
+ });
+
+ // identifiedSpeakersInOrder is now just an alias since identifiedSpeakers already preserves order
+ const identifiedSpeakersInOrder = computed(() => {
+ return identifiedSpeakers.value;
+ });
+
+ const hasSpeakerNames = computed(() => {
+ // Check if any speaker has a non-empty name
+ return Object.values(speakerMap.value).some(
+ (speakerData) => speakerData.name && speakerData.name.trim() !== ""
+ );
+ });
+
+ const processedTranscription = computed(() => {
+ if (!selectedRecording.value?.transcription) {
+ return {
+ hasDialogue: false,
+ content: "",
+ speakers: [],
+ simpleSegments: [],
+ bubbleRows: [],
+ };
+ }
+
+ const transcription = selectedRecording.value.transcription;
+ let transcriptionData;
+
+ try {
+ transcriptionData = JSON.parse(transcription);
+ } catch (e) {
+ transcriptionData = null;
+ }
+
+ // Handle new simplified JSON format (array of segments)
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ const wasDiarized = transcriptionData.some(
+ (segment) => segment.speaker
+ );
+
+ if (!wasDiarized) {
+ const segments = transcriptionData.map((segment) => ({
+ sentence: segment.sentence,
+ startTime: segment.start_time,
+ }));
+ return {
+ hasDialogue: false,
+ isJson: true,
+ content: segments.map((s) => s.sentence).join("\n"),
+ simpleSegments: segments,
+ speakers: [],
+ bubbleRows: [],
+ };
+ }
+
+ // Extract unique speakers
+ const speakers = [
+ ...new Set(
+ transcriptionData
+ .map((segment) => segment.speaker)
+ .filter(Boolean)
+ ),
+ ];
+ const speakerColors = {};
+ speakers.forEach((speaker, index) => {
+ speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
+ });
+
+ const simpleSegments = transcriptionData.map((segment) => ({
+ speakerId: segment.speaker,
+ speaker: speakerMap.value[segment.speaker]?.name || segment.speaker,
+ sentence: segment.sentence,
+ startTime: segment.start_time || segment.startTime,
+ endTime: segment.end_time || segment.endTime,
+ color: speakerColors[segment.speaker] || "speaker-color-1",
+ }));
+
+ const processedSimpleSegments = [];
+ let lastSpeakerId = null;
+ simpleSegments.forEach((segment) => {
+ processedSimpleSegments.push({
+ ...segment,
+ showSpeaker: segment.speakerId !== lastSpeakerId,
+ });
+ lastSpeakerId = segment.speakerId;
+ });
+
+ const bubbleRows = [];
+ let lastBubbleSpeakerId = null;
+ simpleSegments.forEach((segment) => {
+ if (
+ bubbleRows.length === 0 ||
+ segment.speakerId !== lastBubbleSpeakerId
+ ) {
+ bubbleRows.push({
+ speaker: segment.speaker,
+ color: segment.color,
+ isMe:
+ segment.speaker &&
+ typeof segment.speaker === "string" &&
+ segment.speaker.toLowerCase().includes("me"),
+ bubbles: [],
+ });
+ lastBubbleSpeakerId = segment.speakerId;
+ }
+ bubbleRows[bubbleRows.length - 1].bubbles.push({
+ sentence: segment.sentence,
+ startTime: segment.startTime || segment.start_time,
+ color: segment.color,
+ });
+ });
+
+ return {
+ hasDialogue: true,
+ isJson: true,
+ segments: simpleSegments,
+ simpleSegments: processedSimpleSegments,
+ bubbleRows: bubbleRows,
+ speakers: speakers.map((speaker) => ({
+ name: speakerMap.value[speaker]?.name || speaker,
+ color: speakerColors[speaker],
+ })),
+ };
+ } else {
+ // Fallback for plain text transcription
+ const speakerRegex = /\[([^\]]+)\]:\s*/g;
+ const hasDialogue = speakerRegex.test(transcription);
+
+ if (!hasDialogue) {
+ return {
+ hasDialogue: false,
+ isJson: false,
+ content: transcription,
+ speakers: [],
+ simpleSegments: [],
+ bubbleRows: [],
+ };
+ }
+
+ speakerRegex.lastIndex = 0;
+ const speakers = new Set();
+ let match;
+ while ((match = speakerRegex.exec(transcription)) !== null) {
+ speakers.add(match[1]);
+ }
+
+ const speakerList = Array.from(speakers);
+ const speakerColors = {};
+ speakerList.forEach((speaker, index) => {
+ speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
+ });
+
+ const segments = [];
+ const lines = transcription.split("\n");
+ let currentSpeakerId = null;
+ let currentText = "";
+
+ for (const line of lines) {
+ const speakerMatch = line.match(/^\[([^\]]+)\]:\s*(.*)$/);
+ if (speakerMatch) {
+ if (currentSpeakerId && currentText.trim()) {
+ segments.push({
+ speakerId: currentSpeakerId,
+ speaker:
+ speakerMap.value[currentSpeakerId]?.name ||
+ currentSpeakerId,
+ sentence: currentText.trim(),
+ color: speakerColors[currentSpeakerId] || "speaker-color-1",
+ });
+ }
+ currentSpeakerId = speakerMatch[1];
+ currentText = speakerMatch[2];
+ } else if (currentSpeakerId && line.trim()) {
+ currentText += " " + line.trim();
+ } else if (!currentSpeakerId && line.trim()) {
+ segments.push({
+ speakerId: null,
+ speaker: null,
+ sentence: line.trim(),
+ color: "speaker-color-1",
+ });
+ }
+ }
+
+ if (currentSpeakerId && currentText.trim()) {
+ segments.push({
+ speakerId: currentSpeakerId,
+ speaker:
+ speakerMap.value[currentSpeakerId]?.name || currentSpeakerId,
+ sentence: currentText.trim(),
+ color: speakerColors[currentSpeakerId] || "speaker-color-1",
+ });
+ }
+
+ const simpleSegments = [];
+ let lastSpeakerId = null;
+ segments.forEach((segment) => {
+ simpleSegments.push({
+ ...segment,
+ showSpeaker: segment.speakerId !== lastSpeakerId,
+ sentence: segment.sentence || segment.text,
+ });
+ lastSpeakerId = segment.speakerId;
+ });
+
+ const bubbleRows = [];
+ let currentRow = null;
+ segments.forEach((segment) => {
+ if (!currentRow || currentRow.speakerId !== segment.speakerId) {
+ if (currentRow) bubbleRows.push(currentRow);
+ currentRow = {
+ speakerId: segment.speakerId,
+ speaker: segment.speaker,
+ color: segment.color,
+ bubbles: [],
+ isMe:
+ segment.speaker &&
+ segment.speaker.toLowerCase().includes("me"),
+ };
+ }
+ currentRow.bubbles.push({
+ sentence: segment.sentence,
+ color: segment.color,
+ });
+ });
+ if (currentRow) bubbleRows.push(currentRow);
+
+ return {
+ hasDialogue: true,
+ isJson: false,
+ segments: segments,
+ simpleSegments: simpleSegments,
+ bubbleRows: bubbleRows,
+ speakers: speakerList.map((speaker) => ({
+ name: speakerMap.value[speaker]?.name || speaker,
+ color: speakerColors[speaker] || "speaker-color-1",
+ })),
+ };
+ }
+ });
+
+ //--------------------计算属性与派生展示数据 end------------------
+
+ //------------------- 主题与配色配置 start------------------
+ // --- Color Scheme Management ---
+ const colorSchemes = {
+ light: [
+ {
+ id: "blue",
+ name: "Ocean Blue",
+ description: "Classic blue theme with professional appeal",
+ class: "",
+ },
+ {
+ id: "emerald",
+ name: "Forest Emerald",
+ description: "Fresh green theme for a natural feel",
+ class: "theme-light-emerald",
+ },
+ {
+ id: "purple",
+ name: "Royal Purple",
+ description: "Elegant purple theme with sophistication",
+ class: "theme-light-purple",
+ },
+ {
+ id: "rose",
+ name: "Sunset Rose",
+ description: "Warm pink theme with gentle energy",
+ class: "theme-light-rose",
+ },
+ {
+ id: "amber",
+ name: "Golden Amber",
+ description: "Warm yellow theme for brightness",
+ class: "theme-light-amber",
+ },
+ {
+ id: "teal",
+ name: "Ocean Teal",
+ description: "Cool teal theme for tranquility",
+ class: "theme-light-teal",
+ },
+ ],
+ dark: [
+ {
+ id: "blue",
+ name: "Midnight Blue",
+ description: "Deep blue theme for focused work",
+ class: "",
+ },
+ {
+ id: "emerald",
+ name: "Dark Forest",
+ description: "Rich green theme for comfortable viewing",
+ class: "theme-dark-emerald",
+ },
+ {
+ id: "purple",
+ name: "Deep Purple",
+ description: "Mysterious purple theme for creativity",
+ class: "theme-dark-purple",
+ },
+ {
+ id: "rose",
+ name: "Dark Rose",
+ description: "Muted pink theme with subtle warmth",
+ class: "theme-dark-rose",
+ },
+ {
+ id: "amber",
+ name: "Dark Amber",
+ description: "Warm brown theme for cozy sessions",
+ class: "theme-dark-amber",
+ },
+ {
+ id: "teal",
+ name: "Deep Teal",
+ description: "Dark teal theme for calm focus",
+ class: "theme-dark-teal",
+ },
+ ],
+ };
+
+ //--------------------主题与配色配置 end------------------
+
+ //------------------- 通用工具方法与格式化逻辑 start------------------
+ // --- Utility Methods ---
+ const openmodel = async (e) => {
+ urlmsg.value = "获取链接中...";
+ isModalOpen.value = true;
+ let urls = `/tool/speakr/api/external/summarize_text`;
+ const response = await fetch(urls, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: asrResult.value,
+ prompt_id: e ? e.id : "",
+ }),
+ });
+ const data = await response.json();
+
+ if (response) {
+ urlinfo.value = data.string;
+ urlmsg.value = "点击打开投屏页面";
+ console.log(data.value, "urlinfo.value", response, data);
+ }
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to reset status");
+ }
+ };
+ const openzjmodel = async () => {
+ zjloding.value = true;
+ let urls = `/tool/speakr/api/external/submit`;
+ try {
+ const response = await fetch(urls, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: analysisResult.value,
+ }),
+ });
+ const data = await response.json();
+
+ if (response) {
+ window.open(data.string, "_blank");
+ zjloding.value = false;
+ }
+ if (!response.ok) {
+ zjloding.value = false;
+
+ throw new Error(data.error || "Failed to reset status");
+ }
+ } catch (error) {
+ console.error("接口请求失败:", error);
+ zjloding.value = false;
+ }
+ };
+ const closeModal = () => {
+ isModalOpen.value = false;
+ };
+ const setGlobalError = (message, duration = 7000) => {
+ globalError.value = message;
+ if (duration > 0) {
+ setTimeout(() => {
+ if (globalError.value === message) globalError.value = null;
+ }, duration);
+ }
+ };
+
+ const formatFileSize = (bytes) => {
+ if (bytes == null || bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ if (bytes < 0) bytes = 0;
+ const i =
+ bytes === 0
+ ? 0
+ : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
+ const size =
+ i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
+ return size + " " + sizes[i];
+ };
+
+ const formatDisplayDate = (dateString) => {
+ if (!dateString) return "";
+ try {
+ // Try to parse the date string directly first (it might already be formatted)
+ let date = new Date(dateString);
+ // If that fails or results in invalid date, try different approaches
+ if (isNaN(date.getTime())) {
+ // Try appending time if it looks like a date-only string (YYYY-MM-DD format)
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
+ date = new Date(dateString + "T00:00:00");
+ } else {
+ // If it's already a formatted string, just return it
+ return dateString;
+ }
+ }
+
+ // If we still have an invalid date, return the original string
+ if (isNaN(date.getTime())) {
+ return dateString;
+ }
+
+ return date.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ } catch (e) {
+ console.error("Error formatting date:", e);
+ return dateString;
+ }
+ };
+
+ const formatStatus = (status) => {
+ if (!status || status === "COMPLETED") return "";
+ const statusMap = {
+ PENDING: t("status.queued"),
+ PROCESSING: t("status.processing"),
+ TRANSCRIBING: t("status.transcribing"),
+ SUMMARIZING: t("status.summarizing"),
+ FAILED: t("status.failed"),
+ UPLOADING: t("status.uploading"),
+ };
+ return (
+ statusMap[status] ||
+ status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()
+ );
+ };
+
+ const getStatusClass = (status) => {
+ switch (status) {
+ case "PENDING":
+ return "status-pending";
+ case "PROCESSING":
+ return "status-processing";
+ case "SUMMARIZING":
+ return "status-summarizing";
+ case "COMPLETED":
+ return "";
+ case "FAILED":
+ return "status-failed";
+ default:
+ return "status-pending";
+ }
+ };
+
+ const formatTime = (seconds) => {
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${minutes.toString().padStart(2, "0")}:${secs
+ .toString()
+ .padStart(2, "0")}`;
+ };
+
+ const formatDuration = (totalSeconds) => {
+ if (totalSeconds == null || totalSeconds < 0) return "N/A";
+
+ if (totalSeconds < 1) {
+ return `${totalSeconds.toFixed(2)} seconds`;
+ }
+
+ totalSeconds = Math.round(totalSeconds);
+
+ if (totalSeconds < 60) {
+ return `${totalSeconds} sec`;
+ }
+
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ let parts = [];
+ if (hours > 0) {
+ parts.push(`${hours} hr`);
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes} min`);
+ }
+ // Only show seconds if duration is less than an hour
+ if (hours === 0 && seconds > 0) {
+ parts.push(`${seconds} sec`);
+ }
+
+ return parts.join(" ");
+ };
+
+ const createLocalizedRecordingFilename = () => {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ return `录音_${timestamp}.webm`;
+ };
+
+ const getDownloadFilenameFromHeaders = (
+ contentDisposition,
+ fallbackFilename
+ ) => {
+ if (!contentDisposition) {
+ return fallbackFilename;
+ }
+
+ const utf8Match = /filename\*=utf-8''([^;]+)/i.exec(contentDisposition);
+ if (utf8Match && utf8Match[1]) {
+ return decodeURIComponent(utf8Match[1]);
+ }
+
+ const regularMatch = /filename="([^"]+)"/i.exec(contentDisposition);
+ if (regularMatch && regularMatch[1]) {
+ return regularMatch[1];
+ }
+
+ return fallbackFilename;
+ };
+
+ //--------------------通用工具方法与格式化逻辑 end------------------
+
+ //------------------- 录音体积监控逻辑 start------------------
+ // --- Recording Size Monitoring Functions ---
+ const updateFileSizeEstimate = () => {
+ if (!isRecording.value || !actualBitrate.value) return;
+
+ // Calculate estimated size based on recording time and bitrate
+ const recordingTimeSeconds = recordingTime.value;
+ const estimatedBits = actualBitrate.value * recordingTimeSeconds;
+ const estimatedBytes = estimatedBits / 8;
+ estimatedFileSize.value = estimatedBytes;
+
+ // Check if we're approaching the size limit
+ const sizeMB = estimatedBytes / (1024 * 1024);
+ const warningThresholdMB = maxRecordingMB.value * 0.8; // 80% of max size
+
+ if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) {
+ fileSizeWarningShown.value = true;
+ showToast(
+ `Recording size is ${formatFileSize(
+ estimatedBytes
+ )}. Consider stopping soon to avoid auto-stop at ${
+ maxRecordingMB.value
+ }MB.`,
+ "fa-exclamation-triangle",
+ 5000
+ );
+ }
+
+ // Auto-stop if we exceed the maximum size
+ if (sizeMB > maxRecordingMB.value) {
+ console.log(
+ `Auto-stopping recording: size ${formatFileSize(
+ estimatedBytes
+ )} exceeds limit of ${maxRecordingMB.value}MB`
+ );
+ stopRecording();
+ showToast(
+ `Recording automatically stopped at ${formatFileSize(
+ estimatedBytes
+ )} to prevent excessive file size.`,
+ "fa-stop-circle",
+ 7000
+ );
+ }
+ };
+
+ const startSizeMonitoring = () => {
+ if (sizeCheckInterval.value) {
+ clearInterval(sizeCheckInterval.value);
+ }
+
+ // Reset size monitoring state
+ estimatedFileSize.value = 0;
+ fileSizeWarningShown.value = false;
+
+ // Start monitoring every 5 seconds
+ sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 5000);
+ };
+
+ const stopSizeMonitoring = () => {
+ if (sizeCheckInterval.value) {
+ clearInterval(sizeCheckInterval.value);
+ sizeCheckInterval.value = null;
+ }
+ };
+ //--------------------录音体积监控逻辑 end------------------
+
+ //------------------- 总结提示词与外部分析流程 start------------------
+ const promptVisible = ref(false);
+ const promptList = ref([]);
+ const recommendPromptId = ref(null);
+ const recommendPromptName = ref("");
+ const recommendPromptReason = ref("");
+ const selectedPromptId = ref(
+ localStorage.getItem("selected_prompt_id") || null
+ );
+ // const selectedPromptId = ref(null)
+ let modelTypeForP = "";
+ /** 打开弹框 */
+ const openPromptModal = (e) => {
+ modelTypeForP = e;
+ recommendPromptId.value = null;
+ recommendPromptName.value = "";
+ recommendPromptReason.value = "";
+ promptVisible.value = true;
+ getPromptList(e);
+ getRecommend();
+ };
+
+ /** 关闭弹框 */
+ const closePromptModal = () => {
+ promptVisible.value = false;
+ };
+
+ /** 获取 Prompt 列表 */
+ const getPromptList = async (e) => {
+ try {
+ const res = await fetch(
+ `/tool/speakr/api/external/prompts/list`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ skip: 0, limit: 999 }),
+ }
+ );
+
+ const data = await res.json();
+ promptList.value = data.prompt_list || [];
+ } catch (e) {
+ console.error("提示词加载失败", e);
+ }
+ };
+ /** 根据文本获取Prompt推荐 */
+ const getRecommend = async (e) => {
+ try {
+ const res = await fetch(
+ `/tool/speakr/api/external/recommend_prompts`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ input_str: asrResult.value }),
+ }
+ );
+
+ const data = await res.json();
+ recommendPromptId.value = data.id;
+ recommendPromptName.value = data.name;
+ recommendPromptReason.value = data.reason;
+ } catch (e) {
+ console.error("获取推荐失败", e);
+ }
+ };
+ /** 确认选择 */
+ const confirmPromptSelection = () => {
+ if (selectedPromptId.value) {
+ localStorage.setItem("selected_prompt_id", selectedPromptId.value);
+ }
+
+ const selectedPrompt = promptList.value.find(
+ (i) => i.id == selectedPromptId.value
+ );
+ console.log("选中的提示词:", selectedPrompt);
+ if (modelTypeForP === "zj") {
+ sumupResult(selectedPrompt);
+ } else {
+ openmodel(selectedPrompt);
+ }
+
+ // emit("promptSelected", selectedPrompt)
+
+ promptVisible.value = false;
+ };
+ const sumupResult = async (e) => {
+ isDialogVisible.value = true;
+ // if (!asrResult.value) return;
+
+ try {
+ analysisResult.value = "思考中...";
+
+ const response = await fetch(
+ `/tool/speakr/api/external/summarize_text_stream`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: asrResult.value,
+ // input_str: '香港火灾情况更新** 香港特区政府召开新闻发布会,通报火灾的搜救与安置最新进展。警方表示,已完成5座大厦的搜索,其余2座仍在进行中。至当天16时,火灾已造成 **156人遇难**,目前有约35人参与搜救工作,其中5人连续工作。2. **对话内容提及天气** 有用户询问“今天天气怎么样”,地点为“安康”,表明对话中涉及对当地天气的关注。3. **对话片段含非正式表达** 有用户发言“我是走开始啊不是”,语句不通顺,可能是口语化表达或输入错误,语义不明确。',
+
+ prompt_id: e ? e.id : "",
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to create share link");
+ }
+
+ // 重置结果并开始流式处理
+ analysisResult.value = "";
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ // 解码并处理流数据
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split("\n");
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.content) {
+ analysisResult.value += data.content;
+ }
+ if (data.finished) {
+ console.log(analysisResult.value, "analysisResult.value");
+
+ showToast("总结完成!", "fa-check-circle");
+ break;
+ }
+ } catch (e) {
+ console.log("解析流数据出错:", e);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ setGlobalError(`Failed to create share link: ${error.message}`);
+ }
+ };
+ //--------------------总结提示词与外部分析流程 end------------------
+
+ //------------------- 日期分组与排序工具 start------------------
+ // --- Enhanced Date Utility Functions ---
+ const getDateForSorting = (recording) => {
+ const dateStr =
+ sortBy.value === "meeting_date"
+ ? recording.meeting_date
+ : recording.created_at;
+ if (!dateStr) return null;
+ return new Date(dateStr);
+ };
+
+ const isToday = (date) => {
+ const today = new Date();
+ return isSameDay(date, today);
+ };
+
+ const isYesterday = (date) => {
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ return isSameDay(date, yesterday);
+ };
+
+ const isThisWeek = (date) => {
+ const now = new Date();
+ const startOfWeek = new Date(now);
+ const day = now.getDay();
+ const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
+ startOfWeek.setDate(diff);
+ startOfWeek.setHours(0, 0, 0, 0);
+
+ const endOfWeek = new Date(startOfWeek);
+ endOfWeek.setDate(startOfWeek.getDate() + 6);
+ endOfWeek.setHours(23, 59, 59, 999);
+
+ return date >= startOfWeek && date <= endOfWeek;
+ };
+
+ const isLastWeek = (date) => {
+ const now = new Date();
+ const startOfLastWeek = new Date(now);
+ const day = now.getDay();
+ const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
+ startOfLastWeek.setDate(diff);
+ startOfLastWeek.setHours(0, 0, 0, 0);
+
+ const endOfLastWeek = new Date(startOfLastWeek);
+ endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
+ endOfLastWeek.setHours(23, 59, 59, 999);
+
+ return date >= startOfLastWeek && date <= endOfLastWeek;
+ };
+
+ const isThisMonth = (date) => {
+ const now = new Date();
+ return (
+ date.getFullYear() === now.getFullYear() &&
+ date.getMonth() === now.getMonth()
+ );
+ };
+
+ const isLastMonth = (date) => {
+ const now = new Date();
+ const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ return (
+ date.getFullYear() === lastMonth.getFullYear() &&
+ date.getMonth() === lastMonth.getMonth()
+ );
+ };
+
+ const isSameDay = (date1, date2) => {
+ return (
+ date1.getFullYear() === date2.getFullYear() &&
+ date1.getMonth() === date2.getMonth() &&
+ date1.getDate() === date2.getDate()
+ );
+ };
+
+ //--------------------日期分组与排序工具 end------------------
+
+ //------------------- 录音详情高级操作 start------------------
+ const reprocessTranscription = (recordingId) => {
+ const recording =
+ recordings.value.find((r) => r.id === recordingId) ||
+ selectedRecording.value;
+ confirmReprocess("transcription", recording);
+ };
+
+ const reprocessSummary = (recordingId) => {
+ const recording =
+ recordings.value.find((r) => r.id === recordingId) ||
+ selectedRecording.value;
+ confirmReprocess("summary", recording);
+ };
+
+ const generateSummary = async () => {
+ if (!selectedRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/generate_summary`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken,
+ },
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to generate summary");
+ }
+
+ // Update the recording status to show it's being processed
+ selectedRecording.value.status = "SUMMARIZING";
+
+ // Also update in recordings list if it exists
+ const recordingInList = recordings.value.find(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (recordingInList) {
+ recordingInList.status = "SUMMARIZING";
+ }
+
+ showToast("Summary generation started", "success");
+ } catch (error) {
+ console.error("Error generating summary:", error);
+ setGlobalError(`Failed to generate summary: ${error.message}`);
+ }
+ };
+
+ const resetRecordingStatus = async (recordingId) => {
+ const recording = recordings.value.find((r) => r.id === recordingId);
+ if (!recording) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/reset_status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to reset status");
+ }
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ if (selectedRecording.value?.id === recording.id) {
+ selectedRecording.value = data.recording;
+ }
+ showToast("Recording status has been reset.", "fa-check-circle");
+ } catch (error) {
+ setGlobalError(`Failed to reset status: ${error.message}`);
+ }
+ };
+
+ const confirmReprocess = (type, recording) => {
+ reprocessType.value = type;
+ reprocessRecording.value = recording;
+ showReprocessModal.value = true;
+ };
+
+ const openTranscriptionEditor = () => {
+ if (processedTranscription.value.isJson) {
+ openAsrEditorModal();
+ } else {
+ openTextEditorModal();
+ }
+ };
+
+ const openTextEditorModal = () => {
+ if (!selectedRecording.value) return;
+ editingTranscriptionContent.value =
+ selectedRecording.value.transcription;
+ showTextEditorModal.value = true;
+ };
+
+ const closeTextEditorModal = () => {
+ showTextEditorModal.value = false;
+ editingTranscriptionContent.value = "";
+ };
+
+ const saveTranscription = async () => {
+ if (!selectedRecording.value) return;
+ await saveTranscriptionContent(editingTranscriptionContent.value);
+ closeTextEditorModal();
+ };
+
+ const openAsrEditorModal = async () => {
+ if (!selectedRecording.value) return;
+ try {
+ const segments = JSON.parse(selectedRecording.value.transcription);
+ editingSegments.value = segments.map((s, i) => ({
+ ...s,
+ id: i,
+ showSuggestions: false,
+ filteredSpeakers: [],
+ }));
+
+ // Populate available speakers
+ const speakersInTranscript = [
+ ...new Set(segments.map((s) => s.speaker)),
+ ];
+ const response = await fetch("/tool/speakr/speakers");
+ const speakersFromDb = await response.json();
+ const speakerNamesFromDb = speakersFromDb.map((s) => s.name);
+ availableSpeakers.value = [
+ ...new Set([...speakersInTranscript, ...speakerNamesFromDb]),
+ ].sort();
+
+ showAsrEditorModal.value = true;
+ } catch (e) {
+ console.error(
+ "Could not parse transcription as JSON for ASR editor:",
+ e
+ );
+ setGlobalError(
+ "This transcription is not in the correct format for the ASR editor."
+ );
+ }
+ };
+
+ const closeAsrEditorModal = () => {
+ showAsrEditorModal.value = false;
+ editingSegments.value = [];
+ availableSpeakers.value = [];
+ };
+
+ const saveAsrTranscription = async () => {
+ const contentToSave = JSON.stringify(
+ editingSegments.value.map(
+ ({ id, showSuggestions, filteredSpeakers, ...rest }) => rest
+ )
+ );
+ await saveTranscriptionContent(contentToSave);
+ closeAsrEditorModal();
+ };
+
+ const adjustTime = (index, field, amount) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index][field] = parseFloat(
+ (editingSegments.value[index][field] + amount).toFixed(3)
+ );
+ }
+ };
+
+ const filterSpeakers = (index) => {
+ const segment = editingSegments.value[index];
+ if (segment) {
+ const query = segment.speaker.toLowerCase();
+ segment.filteredSpeakers = availableSpeakers.value.filter((s) =>
+ s.toLowerCase().includes(query)
+ );
+ }
+ };
+
+ const openSpeakerSuggestions = (index) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].showSuggestions = true;
+ filterSpeakers(index);
+ }
+ };
+
+ const closeSpeakerSuggestions = (index) => {
+ if (editingSegments.value[index]) {
+ window.setTimeout(() => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].showSuggestions = false;
+ }
+ }, 200); // Delay to allow click event to register
+ }
+ };
+
+ const selectSpeaker = (index, speaker) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].speaker = speaker;
+ editingSegments.value[index].showSuggestions = false;
+ }
+ };
+
+ const addSegment = () => {
+ const lastSegment =
+ editingSegments.value[editingSegments.value.length - 1];
+ editingSegments.value.push({
+ id: Date.now(),
+ speaker: lastSegment ? lastSegment.speaker : "SPEAKER_00",
+ start_time: lastSegment ? lastSegment.end_time : 0,
+ end_time: lastSegment ? lastSegment.end_time + 1 : 1,
+ sentence: "",
+ });
+ };
+
+ const removeSegment = (index) => {
+ editingSegments.value.splice(index, 1);
+ };
+
+ const saveTranscriptionContent = async (content) => {
+ if (!selectedRecording.value) return;
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/update_transcription`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ transcription: content }),
+ }
+ );
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update transcription");
+ const index = recordings.value.findIndex(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ selectedRecording.value = data.recording;
+ showToast("Transcription updated successfully!", "fa-check-circle");
+ } catch (error) {
+ console.error("Save Transcription Error:", error);
+ setGlobalError(`Failed to save transcription: ${error.message}`);
+ }
+ };
+
+ const cancelReprocess = () => {
+ showReprocessModal.value = false;
+ reprocessType.value = null;
+ reprocessRecording.value = null;
+ };
+
+ const executeReprocess = async () => {
+ if (!reprocessRecording.value || !reprocessType.value) return;
+
+ const recordingId = reprocessRecording.value.id;
+ const type = reprocessType.value;
+
+ // Close the modal first
+ cancelReprocess();
+
+ if (type === "transcription") {
+ await performReprocessTranscription(
+ recordingId,
+ asrReprocessOptions.language,
+ asrReprocessOptions.min_speakers,
+ asrReprocessOptions.max_speakers
+ );
+ } else if (type === "summary") {
+ await performReprocessSummary(recordingId);
+ }
+ };
+
+ const performReprocessTranscription = async (
+ recordingId,
+ language,
+ minSpeakers,
+ maxSpeakers
+ ) => {
+ if (!recordingId) {
+ setGlobalError("No recording ID provided for reprocessing.");
+ return;
+ }
+
+ try {
+ const payload = {
+ language: language,
+ min_speakers: minSpeakers,
+ max_speakers: maxSpeakers,
+ };
+
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reprocess_transcription`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to start transcription reprocessing"
+ );
+
+ // Ensure the recording status is properly set to PROCESSING
+ if (data.recording && data.recording.status !== "PROCESSING") {
+ console.warn(
+ `Warning: Reprocess transcription returned unexpected status: ${data.recording.status}`
+ );
+ data.recording.status = "PROCESSING";
+ }
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Transcription reprocessing started", "fa-sync-alt");
+
+ // Switch to Summary tab to show progress
+ selectedTab.value = "summary";
+
+ // Show progress modal for reprocessing
+ showProgressModalForReprocessing(recordingId, "transcription");
+
+ // Start polling for status updates
+ startReprocessingPoll(recordingId);
+ } catch (error) {
+ console.error("Reprocess Transcription Error:", error);
+ setGlobalError(
+ `Failed to start transcription reprocessing: ${error.message}`
+ );
+ }
+ };
+
+ const performReprocessSummary = async (recordingId) => {
+ if (!recordingId) {
+ setGlobalError("No recording ID provided for reprocessing.");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reprocess_summary`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to start summary reprocessing"
+ );
+
+ // Ensure the recording status is properly set to SUMMARIZING
+ if (data.recording && data.recording.status !== "SUMMARIZING") {
+ console.warn(
+ `Warning: Reprocess summary returned unexpected status: ${data.recording.status}`
+ );
+ data.recording.status = "SUMMARIZING";
+ }
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Summary reprocessing started", "fa-sync-alt");
+
+ // Switch to Summary tab to show progress
+ selectedTab.value = "summary";
+
+ // Show progress modal for reprocessing
+ showProgressModalForReprocessing(recordingId, "summary");
+
+ // Start polling for status updates
+ startReprocessingPoll(recordingId);
+ } catch (error) {
+ console.error("Reprocess Summary Error:", error);
+ setGlobalError(
+ `Failed to start summary reprocessing: ${error.message}`
+ );
+ }
+ };
+
+ // Show progress modal for reprocessing operations
+ const showProgressModalForReprocessing = (recordingId, type) => {
+ const recording = recordings.value.find((r) => r.id === recordingId);
+ if (!recording) return;
+
+ // Create a mock file item for the progress modal
+ const reprocessItem = {
+ file: {
+ name: recording.title || `Recording ${recordingId}`,
+ size: recording.file_size || 0,
+ },
+ status: "pending",
+ recordingId: recordingId,
+ clientId: `reprocess-${type}-${recordingId}-${Date.now()}`,
+ error: null,
+ isReprocessing: true,
+ reprocessType: type,
+ };
+
+ // Add to upload queue for progress tracking
+ uploadQueue.value.unshift(reprocessItem);
+
+ // Show progress modal
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ // Set as currently processing file
+ currentlyProcessingFile.value = reprocessItem;
+ processingProgress.value = 10;
+ processingMessage.value =
+ type === "transcription"
+ ? "Starting transcription reprocessing..."
+ : "Starting summary reprocessing...";
+ };
+
+ // Polling for reprocessing status updates
+ const reprocessingPolls = ref(new Map()); // Track active polls by recording ID
+
+ const startReprocessingPoll = (recordingId) => {
+ // Clear any existing poll for this recording
+ if (reprocessingPolls.value.has(recordingId)) {
+ clearInterval(reprocessingPolls.value.get(recordingId));
+ }
+
+ // console.log(`Starting reprocessing poll for recording ${recordingId}`);
+
+ const pollInterval = setInterval(async () => {
+ try {
+ const response = await fetch(`/tool/speakr/status/${recordingId}`);
+ if (!response.ok) {
+ console.error(`Status check failed for recording ${recordingId}`);
+ stopReprocessingPoll(recordingId);
+ return;
+ }
+
+ const data = await response.json();
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+ if (index !== -1) {
+ recordings.value[index] = data;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data;
+ }
+
+ const queueItem = uploadQueue.value.find(
+ (item) => item.recordingId === recordingId
+ );
+
+ // Update the item's status and name for intermediate states
+ if (queueItem) {
+ queueItem.status = data.status; // e.g., 'PROCESSING', 'SUMMARIZING'
+ queueItem.file.name = data.title || data.original_filename;
+ }
+
+ // Update progress modal if this is the currently processing item
+ if (
+ queueItem &&
+ currentlyProcessingFile.value?.clientId === queueItem.clientId
+ ) {
+ updateReprocessingProgress(data.status, queueItem);
+ }
+
+ // Stop polling if processing is complete
+ if (data.status === "COMPLETED" || data.status === "FAILED") {
+ console.log(
+ `Reprocessing ${data.status.toLowerCase()} for recording ${recordingId}`
+ );
+ stopReprocessingPoll(recordingId);
+
+ // Update queue item status to final lowercase state
+ if (queueItem) {
+ queueItem.status =
+ data.status === "COMPLETED" ? "completed" : "failed";
+ if (data.status === "FAILED") {
+ queueItem.error = data.error_message || "Reprocessing failed";
+ }
+ }
+
+ // Clear current processing if this was the active item
+ if (currentlyProcessingFile.value?.recordingId === recordingId) {
+ resetCurrentFileProcessingState();
+ }
+
+ if (data.status === "COMPLETED") {
+ showToast(
+ "Reprocessing completed successfully",
+ "fa-check-circle"
+ );
+ } else {
+ showToast("Reprocessing failed", "fa-exclamation-circle");
+ }
+ }
+ } catch (error) {
+ console.error(
+ `Error polling status for recording ${recordingId}:`,
+ error
+ );
+ stopReprocessingPoll(recordingId);
+ }
+ }, 3000);
+
+ reprocessingPolls.value.set(recordingId, pollInterval);
+ };
+
+ const updateReprocessingProgress = (status, queueItem) => {
+ switch (status) {
+ case "PENDING":
+ processingProgress.value = 20;
+ processingMessage.value = `Waiting to start ${queueItem.reprocessType} reprocessing...`;
+ break;
+ case "PROCESSING":
+ processingProgress.value = Math.round(
+ Math.min(70, processingProgress.value + Math.random() * 10)
+ );
+ processingMessage.value =
+ queueItem.reprocessType === "transcription"
+ ? "Reprocessing transcription..."
+ : "Processing audio...";
+ break;
+ case "SUMMARIZING":
+ processingProgress.value = Math.round(
+ Math.min(90, processingProgress.value + Math.random() * 10)
+ );
+ processingMessage.value =
+ queueItem.reprocessType === "summary"
+ ? "Regenerating summary..."
+ : "Generating title and summary...";
+ break;
+ case "COMPLETED":
+ processingProgress.value = 100;
+ processingMessage.value = "Reprocessing completed!";
+ break;
+ case "FAILED":
+ processingProgress.value = 100;
+ processingMessage.value = "Reprocessing failed.";
+ break;
+ default:
+ processingProgress.value = 15;
+ processingMessage.value = "Starting reprocessing...";
+ }
+ };
+
+ const stopReprocessingPoll = (recordingId) => {
+ if (reprocessingPolls.value.has(recordingId)) {
+ clearInterval(reprocessingPolls.value.get(recordingId));
+ reprocessingPolls.value.delete(recordingId);
+ console.log(`Stopped reprocessing poll for recording ${recordingId}`);
+ }
+ };
+
+ const confirmReset = (recording) => {
+ recordingToReset.value = recording;
+ showResetModal.value = true;
+ };
+
+ const cancelReset = () => {
+ showResetModal.value = false;
+ recordingToReset.value = null;
+ };
+
+ const executeReset = async () => {
+ if (!recordingToReset.value) return;
+ const recordingId = recordingToReset.value.id;
+
+ // Close the modal first
+ cancelReset();
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reset_status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to reset status");
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reset
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Recording status has been reset.", "fa-check-circle");
+ } catch (error) {
+ console.error("Reset Status Error:", error);
+ setGlobalError(`Failed to reset status: ${error.message}`);
+ }
+ };
+
+ const searchSpeakers = async (query, speakerId) => {
+ if (!query || query.length < 2) {
+ speakerSuggestions.value[speakerId] = [];
+ return;
+ }
+
+ loadingSuggestions.value[speakerId] = true;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/speakers/search?q=${encodeURIComponent(query)}`
+ );
+ if (!response.ok) throw new Error("Failed to search speakers");
+
+ const speakers = await response.json();
+ speakerSuggestions.value[speakerId] = speakers;
+ } catch (error) {
+ console.error("Error searching speakers:", error);
+ speakerSuggestions.value[speakerId] = [];
+ } finally {
+ loadingSuggestions.value[speakerId] = false;
+ }
+ };
+
+ const selectSpeakerSuggestion = (speakerId, suggestion) => {
+ if (speakerMap.value[speakerId]) {
+ speakerMap.value[speakerId].name = suggestion.name;
+ speakerSuggestions.value[speakerId] = [];
+ }
+ };
+
+ const closeSpeakerSuggestionsOnClick = (event) => {
+ // Check if the click was on an input field or dropdown
+ const clickedInput = event.target.closest('input[type="text"]');
+ const clickedDropdown = event.target.closest(".absolute.z-10");
+
+ // If not clicking on input or dropdown, close all suggestions
+ if (!clickedInput && !clickedDropdown) {
+ Object.keys(speakerSuggestions.value).forEach((speakerId) => {
+ speakerSuggestions.value[speakerId] = [];
+ });
+ }
+ };
+
+ // Create a mapping for display-friendly speaker IDs
+ const speakerDisplayMap = ref({});
+ const modalSpeakers = ref([]);
+ const is_final = ref(false);
+ const openSpeakerModal = () => {
+ // Clear any existing speaker map data first
+ speakerMap.value = {};
+ speakerDisplayMap.value = {};
+
+ // Get the same speaker order used in processedTranscription
+ const transcription = selectedRecording.value?.transcription;
+ let speakers = [];
+
+ if (transcription) {
+ try {
+ const transcriptionData = JSON.parse(transcription);
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ // Use the exact same logic as processedTranscription to get speakers
+ speakers = [
+ ...new Set(
+ transcriptionData
+ .map((segment) => segment.speaker)
+ .filter(Boolean)
+ ),
+ ];
+ }
+ } catch (e) {
+ // Fall back to identifiedSpeakers if JSON parsing fails
+ speakers = identifiedSpeakers.value;
+ }
+ }
+
+ // Set modalSpeakers for the template to use
+ modalSpeakers.value = speakers;
+
+ // Initialize speaker map with the same order and colors as the transcript
+ speakerMap.value = speakers.reduce((acc, speaker, index) => {
+ acc[speaker] = {
+ name: "",
+ isMe: false,
+ color: `speaker-color-${(index % 8) + 1}`, // Same color assignment as processedTranscription
+ };
+ // Keep the original speaker ID for display
+ speakerDisplayMap.value[speaker] = speaker;
+ return acc;
+ }, {});
+
+ highlightedSpeaker.value = null;
+ speakerSuggestions.value = {};
+ loadingSuggestions.value = {};
+ isAutoIdentifying.value = false;
+ showSpeakerModal.value = true;
+ };
+
+ const closeSpeakerModal = () => {
+ showSpeakerModal.value = false;
+ highlightedSpeaker.value = null;
+ // Clear the speaker map to prevent stale data from persisting
+ speakerMap.value = {};
+ speakerSuggestions.value = {};
+ loadingSuggestions.value = {};
+
+ // Clean up click handler if it exists
+ if (window.speakerModalClickHandler) {
+ const modalContent = document.querySelector(".modal-content");
+ if (modalContent) {
+ modalContent.removeEventListener(
+ "click",
+ window.speakerModalClickHandler
+ );
+ }
+ delete window.speakerModalClickHandler;
+ }
+ };
+
+ const saveSpeakerNames = async () => {
+ if (!selectedRecording.value) return;
+
+ // Create a filtered speaker map that excludes entries with blank names
+ const filteredSpeakerMap = Object.entries(speakerMap.value).reduce(
+ (acc, [speakerId, speakerData]) => {
+ if (speakerData.name && speakerData.name.trim() !== "") {
+ acc[speakerId] = speakerData;
+ }
+ return acc;
+ },
+ {}
+ );
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/update_speakers`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ speaker_map: filteredSpeakerMap, // Send the filtered map
+ regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value,
+ }),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update speakers");
+
+ // On success, close the modal and clear the speakerMap state *before*
+ // updating the recording data. This prevents a race condition where the
+ // view could re-render using the new data but the old, lingering speakerMap.
+ closeSpeakerModal();
+
+ // The backend returns the fully updated recording object.
+ // We can directly update our local state with this fresh data.
+ const index = recordings.value.findIndex(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ selectedRecording.value = data.recording;
+
+ showToast("Speaker names updated successfully!", "fa-check-circle");
+
+ // If a summary regeneration was requested, start polling for its status.
+ if (regenerateSummaryAfterSpeakerUpdate.value) {
+ startReprocessingPoll(selectedRecording.value.id);
+ }
+ } catch (error) {
+ console.error("Save Speaker Names Error:", error);
+ setGlobalError(`Failed to save speaker names: ${error.message}`);
+ }
+ };
+
+ // Speaker group navigation state
+ const currentSpeakerGroupIndex = ref(-1);
+ const speakerGroups = ref([]);
+
+ const findSpeakerGroups = (speakerId) => {
+ if (!speakerId) return [];
+
+ const groups = [];
+ const modalTranscript = document.querySelector(
+ "div.speaker-modal-transcript"
+ );
+ const mainTranscript = document.querySelector(
+ ".transcription-simple-view, .transcription-with-speakers, .transcription-content"
+ );
+ const transcriptContainer = modalTranscript || mainTranscript;
+
+ if (!transcriptContainer) return [];
+
+ // For JSON-based transcripts with segments
+ const allSegments =
+ transcriptContainer.querySelectorAll(".speaker-segment");
+ if (allSegments.length > 0) {
+ let currentGroup = null;
+ let lastSpeakerId = null;
+
+ allSegments.forEach((segment) => {
+ const speakerTag = segment.querySelector("[data-speaker-id]");
+ const segmentSpeakerId = speakerTag?.dataset.speakerId;
+
+ if (segmentSpeakerId === speakerId) {
+ // If this is a new group (not consecutive with previous)
+ if (lastSpeakerId !== speakerId) {
+ currentGroup = {
+ startElement: segment,
+ elements: [segment],
+ };
+ groups.push(currentGroup);
+ } else if (currentGroup) {
+ // Add to existing group
+ currentGroup.elements.push(segment);
+ }
+ }
+ lastSpeakerId = segmentSpeakerId;
+ });
+ } else {
+ // For plain text transcripts with speaker tags
+ const allTags =
+ transcriptContainer.querySelectorAll("[data-speaker-id]");
+ let currentGroup = null;
+
+ allTags.forEach((tag) => {
+ if (tag.dataset.speakerId === speakerId) {
+ // Find the parent element that contains this speaker's content
+ const parentSegment =
+ tag.closest(".speaker-segment") || tag.parentElement;
+
+ if (
+ !currentGroup ||
+ !currentGroup.lastElement ||
+ !parentSegment.previousElementSibling ||
+ parentSegment.previousElementSibling !==
+ currentGroup.lastElement
+ ) {
+ // Start a new group
+ currentGroup = {
+ startElement: parentSegment,
+ elements: [parentSegment],
+ lastElement: parentSegment,
+ };
+ groups.push(currentGroup);
+ } else {
+ // Continue the group
+ currentGroup.elements.push(parentSegment);
+ currentGroup.lastElement = parentSegment;
+ }
+ }
+ });
+ }
+
+ return groups;
+ };
+
+ const highlightSpeakerInTranscript = (speakerId) => {
+ highlightedSpeaker.value = speakerId;
+
+ if (speakerId) {
+ // Find all speaker groups for navigation
+ speakerGroups.value = findSpeakerGroups(speakerId);
+ currentSpeakerGroupIndex.value = 0;
+
+ // Scroll to the first group
+ if (speakerGroups.value.length > 0) {
+ nextTick(() => {
+ const firstGroup = speakerGroups.value[0];
+ if (firstGroup && firstGroup.startElement) {
+ firstGroup.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ });
+ }
+ } else {
+ speakerGroups.value = [];
+ currentSpeakerGroupIndex.value = -1;
+ }
+ };
+
+ const navigateToNextSpeakerGroup = () => {
+ if (speakerGroups.value.length === 0) return;
+
+ // Don't reset the speaker groups, just update the index
+ currentSpeakerGroupIndex.value =
+ (currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length;
+ const group = speakerGroups.value[currentSpeakerGroupIndex.value];
+ if (group && group.startElement) {
+ group.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ };
+
+ const navigateToPrevSpeakerGroup = () => {
+ if (speakerGroups.value.length === 0) return;
+
+ // Don't reset the speaker groups, just update the index
+ currentSpeakerGroupIndex.value =
+ currentSpeakerGroupIndex.value <= 0
+ ? speakerGroups.value.length - 1
+ : currentSpeakerGroupIndex.value - 1;
+ const group = speakerGroups.value[currentSpeakerGroupIndex.value];
+ if (group && group.startElement) {
+ group.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ };
+
+ // Enhanced speaker highlighting with focus/blur events for text inputs
+ const focusSpeaker = (speakerId) => {
+ // Set this as the active speaker input
+ activeSpeakerInput.value = speakerId;
+ // Only highlight if not already highlighted (to preserve navigation state)
+ if (highlightedSpeaker.value !== speakerId) {
+ highlightSpeakerInTranscript(speakerId);
+ }
+ };
+
+ const blurSpeaker = () => {
+ // Clear the active speaker input after a delay to allow clicking on suggestions
+ setTimeout(() => {
+ activeSpeakerInput.value = null;
+ speakerSuggestions.value = {};
+ }, 200);
+ clearSpeakerHighlight();
+ };
+
+ const clearSpeakerHighlight = () => {
+ highlightedSpeaker.value = null;
+ };
+
+ const autoIdentifySpeakers = async () => {
+ if (!selectedRecording.value) {
+ showToast("No recording selected.", "fa-exclamation-circle");
+ return;
+ }
+
+ isAutoIdentifying.value = true;
+ showToast("Starting automatic speaker identification...", "fa-magic");
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/auto_identify_speakers`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ current_speaker_map: speakerMap.value,
+ }),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ data.error || "Unknown error occurred during auto-identification."
+ );
+ }
+
+ // Check if there's a message (e.g., all speakers already identified)
+ if (data.message) {
+ showToast(data.message, "fa-info-circle");
+ return;
+ }
+
+ // Update speakerMap with the identified names (only for unidentified speakers)
+ let identifiedCount = 0;
+ for (const speakerId in data.speaker_map) {
+ const identifiedName = data.speaker_map[speakerId];
+ if (
+ speakerMap.value[speakerId] &&
+ identifiedName &&
+ identifiedName.trim() !== ""
+ ) {
+ speakerMap.value[speakerId].name = identifiedName;
+ identifiedCount++;
+ }
+ }
+
+ if (identifiedCount > 0) {
+ showToast(
+ `${identifiedCount} speaker(s) identified successfully!`,
+ "fa-check-circle"
+ );
+ } else {
+ showToast(
+ "No speakers could be identified from the context.",
+ "fa-info-circle"
+ );
+ }
+ } catch (error) {
+ console.error("Auto Identify Speakers Error:", error);
+ showToast(`Error: ${error.message}`, "fa-exclamation-circle", 5000);
+ } finally {
+ isAutoIdentifying.value = false;
+ }
+ };
+
+ const toggleInbox = async (recording) => {
+ if (!recording || !recording.id) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/toggle_inbox`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to toggle inbox status");
+
+ // Update the recording in the UI
+ recording.is_inbox = data.is_inbox;
+
+ // Update in the recordings list
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].is_inbox = data.is_inbox;
+ }
+
+ showToast(
+ `Recording ${data.is_inbox ? "moved to inbox" : "marked as read"}`
+ );
+ } catch (error) {
+ console.error("Toggle Inbox Error:", error);
+ setGlobalError(`Failed to toggle inbox status: ${error.message}`);
+ }
+ };
+
+ // Toggle highlighted status
+ const toggleHighlight = async (recording) => {
+ if (!recording || !recording.id) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/toggle_highlight`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to toggle highlighted status"
+ );
+
+ // Update the recording in the UI
+ recording.is_highlighted = data.is_highlighted;
+
+ // Update in the recordings list
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].is_highlighted = data.is_highlighted;
+ }
+
+ showToast(
+ `Recording ${data.is_highlighted ? "highlighted" : "unhighlighted"}`
+ );
+ } catch (error) {
+ console.error("Toggle Highlight Error:", error);
+ setGlobalError(
+ `Failed to toggle highlighted status: ${error.message}`
+ );
+ }
+ };
+
+ //--------------------录音详情高级操作 end------------------
+
+ //------------------- 深色模式与主题切换 start------------------
+ // --- Dark Mode ---
+ const toggleDarkMode = () => {
+ isDarkMode.value = !isDarkMode.value;
+ if (isDarkMode.value) {
+ document.documentElement.classList.add("dark");
+ localStorage.setItem("darkMode", "true");
+ } else {
+ document.documentElement.classList.remove("dark");
+ localStorage.setItem("darkMode", "false");
+ }
+ };
+
+ const initializeDarkMode = () => {
+ const prefersDark = window.matchMedia(
+ "(prefers-color-scheme: dark)"
+ ).matches;
+ const savedMode = localStorage.getItem("darkMode");
+ if (savedMode === "true" || (savedMode === null && prefersDark)) {
+ isDarkMode.value = true;
+ document.documentElement.classList.add("dark");
+ } else {
+ isDarkMode.value = false;
+ document.documentElement.classList.remove("dark");
+ }
+ };
+
+ const applyColorScheme = (schemeId, mode = null) => {
+ const targetMode = mode || (isDarkMode.value ? "dark" : "light");
+ const scheme = colorSchemes[targetMode].find((s) => s.id === schemeId);
+
+ if (!scheme) {
+ console.warn(
+ `Color scheme '${schemeId}' not found for mode '${targetMode}'`
+ );
+ return;
+ }
+
+ const allThemeClasses = [
+ ...colorSchemes.light.map((s) => s.class),
+ ...colorSchemes.dark.map((s) => s.class),
+ ].filter((c) => c !== "");
+
+ document.documentElement.classList.remove(...allThemeClasses);
+
+ if (scheme.class) {
+ document.documentElement.classList.add(scheme.class);
+ }
+
+ currentColorScheme.value = schemeId;
+ localStorage.setItem("colorScheme", schemeId);
+ };
+
+ const initializeColorScheme = () => {
+ const savedScheme = localStorage.getItem("colorScheme") || "blue";
+ currentColorScheme.value = savedScheme;
+ applyColorScheme(savedScheme);
+ };
+
+ const openColorSchemeModal = () => {
+ showColorSchemeModal.value = true;
+ };
+
+ const closeColorSchemeModal = () => {
+ showColorSchemeModal.value = false;
+ };
+
+ const selectColorScheme = (schemeId) => {
+ applyColorScheme(schemeId);
+ showToast(
+ `Applied ${
+ colorSchemes[isDarkMode.value ? "dark" : "light"].find(
+ (s) => s.id === schemeId
+ )?.name
+ } theme`,
+ "fa-palette"
+ );
+ };
+
+ const resetColorScheme = () => {
+ applyColorScheme("blue");
+ showToast("Reset to default Ocean Blue theme", "fa-undo");
+ };
+
+ // Watch for dark mode changes to reapply color scheme
+ watch(isDarkMode, () => {
+ applyColorScheme(currentColorScheme.value);
+ });
+ function confirmStopRecording() {
+ return new Promise((resolve) => {
+ // 防止重复创建
+ if (document.getElementById("recording-confirm-mask")) {
+ return;
+ }
+
+ // 遮罩层
+ const mask = document.createElement("div");
+ mask.id = "recording-confirm-mask";
+ mask.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.45);
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ `;
+
+ // 弹框
+ const dialog = document.createElement("div");
+ dialog.style.cssText = `
+ width: 360px;
+ background: #1f2937;
+ color: #fff;
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: 0 10px 30px rgba(0,0,0,.3);
+ font-family: system-ui;
+ `;
+
+ dialog.innerHTML = `
+
+ 确认停止录音
+
+
+ 当前正在录音,切换页面将会停止录音,是否继续?
+
+
+
+ 继续录音
+
+
+ 停止并切换
+
+
+ `;
+
+ mask.appendChild(dialog);
+ document.body.appendChild(mask);
+
+ const cleanup = () => {
+ document.body.removeChild(mask);
+ };
+
+ dialog.querySelector("#confirm-ok").onclick = () => {
+ cleanup();
+ resolve(true);
+ };
+
+ dialog.querySelector("#confirm-cancel").onclick = () => {
+ cleanup();
+ resolve(false);
+ };
+
+ // 点击遮罩关闭 = 取消
+ mask.onclick = (e) => {
+ if (e.target === mask) {
+ cleanup();
+ resolve(false);
+ }
+ };
+ });
+ }
+
+ //--------------------深色模式与主题切换 end------------------
+
+ //------------------- 侧边栏与视图切换控制 start------------------
+ // --- Sidebar Toggle ---
+ const toggleSidebar = () => {
+ isSidebarCollapsed.value = !isSidebarCollapsed.value;
+ };
+
+ //--------------------侧边栏与视图切换控制 end------------------
+
+ //------------------- 页面视图管理 start------------------
+ // --- View Management ---
+ const switchToUploadView = async () => {
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } else {
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ }
+ };
+
+ const selectRecording = async (recording) => {
+ if (currentView.value === "recording" && isRecording.value) {
+ // If we are in the middle of a recording, don't switch views
+ setGlobalError(
+ "Please stop the current recording before selecting another one."
+ );
+ return;
+ }
+ selectedRecording.value = recording;
+
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "detail";
+ } else {
+ currentView.value = "detail";
+ }
+ if (recording && recording.id) {
+ localStorage.setItem("lastSelectedRecordingId", recording.id);
+ } else {
+ localStorage.removeItem("lastSelectedRecordingId");
+ }
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } else {
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "detail";
+ } else {
+ currentView.value = "detail";
+ }
+ if (recording && recording.id) {
+ localStorage.setItem("lastSelectedRecordingId", recording.id);
+ } else {
+ localStorage.removeItem("lastSelectedRecordingId");
+ }
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ }
+ };
+
+ //--------------------页面视图管理 end------------------
+
+ //------------------- 文件上传与上传队列 start------------------
+ // --- File Upload ---
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ dragover.value = true;
+ };
+
+ const handleDragLeave = (e) => {
+ if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) {
+ return;
+ }
+ dragover.value = false;
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ dragover.value = false;
+ addFilesToQueue(e.dataTransfer.files);
+ };
+
+ const handleFileSelect = (e) => {
+ addFilesToQueue(e.target.files);
+ e.target.value = null;
+ };
+
+ const addFilesToQueue = (files) => {
+ let filesAdded = 0;
+ for (const file of files) {
+ const fileObject = file.file ? file.file : file;
+ const notes = file.notes || null;
+ const tags = file.tags || selectedTags.value || [];
+ const asrOptions = file.asrOptions || {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ };
+
+ // Check if it's an audio file or video container with audio
+ const isAudioFile =
+ fileObject &&
+ (fileObject.type.startsWith("audio/") ||
+ fileObject.type === "video/mp4" ||
+ fileObject.type === "video/quicktime" ||
+ fileObject.type === "video/x-msvideo" ||
+ fileObject.type === "video/webm" ||
+ fileObject.name.toLowerCase().endsWith(".amr") ||
+ fileObject.name.toLowerCase().endsWith(".3gp") ||
+ fileObject.name.toLowerCase().endsWith(".3gpp") ||
+ fileObject.name.toLowerCase().endsWith(".mp4") ||
+ fileObject.name.toLowerCase().endsWith(".mov") ||
+ fileObject.name.toLowerCase().endsWith(".avi") ||
+ fileObject.name.toLowerCase().endsWith(".mkv") ||
+ fileObject.name.toLowerCase().endsWith(".webm"));
+
+ if (isAudioFile) {
+ // Only check general file size limit (chunking handles OpenAI 25MB limit automatically)
+ if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) {
+ setGlobalError(
+ `File "${fileObject.name}" exceeds the maximum size of ${maxFileSizeMB.value} MB and was skipped.`
+ );
+ continue;
+ }
+
+ const clientId = `client-${Date.now()}-${Math.random()
+ .toString(36)
+ .substring(2, 9)}`;
+
+ // Auto-summarization will always occur for all uploads
+ const willAutoSummarize = true;
+
+ uploadQueue.value.push({
+ file: fileObject,
+ notes: notes,
+ tags: tags,
+ asrOptions: asrOptions,
+ status: "queued",
+ recordingId: null,
+ clientId: clientId,
+ error: null,
+ willAutoSummarize: willAutoSummarize,
+ });
+ filesAdded++;
+ } else if (fileObject) {
+ setGlobalError(
+ `Invalid file type "${fileObject.name}". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.`
+ );
+ }
+ }
+ if (filesAdded > 0) {
+ console.log(`Added ${filesAdded} file(s) to the queue.`);
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+ }
+ };
+
+ const resetCurrentFileProcessingState = () => {
+ if (pollInterval.value) clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ currentlyProcessingFile.value = null;
+ processingProgress.value = 0;
+ processingMessage.value = "";
+ };
+
+ const startProcessingQueue = async () => {
+ console.log("Attempting to start processing queue...");
+ if (isProcessingActive.value) {
+ console.log("Queue processor already active.");
+ return;
+ }
+
+ isProcessingActive.value = true;
+ resetCurrentFileProcessingState();
+
+ const nextFileItem = uploadQueue.value.find(
+ (item) => item.status === "queued"
+ );
+
+ if (nextFileItem) {
+ console.log(
+ `Processing next file: ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId})`
+ );
+ currentlyProcessingFile.value = nextFileItem;
+
+ // Check if this is a "reload" item (existing recording being tracked)
+ if (nextFileItem.clientId.startsWith("reload-")) {
+ // Skip upload, go directly to polling existing recording
+ console.log(
+ `Skipping upload for existing recording: ${nextFileItem.recordingId}`
+ );
+ nextFileItem.status = "processing";
+ startStatusPolling(nextFileItem, nextFileItem.recordingId);
+ return;
+ }
+
+ nextFileItem.status = "uploading";
+ processingMessage.value = "Preparing upload...";
+ processingProgress.value = 5;
+
+ try {
+ const formData = new FormData();
+ formData.append("file", nextFileItem.file);
+ if (nextFileItem.notes) {
+ formData.append("notes", nextFileItem.notes);
+ }
+
+ // Add tags if selected (multiple tags)
+ // Use tags from the queue item if available, otherwise use global selectedTagIds
+ const tagsToUse = nextFileItem.tags || selectedTags.value || [];
+ tagsToUse.forEach((tag, index) => {
+ const tagId = tag.id || tag; // Handle both tag objects and tag IDs
+ formData.append(`tag_ids[${index}]`, tagId);
+ });
+
+ // Add ASR advanced options if ASR endpoint is enabled
+ if (useAsrEndpoint.value) {
+ // Use ASR options from the queue item if available, otherwise use global values
+ const asrOpts = nextFileItem.asrOptions || {};
+ const language = asrOpts.language || uploadLanguage.value;
+ const minSpeakers =
+ asrOpts.min_speakers || uploadMinSpeakers.value;
+ const maxSpeakers =
+ asrOpts.max_speakers || uploadMaxSpeakers.value;
+
+ if (language) {
+ formData.append("language", language);
+ }
+ // Only send speaker limits if they're actually set
+ if (minSpeakers && minSpeakers !== "") {
+ formData.append("min_speakers", minSpeakers.toString());
+ }
+ if (maxSpeakers && maxSpeakers !== "") {
+ formData.append("max_speakers", maxSpeakers.toString());
+ }
+ }
+
+ processingMessage.value = "Uploading file...";
+ processingProgress.value = 10;
+
+ const response = await fetch("/tool/speakr/upload", {
+ method: "POST",
+ body: formData,
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ let errorMsg =
+ data.error || `Upload failed with status ${response.status}`;
+ if (response.status === 413)
+ errorMsg =
+ data.error ||
+ `File too large. Max: ${
+ data.max_size_mb?.toFixed(0) || maxFileSizeMB.value
+ } MB.`;
+ throw new Error(errorMsg);
+ }
+
+ if (response.status === 202 && data.id) {
+ console.log(
+ `File ${nextFileItem.file.name} uploaded. Recording ID: ${data.id}. Starting status poll.`
+ );
+ nextFileItem.status = "pending";
+ nextFileItem.recordingId = data.id;
+ processingMessage.value =
+ "Upload complete. Waiting for processing...";
+ processingProgress.value = 30;
+
+ recordings.value.unshift(data);
+ totalRecordings.value++; // Update total count
+ pollProcessingStatus(nextFileItem);
+ } else {
+ throw new Error(
+ "Unexpected success response from server after upload."
+ );
+ }
+ } catch (error) {
+ console.error(
+ `Upload/Processing Error for ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId}):`,
+ error
+ );
+ nextFileItem.status = "failed";
+ nextFileItem.error = error.message;
+ const failedRecordIndex = recordings.value.findIndex(
+ (r) => r.id === nextFileItem.recordingId
+ );
+ if (failedRecordIndex !== -1) {
+ recordings.value[failedRecordIndex].status = "FAILED";
+ recordings.value[
+ failedRecordIndex
+ ].transcription = `Upload/Processing failed: ${error.message}`;
+ } else {
+ setGlobalError(
+ `Failed to process "${nextFileItem.file.name}": ${error.message}`
+ );
+ }
+
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ } else {
+ console.log("Upload queue is empty or no files are queued.");
+ isProcessingActive.value = false;
+ }
+ };
+
+ const startStatusPolling = (fileItem, recordingId) => {
+ fileItem.recordingId = recordingId;
+ pollProcessingStatus(fileItem);
+ };
+
+ const pollProcessingStatus = (fileItem) => {
+ if (pollInterval.value) clearInterval(pollInterval.value);
+
+ const recordingId = fileItem.recordingId;
+ if (!recordingId) {
+ console.error(
+ "Cannot poll status without recording ID for",
+ fileItem.file.name
+ );
+ fileItem.status = "failed";
+ fileItem.error = "Internal error: Missing recording ID for polling.";
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ nextTick(startProcessingQueue);
+ return;
+ }
+
+ processingMessage.value = "Waiting for transcription...";
+ processingProgress.value = 40;
+
+ pollInterval.value = setInterval(async () => {
+ // Check if we should stop polling
+ const shouldStopPolling =
+ !currentlyProcessingFile.value ||
+ currentlyProcessingFile.value.clientId !== fileItem.clientId ||
+ fileItem.status === "failed" ||
+ (fileItem.status === "completed" &&
+ (!fileItem.willAutoSummarize || fileItem.summaryCompleted));
+
+ if (shouldStopPolling) {
+ console.log(
+ `Polling stopped for ${fileItem.clientId} as it's no longer active or finished.`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ if (
+ currentlyProcessingFile.value &&
+ currentlyProcessingFile.value.clientId === fileItem.clientId
+ ) {
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ return;
+ }
+
+ try {
+ console.log(
+ `Polling status for recording ID: ${recordingId} (${fileItem.file.name})`
+ );
+ const response = await fetch(`/tool/speakr/status/${recordingId}`);
+ if (!response.ok)
+ throw new Error(
+ `Status check failed with status ${response.status}`
+ );
+
+ const data = await response.json();
+ const galleryIndex = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+
+ if (galleryIndex !== -1) {
+ recordings.value[galleryIndex] = data;
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data;
+ }
+ }
+
+ const previousStatus = fileItem.status;
+ fileItem.status = data.status;
+ fileItem.file.name = data.title || data.original_filename;
+
+ if (data.status === "COMPLETED") {
+ console.log(
+ `Processing COMPLETED for ${fileItem.file.name} (ID: ${recordingId})`
+ );
+
+ // If this was previously summarizing, it's now fully complete
+ if (previousStatus === "summarizing") {
+ console.log(`Auto-summary completed for ${fileItem.file.name}`);
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true;
+
+ // This is final completion - clean up immediately and synchronously
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Completed item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ // Use immediate startProcessingQueue instead of nextTick to avoid duplication
+ startProcessingQueue();
+ return; // Exit early to prevent further processing
+ }
+ // If auto-summarization will occur and hasn't started yet, wait for it
+ else if (
+ fileItem.willAutoSummarize &&
+ !fileItem.hasCheckedForAutoSummary
+ ) {
+ processingMessage.value = "Transcription complete!";
+ processingProgress.value = 85;
+ fileItem.status = "awaiting_summary"; // Use intermediate status to keep it in upload queue
+ // Don't mark as summaryCompleted yet, continue polling
+ }
+ // No auto-summarization expected, complete normally
+ else {
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true; // No summary expected, so consider it complete
+
+ // Complete immediately for files without auto-summarization
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Completed item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ startProcessingQueue();
+ return; // Exit early to prevent further processing
+ }
+
+ // For files with auto-summarization, mark that they've been checked and continue polling
+ if (
+ fileItem.willAutoSummarize &&
+ !fileItem.hasCheckedForAutoSummary
+ ) {
+ fileItem.hasCheckedForAutoSummary = true;
+ fileItem.autoSummaryStartTime = Date.now();
+ console.log(
+ `Auto-summary expected for ${fileItem.file.name}, continuing to poll...`
+ );
+ // Don't complete yet, continue polling
+ return;
+ }
+
+ // If we have auto-summarization and we've been waiting, check if we should timeout
+ if (
+ fileItem.willAutoSummarize &&
+ fileItem.hasCheckedForAutoSummary
+ ) {
+ const waitTime = Date.now() - fileItem.autoSummaryStartTime;
+ const maxWaitTime = 60000; // 60 seconds
+
+ if (waitTime > maxWaitTime) {
+ // Timeout - complete the process
+ console.log(
+ `Auto-summary timeout for ${fileItem.file.name}, completing...`
+ );
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true; // Mark as complete due to timeout
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Timed-out item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ startProcessingQueue();
+ } else {
+ // Still waiting for auto-summary, continue polling
+ return;
+ }
+ }
+
+ // Normal completion path (no auto-summary check needed)
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Remove this item from uploadQueue immediately to prevent duplication
+ const queueIndex = uploadQueue.value.findIndex(
+ (item) => item.clientId === fileItem.clientId
+ );
+ if (queueIndex !== -1) {
+ uploadQueue.value.splice(queueIndex, 1);
+ console.log(
+ `Removed completed item ${fileItem.clientId} from queue immediately`
+ );
+ }
+
+ startProcessingQueue();
+ } else if (data.status === "FAILED") {
+ console.log(
+ `Processing FAILED for ${fileItem.file.name} (ID: ${recordingId})`
+ );
+ processingMessage.value = "Processing failed.";
+ processingProgress.value = 100;
+ fileItem.status = "failed";
+ fileItem.error =
+ data.transcription ||
+ data.summary ||
+ "Processing failed on server.";
+ setGlobalError(
+ `Processing failed for "${data.title || fileItem.file.name}".`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ } else if (data.status === "PROCESSING") {
+ // Check if this file will actually use chunking based on all conditions:
+ // 1. Chunking must be enabled in config
+ // 2. Must NOT be using ASR endpoint (ASR handles large files natively)
+ // 3. For size-based: File size must exceed the limit (can determine immediately)
+ // 4. For time-based: Can't determine client-side, but backend logs show it gets duration
+
+ const couldUseChunking =
+ chunkingEnabled.value && !useAsrEndpoint.value;
+
+ if (couldUseChunking) {
+ if (chunkingMode.value === "size") {
+ // Size-based chunking: we can determine definitively
+ const chunkThresholdBytes = chunkingLimit.value * 1024 * 1024;
+ const willUseChunking =
+ fileItem.file.size > chunkThresholdBytes;
+
+ if (willUseChunking) {
+ processingMessage.value =
+ "Processing large file (chunking in progress)...";
+ // If auto-summarization will occur, cap at 70%, otherwise 80%
+ const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 3
+ )
+ );
+ } else {
+ processingMessage.value = "转录进行中...";
+ // If auto-summarization will occur, cap at 65%, otherwise 75%
+ const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 5
+ )
+ );
+ }
+ } else {
+ // Duration-based chunking: Backend determines this after getting duration
+ // Show a neutral processing message since we can't know client-side
+ processingMessage.value =
+ "Processing file (chunking determined server-side)...";
+ const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 3
+ )
+ );
+ }
+ } else {
+ processingMessage.value = "转录进行中...";
+ const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 5
+ )
+ );
+ }
+ } else if (data.status === "SUMMARIZING") {
+ console.log(`Auto-summary started for ${fileItem.file.name}`);
+ processingMessage.value = "Generating summary...";
+ processingProgress.value = 90;
+ fileItem.status = "summarizing";
+ } else {
+ processingMessage.value = "Waiting in queue...";
+ processingProgress.value = 45;
+ }
+ } catch (error) {
+ console.error(
+ `Polling Error for ${fileItem.file.name} (ID: ${recordingId}):`,
+ error
+ );
+ fileItem.status = "failed";
+ fileItem.error = `Error checking status: ${error.message}`;
+ setGlobalError(
+ `Error checking status for "${fileItem.file.name}": ${error.message}.`
+ );
+ const galleryIndex = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+ if (galleryIndex !== -1)
+ recordings.value[galleryIndex].status = "FAILED";
+
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ }, 5000);
+ };
+
+ //--------------------文件上传与上传队列 end------------------
+
+ //------------------- 录音与标签数据加载 start------------------
+ // --- Data Loading ---
+ const loadRecordings = async (
+ page = 1,
+ append = false,
+ searchQuery = ""
+ ) => {
+ globalError.value = null;
+ if (!append) {
+ isLoadingRecordings.value = true;
+ } else {
+ isLoadingMore.value = true;
+ }
+
+ try {
+ const params = new URLSearchParams({
+ page: page.toString(),
+ per_page: perPage.value.toString(),
+ });
+
+ if (searchQuery.trim()) {
+ params.set("q", searchQuery.trim());
+ }
+
+ const response = await fetch(`/tool/speakr/api/recordings?${params}`);
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to load recordings");
+
+ // Update pagination state
+ currentPage.value = data.pagination.page;
+ totalRecordings.value = data.pagination.total;
+ totalPages.value = data.pagination.total_pages;
+ hasNextPage.value = data.pagination.has_next;
+ hasPrevPage.value = data.pagination.has_prev;
+
+ // Update recordings data
+ if (append) {
+ // Append to existing recordings (infinite scroll)
+ recordings.value = [...recordings.value, ...data.recordings];
+ } else {
+ // Replace recordings (fresh load or search)
+ recordings.value = data.recordings;
+
+ // Try to restore last selected recording
+ const lastRecordingId = localStorage.getItem(
+ "lastSelectedRecordingId"
+ );
+ if (lastRecordingId && data.recordings.length > 0) {
+ const recordingToSelect = data.recordings.find(
+ (r) => r.id == lastRecordingId
+ );
+ if (recordingToSelect) {
+ selectRecording(recordingToSelect);
+ }
+ }
+ }
+
+ // Handle incomplete recordings for processing queue
+ const incompleteRecordings = data.recordings.filter((r) =>
+ ["PENDING", "PROCESSING", "SUMMARIZING"].includes(r.status)
+ );
+ if (incompleteRecordings.length > 0 && !isProcessingActive.value) {
+ console.warn(
+ `Found ${incompleteRecordings.length} incomplete recording(s) on load.`
+ );
+ for (const recording of incompleteRecordings) {
+ let queueItem = uploadQueue.value.find(
+ (item) => item.recordingId === recording.id
+ );
+ if (!queueItem) {
+ queueItem = {
+ file: {
+ name: recording.title || `Recording ${recording.id}`,
+ size: recording.file_size,
+ },
+ status: "queued",
+ recordingId: recording.id,
+ clientId: `reload-${recording.id}`,
+ error: null,
+ };
+ uploadQueue.value.unshift(queueItem);
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Load Recordings Error:", error);
+ setGlobalError(`Failed to load recordings: ${error.message}`);
+ if (!append) {
+ recordings.value = [];
+ }
+ } finally {
+ isLoadingRecordings.value = false;
+ isLoadingMore.value = false;
+ }
+ };
+
+ // Load more recordings (infinite scroll)
+ const loadMoreRecordings = async () => {
+ if (!hasNextPage.value || isLoadingMore.value) return;
+ await loadRecordings(currentPage.value + 1, true, searchQuery.value);
+ };
+
+ // Search with debouncing
+ const performSearch = async (query = "") => {
+ currentPage.value = 1;
+ await loadRecordings(1, false, query);
+ };
+
+ // Debounced search function
+ const debouncedSearch = (query) => {
+ if (searchDebounceTimer.value) {
+ clearTimeout(searchDebounceTimer.value);
+ }
+ searchDebounceTimer.value = setTimeout(() => {
+ performSearch(query);
+ }, 300); // 300ms debounce
+ };
+
+ const loadTags = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/tags");
+ if (response.ok) {
+ availableTags.value = await response.json();
+ } else {
+ console.warn("Failed to load tags:", response.status);
+ availableTags.value = [];
+ }
+ } catch (error) {
+ console.warn("Error loading tags:", error);
+ availableTags.value = [];
+ }
+ };
+
+ const addTagToSelection = (tagId) => {
+ if (!selectedTagIds.value.includes(tagId)) {
+ selectedTagIds.value.push(tagId);
+ applyTagDefaults();
+ }
+ };
+
+ const removeTagFromSelection = (tagId) => {
+ const index = selectedTagIds.value.indexOf(tagId);
+ if (index > -1) {
+ selectedTagIds.value.splice(index, 1);
+ applyTagDefaults();
+ }
+ };
+
+ const applyTagDefaults = () => {
+ // Apply defaults from the first selected tag (highest priority)
+ const firstTag = selectedTags.value[0];
+ if (firstTag && useAsrEndpoint.value) {
+ if (firstTag.default_language) {
+ uploadLanguage.value = firstTag.default_language;
+ }
+ if (firstTag.default_min_speakers) {
+ uploadMinSpeakers.value = firstTag.default_min_speakers;
+ }
+ if (firstTag.default_max_speakers) {
+ uploadMaxSpeakers.value = firstTag.default_max_speakers;
+ }
+ }
+ };
+
+ // Legacy function for backward compatibility
+ const onTagSelected = applyTagDefaults;
+
+ // Tag helper functions
+ const getRecordingTags = (recording) => {
+ if (!recording || !recording.tags) return [];
+ return recording.tags || [];
+ };
+
+ const getAvailableTagsForRecording = (recording) => {
+ if (!recording || !availableTags.value) return [];
+ const recordingTagIds = getRecordingTags(recording).map(
+ (tag) => tag.id
+ );
+ return availableTags.value.filter(
+ (tag) => !recordingTagIds.includes(tag.id)
+ );
+ };
+
+ // Computed property for filtered available tags in the modal
+ const filteredAvailableTagsForModal = computed(() => {
+ if (!editingRecording.value) return [];
+ const availableTags = getAvailableTagsForRecording(
+ editingRecording.value
+ );
+ if (!tagSearchFilter.value) return availableTags;
+
+ const filter = tagSearchFilter.value.toLowerCase();
+ return availableTags.filter((tag) =>
+ tag.name.toLowerCase().includes(filter)
+ );
+ });
+ const filterByTag = (tag) => {
+ // Use advanced filter instead of text-based
+ filterTags.value = [tag.id];
+ applyAdvancedFilters();
+ };
+ const clearTagFilter = () => {
+ searchQuery.value = "";
+ clearAllFilters();
+ };
+
+ // Build search query from advanced filters
+ const buildSearchQuery = () => {
+ let query = [];
+
+ // Add text search
+ if (filterTextQuery.value.trim()) {
+ query.push(filterTextQuery.value.trim());
+ }
+
+ // Add tag filters
+ if (filterTags.value.length > 0) {
+ const tagNames = filterTags.value
+ .map((tagId) => {
+ const tag = availableTags.value.find((t) => t.id === tagId);
+ return tag ? `tag:${tag.name.replace(/\s+/g, "_")}` : "";
+ })
+ .filter(Boolean);
+ query.push(...tagNames);
+ }
+
+ // Add date filter
+ if (filterDatePreset.value) {
+ query.push(`date:${filterDatePreset.value}`);
+ } else if (filterDateRange.value.start || filterDateRange.value.end) {
+ // Custom date range - send as separate parameters
+ // Will be handled differently in the backend
+ if (filterDateRange.value.start) {
+ query.push(`date_from:${filterDateRange.value.start}`);
+ }
+ if (filterDateRange.value.end) {
+ query.push(`date_to:${filterDateRange.value.end}`);
+ }
+ }
+
+ return query.join(" ");
+ };
+
+ const applyAdvancedFilters = () => {
+ searchQuery.value = buildSearchQuery();
+ };
+
+ const clearAllFilters = () => {
+ filterTags.value = [];
+ filterDateRange.value = { start: "", end: "" };
+ filterDatePreset.value = "";
+ filterTextQuery.value = "";
+ searchQuery.value = "";
+ };
+
+ const editRecordingTags = (recording) => {
+ editingRecording.value = recording;
+ selectedNewTagId.value = "";
+ showEditTagsModal.value = true;
+ };
+
+ const closeEditTagsModal = () => {
+ showEditTagsModal.value = false;
+ editingRecording.value = null;
+ selectedNewTagId.value = "";
+ tagSearchFilter.value = ""; // Clear the filter when closing
+ };
+
+ const addTagToRecording = async (tagId = null) => {
+ // Use provided tagId or fall back to selectedNewTagId
+ const tagToAddId = tagId || selectedNewTagId.value;
+ if (!tagToAddId || !editingRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/api/recordings/${editingRecording.value.id}/tags`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken,
+ },
+ body: JSON.stringify({ tag_id: tagToAddId }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to add tag");
+ }
+
+ // Update local recording data
+ const tagToAdd = availableTags.value.find(
+ (tag) => tag.id == tagToAddId
+ );
+ if (tagToAdd) {
+ if (!editingRecording.value.tags) {
+ editingRecording.value.tags = [];
+ }
+ editingRecording.value.tags.push(tagToAdd);
+
+ // Also update in recordings list if it's a different object
+ const recordingInList = recordings.value.find(
+ (r) => r.id === editingRecording.value.id
+ );
+ if (recordingInList && recordingInList !== editingRecording.value) {
+ if (!recordingInList.tags) {
+ recordingInList.tags = [];
+ }
+ recordingInList.tags.push(tagToAdd);
+ }
+ }
+
+ selectedNewTagId.value = "";
+ } catch (error) {
+ console.error("Error adding tag to recording:", error);
+ setGlobalError(`Failed to add tag: ${error.message}`);
+ }
+ };
+
+ const removeTagFromRecording = async (tagId) => {
+ if (!editingRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/api/recordings/${editingRecording.value.id}/tags/${tagId}`,
+ {
+ method: "DELETE",
+ headers: {
+ "X-CSRFToken": csrfToken,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to remove tag");
+ }
+
+ // Update local recording data
+ editingRecording.value.tags = editingRecording.value.tags.filter(
+ (tag) => tag.id !== tagId
+ );
+
+ // Also update in recordings list if it's a different object
+ const recordingInList = recordings.value.find(
+ (r) => r.id === editingRecording.value.id
+ );
+ if (
+ recordingInList &&
+ recordingInList !== editingRecording.value &&
+ recordingInList.tags
+ ) {
+ recordingInList.tags = recordingInList.tags.filter(
+ (tag) => tag.id !== tagId
+ );
+ }
+ } catch (error) {
+ console.error("Error removing tag from recording:", error);
+ setGlobalError(`Failed to remove tag: ${error.message}`);
+ }
+ };
+
+ //--------------------录音与标签数据加载 end------------------
+
+ //------------------- 录音控制与实时采集 start------------------
+ // --- Audio Recording ---
+ const startRecordingWithDisclaimer = async (mode = "microphone") => {
+ // Check if disclaimer needs to be shown
+ if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) {
+ pendingRecordingMode.value = mode;
+ showRecordingDisclaimerModal.value = true;
+ } else {
+ // No disclaimer configured, proceed directly
+ await startRecordingActual(mode);
+ }
+ };
+
+ const acceptRecordingDisclaimer = async () => {
+ showRecordingDisclaimerModal.value = false;
+ if (pendingRecordingMode.value) {
+ await startRecordingActual(pendingRecordingMode.value);
+ pendingRecordingMode.value = null;
+ }
+ };
+
+ const cancelRecordingDisclaimer = () => {
+ showRecordingDisclaimerModal.value = false;
+ pendingRecordingMode.value = null;
+ };
+
+ const startRecording = startRecordingWithDisclaimer; // Maintain backward compatibility
+ let systemStream = null;
+ let micStream = null;
+
+ const startRecordingActual = async (mode = "microphone") => {
+ recordingMode.value = mode;
+
+ try {
+ // Load tags if not already loaded
+ if (availableTags.value.length === 0) {
+ await loadTags();
+ }
+
+ // Reset state
+ audioChunks.value = [];
+ audioBlobURL.value = null;
+ recordingNotes.value = "";
+ activeStreams.value = [];
+ // Clear previous tag selection and ASR options for fresh recording
+ selectedTags.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+
+ // Reset draft-related state for fresh recording (Bug fix: prevents new recordings from being merged into old drafts)
+ currentDraftId.value = null;
+ isPaused.value = false;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ asrResult.value = '';
+ asrResultOnline.value = '';
+ asrResultOffline.value = '';
+ recordingTime.value = 0;
+ isStoppingRecording.value = false;
+ pendingCorrectionCount = 0;
+ isEditingTranscription.value = false;
+ startNewRealtimeEpoch({ clearTimestampBarrier: true });
+
+ let combinedStream = null;
+
+ // Get microphone stream if needed
+ if (mode === "microphone" || mode === "both") {
+ if (!canRecordAudio.value) {
+ throw new Error(
+ "Microphone recording is not supported by your browser or permission was denied."
+ );
+ }
+ micStream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+ activeStreams.value.push(micStream);
+ showToast("Microphone access granted", "fa-microphone");
+ }
+
+ // Get system audio stream if needed
+ if (mode === "system" || mode === "both") {
+ if (!canRecordSystemAudio.value) {
+ throw new Error(
+ "System audio recording is not supported by your browser."
+ );
+ }
+ try {
+ systemStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: true,
+ video: true, // Request video to enable system audio sharing prompt
+ });
+
+ // Check if the user actually granted audio permission
+ if (systemStream.getAudioTracks().length === 0) {
+ // Stop the video track if it exists, since we didn't get audio
+ systemStream.getVideoTracks().forEach((track) => track.stop());
+ throw new Error(
+ 'System audio permission was not granted. Please ensure you check the "Share system audio" box.'
+ );
+ }
+
+ activeStreams.value.push(systemStream);
+ showToast("System audio access granted", "fa-desktop");
+ } catch (err) {
+ if (mode === "system") {
+ throw err; // Re-throw the original error to be caught by the outer handler
+ } else {
+ // For 'both' mode, fall back to microphone only
+ showToast(
+ "System audio denied, using microphone only",
+ "fa-exclamation-triangle"
+ );
+ mode = "microphone";
+ systemStream = null; // Make sure systemStream is null so it's not used later
+ }
+ }
+ }
+
+ // Combine streams if we have both
+ if (micStream && systemStream) {
+ try {
+ audioContext.value = new (window.AudioContext ||
+ window.webkitAudioContext)();
+
+ const micSource =
+ audioContext.value.createMediaStreamSource(micStream);
+ const systemSource =
+ audioContext.value.createMediaStreamSource(systemStream);
+ const destination =
+ audioContext.value.createMediaStreamDestination();
+
+ micSource.connect(destination);
+ systemSource.connect(destination);
+
+ // Create a new MediaStream with only the audio track from the destination
+ const mixedAudioTrack = destination.stream.getAudioTracks()[0];
+ if (!mixedAudioTrack) {
+ throw new Error("Failed to create mixed audio track");
+ }
+
+ combinedStream = new MediaStream([mixedAudioTrack]);
+
+ // Verify the stream has audio tracks
+ if (combinedStream.getAudioTracks().length === 0) {
+ throw new Error("Combined stream has no audio tracks");
+ }
+
+ console.log(
+ "Successfully created combined audio stream with",
+ combinedStream.getAudioTracks().length,
+ "audio tracks"
+ );
+ showToast(
+ "Recording both microphone and system audio",
+ "fa-microphone"
+ );
+ } catch (error) {
+ console.error("Failed to combine audio streams:", error);
+ // Fallback to system audio only
+ if (audioContext.value) {
+ audioContext.value
+ .close()
+ .catch((e) =>
+ console.error("Error closing AudioContext:", e)
+ );
+ audioContext.value = null;
+ }
+ combinedStream = systemStream;
+ showToast(
+ "Failed to combine audio, using system audio only",
+ "fa-exclamation-triangle"
+ );
+ }
+ } else if (systemStream) {
+ // For system audio only, create a new stream with just the audio tracks
+ const audioTracks = systemStream.getAudioTracks();
+ if (audioTracks.length > 0) {
+ combinedStream = new MediaStream(audioTracks);
+ console.log(
+ "Created system audio stream with",
+ audioTracks.length,
+ "audio tracks"
+ );
+ showToast("Recording system audio only", "fa-desktop");
+ } else {
+ throw new Error("System stream has no audio tracks");
+ }
+ } else if (micStream) {
+ combinedStream = micStream;
+ showToast("Recording microphone only", "fa-microphone");
+ } else {
+ throw new Error("No audio streams available for recording.");
+ }
+
+ // Setup MediaRecorder with optimized settings for transcription
+ const getOptimizedRecorderOptions = () => {
+ // Define transcription-optimized options in order of preference
+ const optionsList = [
+ // Best option: Opus codec at 32kbps (excellent compression for speech)
+ {
+ mimeType: "audio/webm;codecs=opus",
+ audioBitsPerSecond: 32000,
+ description: "Optimized (32kbps Opus)",
+ },
+ // Good option: Opus at 64kbps (slightly higher quality)
+ {
+ mimeType: "audio/webm;codecs=opus",
+ audioBitsPerSecond: 64000,
+ description: "Good quality (64kbps Opus)",
+ },
+ // Fallback 1: WebM with reduced bitrate
+ {
+ mimeType: "audio/webm",
+ audioBitsPerSecond: 64000,
+ description: "Standard WebM (64kbps)",
+ },
+ // Fallback 2: MP4 with reduced bitrate
+ {
+ mimeType: "audio/mp4",
+ audioBitsPerSecond: 64000,
+ description: "Standard MP4 (64kbps)",
+ },
+ // Fallback 3: Just the codec without bitrate
+ {
+ mimeType: "audio/webm;codecs=opus",
+ description: "Opus codec (default bitrate)",
+ },
+ // Fallback 4: Just WebM without bitrate
+ {
+ mimeType: "audio/webm",
+ description: "WebM (default bitrate)",
+ },
+ ];
+
+ // Test each option to find the first supported one
+ for (const options of optionsList) {
+ if (MediaRecorder.isTypeSupported(options.mimeType)) {
+ console.log(
+ `Testing audio recording option: ${options.description} - ${options.mimeType}`
+ );
+ return options;
+ }
+ }
+
+ // Final fallback: no options (browser default)
+ console.log("Using browser default audio recording settings");
+ return null;
+ };
+
+ // Try to create MediaRecorder with progressive fallbacks
+ let mediaRecorderCreated = false;
+ let recorderOptions = getOptimizedRecorderOptions();
+ let attemptCount = 0;
+
+ while (!mediaRecorderCreated && attemptCount < 5) {
+ try {
+ attemptCount++;
+
+ if (recorderOptions && attemptCount === 1) {
+ // First attempt: try with full options
+ console.log(
+ `Attempt ${attemptCount}: Trying ${recorderOptions.description}`
+ );
+ mediaRecorder.value = new MediaRecorder(
+ combinedStream,
+ recorderOptions
+ );
+ actualBitrate.value =
+ recorderOptions.audioBitsPerSecond || 64000;
+ showToast(
+ `Recording: ${recorderOptions.description}`,
+ "fa-compress-alt",
+ 3000
+ );
+ } else if (
+ recorderOptions &&
+ attemptCount === 2 &&
+ recorderOptions.audioBitsPerSecond
+ ) {
+ // Second attempt: try same mime type without bitrate constraint
+ console.log(
+ `Attempt ${attemptCount}: Trying ${recorderOptions.mimeType} without bitrate`
+ );
+ mediaRecorder.value = new MediaRecorder(combinedStream, {
+ mimeType: recorderOptions.mimeType,
+ });
+ actualBitrate.value = 128000; // Estimate
+ showToast(
+ `Recording: ${recorderOptions.mimeType} (default bitrate)`,
+ "fa-compress-alt",
+ 3000
+ );
+ } else {
+ // Final attempt: browser default
+ console.log(`Attempt ${attemptCount}: Using browser default`);
+ mediaRecorder.value = new MediaRecorder(combinedStream);
+ actualBitrate.value = 128000; // Estimate browser default
+ showToast(
+ "Recording with browser default settings",
+ "fa-microphone",
+ 3000
+ );
+ }
+
+ mediaRecorderCreated = true;
+ console.log(
+ `MediaRecorder created successfully on attempt ${attemptCount}`
+ );
+ } catch (error) {
+ console.warn(
+ `MediaRecorder creation attempt ${attemptCount} failed:`,
+ error
+ );
+ mediaRecorder.value = null;
+
+ if (attemptCount >= 5) {
+ throw new Error(
+ `Failed to create MediaRecorder after ${attemptCount} attempts. Last error: ${error.message}`
+ );
+ }
+ }
+ }
+
+ if (!mediaRecorder.value) {
+ throw new Error(
+ "Failed to create MediaRecorder with any configuration"
+ );
+ }
+
+ console.log(
+ `Recording with estimated bitrate: ${actualBitrate.value} bps`
+ );
+
+ mediaRecorder.value.ondataavailable = (event) =>
+ audioChunks.value.push(event.data);
+ mediaRecorder.value.onstop = () => {
+ const audioBlob = new Blob(audioChunks.value, {
+ type: "audio/webm",
+ });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+
+ // Stop size monitoring
+ stopSizeMonitoring();
+
+ // Stop all active streams
+ activeStreams.value.forEach((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ });
+ activeStreams.value = [];
+
+ if (audioContext.value) {
+ audioContext.value
+ .close()
+ .catch((e) => console.error("Error closing AudioContext:", e));
+ audioContext.value = null;
+ }
+ cancelAnimationFrame(animationFrameId.value);
+ clearInterval(recordingInterval.value);
+ };
+
+ // --- Visualizer Setup ---
+ if (!audioContext.value) {
+ audioContext.value = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ }
+
+ if (mode === "both" && micStream && systemStream) {
+ // Dual visualizer setup
+ micAnalyser.value = audioContext.value.createAnalyser();
+ micAnalyser.value.fftSize = 256;
+ const micSource =
+ audioContext.value.createMediaStreamSource(micStream);
+ micSource.connect(micAnalyser.value);
+
+ systemAnalyser.value = audioContext.value.createAnalyser();
+ systemAnalyser.value.fftSize = 256;
+ const systemSource =
+ audioContext.value.createMediaStreamSource(systemStream);
+ systemSource.connect(systemAnalyser.value);
+ } else {
+ // Single visualizer setup
+ const visualizerStream = micStream || systemStream;
+ if (visualizerStream) {
+ analyser.value = audioContext.value.createAnalyser();
+ analyser.value.fftSize = 256;
+ const source =
+ audioContext.value.createMediaStreamSource(visualizerStream);
+ source.connect(analyser.value);
+ }
+ }
+
+ // Start recording and timer
+ mediaRecorder.value.start();
+ isRecording.value = true;
+ recordingTime.value = 0;
+ recordingInterval.value = setInterval(
+ () => recordingTime.value++,
+ 1000
+ );
+
+ // Start size monitoring
+ startSizeMonitoring();
+
+ // Switch to recording view
+ currentView.value = "recording";
+
+ // Start visualizer(s)
+ drawVisualizers();
+
+ setGlobalError(null);
+ } catch (err) {
+ console.error("Error starting recording:", err);
+ setGlobalError(`Could not start recording: ${err.message}`);
+ isRecording.value = false;
+
+ // Clean up any streams that were created
+ activeStreams.value.forEach((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ });
+ activeStreams.value = [];
+ }
+ };
+
+ const stopRecording = async () => {
+ if (isStoppingRecording.value) {
+ return;
+ }
+
+ const hasRecordingSession =
+ isRecording.value ||
+ isPaused.value ||
+ audioChunks.value.length > 0 ||
+ !!currentDraftId.value ||
+ activeStreams.value.length > 0;
+
+ if (!hasRecordingSession) {
+ return;
+ }
+
+ isStoppingRecording.value = true;
+ try {
+ isRec = false;
+
+ if (mediaRecorder.value && isRecording.value) {
+ is_final.value = true;
+ mediaRecorder.value.stop();
+ isRecording.value = false;
+ stopSizeMonitoring();
+ cancelAnimationFrame(animationFrameId.value);
+ animationFrameId.value = null;
+ // 停止录音计时器
+ if (recordingInterval.value) {
+ clearInterval(recordingInterval.value);
+ recordingInterval.value = null;
+ }
+ }
+
+ const finishPromises = [];
+ if (webReader && typeof webReader.wsFinish === "function") {
+ finishPromises.push(webReader.wsFinish(3500));
+ } else if (webReader) {
+ webReader.wsStop();
+ }
+ if (webReader2 && typeof webReader2.wsFinish === "function") {
+ finishPromises.push(webReader2.wsFinish(3500));
+ } else if (webReader2) {
+ webReader2.wsStop();
+ }
+ if (finishPromises.length > 0) {
+ await Promise.allSettled(finishPromises);
+ }
+
+ // Wait for MediaRecorder to emit the final blob.
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // 如果是草稿模式,上传最后一个片段但不要 finalize
+ if (currentDraftId.value) {
+ try {
+ // 上传最后一个片段 (only if not already paused/uploaded)
+ if (!isPaused.value && audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ const formData = new FormData();
+ formData.append('audio', audioBlob);
+ formData.append('duration', recordingTime.value - pausedDuration.value);
+ formData.append('transcription', asrResult.value + asrResultOffline.value + asrResultOnline.value || '');
+
+ await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
+ method: 'POST',
+ body: formData
+ });
+
+ // 清理 audioChunks 防止重复
+ audioChunks.value = [];
+ }
+
+ // 合并音频片段并获取预览 URL (不 finalize,只是下载合并后的音频用于预览)
+ const audioResp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/download`);
+ if (audioResp.ok) {
+ const audioBlob = await audioResp.blob();
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ }
+
+ // 保持 currentDraftId 状态,以便用户选择上传或放弃
+ isPaused.value = false;
+
+ } catch (err) {
+ console.error('Error saving final segment:', err);
+ setGlobalError(`保存片段失败: ${err.message}`);
+ }
+ } else {
+ // 非草稿模式(直接录音),创建 audioBlobURL
+ if (audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ }
+ }
+
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } finally {
+ isStoppingRecording.value = false;
+ }
+ };
+
+ // 暂停录音
+ const pauseRecording = async () => {
+ if (!isRecording.value || isPaused.value) return;
+
+ try {
+ // 首先停止音频数据发送,防止向已关闭的 WebSocket 发送数据
+ isRec = false;
+
+ // 停止 WebSocket 转录
+ if (webReader) webReader.wsStop();
+ if (webReader2) webReader2.wsStop();
+
+ // 停止当前 MediaRecorder
+ if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
+ mediaRecorder.value.stop();
+ }
+
+ // 等待 MediaRecorder 生成最终数据
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // 如果处于编辑模式,先退出编辑模式
+ if (isEditingTranscription.value) {
+ exitEditMode();
+ }
+
+ // 等待所有进行中的LLM矫正完成(最多8秒超时)
+ if (pendingCorrectionCount > 0) {
+ console.log(`等待 ${pendingCorrectionCount} 个LLM矫正完成...`);
+ const waitStart = Date.now();
+ while (pendingCorrectionCount > 0 && Date.now() - waitStart < 8000) {
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+ if (pendingCorrectionCount > 0) {
+ console.warn(`超时:仍有 ${pendingCorrectionCount} 个LLM矫正未完成`);
+ }
+ }
+
+ // 创建草稿(如果是第一次暂停)
+ if (!currentDraftId.value) {
+ const resp = await fetch('/tool/speakr/api/draft/create', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ recording_mode: recordingMode.value,
+ notes: recordingNotes.value
+ })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ currentDraftId.value = data.draft.id;
+ } else {
+ throw new Error(data.error || '创建草稿失败');
+ }
+ }
+
+ // 上传当前片段
+ if (audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ const formData = new FormData();
+ formData.append('audio', audioBlob);
+ formData.append('duration', recordingTime.value - pausedDuration.value);
+ // 发送完整转录文本(历史 + 离线修正 + 当前在线结果)以防止覆盖历史记录
+ const fullTranscription = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
+ formData.append('transcription', fullTranscription);
+
+ await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
+ method: 'POST',
+ body: formData
+ });
+
+ // Clear chunks after upload to prevent duplication
+ audioChunks.value = [];
+ }
+
+ // 更新状态
+ isPaused.value = true;
+ pausedDuration.value = recordingTime.value;
+ pausedTranscription.value = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
+ isRecording.value = false;
+
+ // 停止计时器和动画
+ clearInterval(recordingInterval.value);
+ cancelAnimationFrame(animationFrameId.value);
+ stopSizeMonitoring();
+
+ // 停止所有媒体流
+ activeStreams.value.forEach(stream => {
+ stream.getTracks().forEach(track => track.stop());
+ });
+ activeStreams.value = [];
+ micStream = null;
+ systemStream = null;
+
+ showToast('录音已暂停', 'fa-pause');
+
+ // 刷新草稿列表
+ loadDrafts();
+ } catch (err) {
+ console.error('Error pausing recording:', err);
+ setGlobalError(`暂停录音失败: ${err.message}`);
+ }
+ };
+
+ // 恢复录音
+ const resumeRecording = async () => {
+ if (!isPaused.value || !currentDraftId.value) return;
+
+ try {
+ // 重置音频块
+ audioChunks.value = [];
+
+ // Bug fix: 清空segmentList,防止旧segment与pausedTranscription(历史文本)重复渲染
+ // pausedTranscription已经包含了历史转录,segmentList应该只包含恢复后的新转录
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ pendingCorrectionCount = 0;
+ isEditingTranscription.value = false;
+ startNewRealtimeEpoch({ clearTimestampBarrier: true });
+
+ let combinedStream = null;
+ const mode = recordingMode.value;
+
+ // 重新获取媒体流
+ if (mode === 'microphone' || mode === 'both') {
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ activeStreams.value.push(micStream);
+ }
+
+ if (mode === 'system' || mode === 'both') {
+ try {
+ systemStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: true,
+ video: true
+ });
+ if (systemStream.getAudioTracks().length === 0) {
+ systemStream.getVideoTracks().forEach(track => track.stop());
+ throw new Error('未获取系统音频权限');
+ }
+ activeStreams.value.push(systemStream);
+ } catch (err) {
+ if (mode === 'system') throw err;
+ showToast('系统音频权限被拒绝,仅使用麦克风', 'fa-exclamation-triangle');
+ recordingMode.value = 'microphone';
+ }
+ }
+
+ // 组合音频流
+ if (micStream && systemStream) {
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
+ const micSource = audioContext.value.createMediaStreamSource(micStream);
+ const systemSource = audioContext.value.createMediaStreamSource(systemStream);
+ const destination = audioContext.value.createMediaStreamDestination();
+ micSource.connect(destination);
+ systemSource.connect(destination);
+ combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]);
+ } else if (systemStream) {
+ combinedStream = new MediaStream(systemStream.getAudioTracks());
+ } else if (micStream) {
+ combinedStream = micStream;
+ } else {
+ throw new Error('无可用音频流');
+ }
+
+ // 创建新的 MediaRecorder
+ mediaRecorder.value = new MediaRecorder(combinedStream, { mimeType: 'audio/webm;codecs=opus' });
+ mediaRecorder.value.ondataavailable = (event) => audioChunks.value.push(event.data);
+ mediaRecorder.value.onstop = () => {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ };
+
+ // 重新设置可视化
+ if (!audioContext.value) {
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ if (mode === 'both' && micStream && systemStream) {
+ // Dual visualizer setup
+ micAnalyser.value = audioContext.value.createAnalyser();
+ micAnalyser.value.fftSize = 256;
+ const micSource = audioContext.value.createMediaStreamSource(micStream);
+ micSource.connect(micAnalyser.value);
+
+ systemAnalyser.value = audioContext.value.createAnalyser();
+ systemAnalyser.value.fftSize = 256;
+ const systemSource = audioContext.value.createMediaStreamSource(systemStream);
+ systemSource.connect(systemAnalyser.value);
+ } else {
+ const visualizerStream = micStream || systemStream;
+ if (visualizerStream) {
+ analyser.value = audioContext.value.createAnalyser();
+ analyser.value.fftSize = 256;
+ const source = audioContext.value.createMediaStreamSource(visualizerStream);
+ source.connect(analyser.value);
+ }
+ }
+
+ // 开始录音
+ mediaRecorder.value.start();
+ isPaused.value = false;
+ isRecording.value = true;
+
+ // 恢复计时器(从暂停时间继续)
+ recordingInterval.value = setInterval(() => recordingTime.value++, 1000);
+
+ // 恢复可视化
+ drawVisualizers();
+ startSizeMonitoring();
+
+ // 恢复 WebSocket 转录
+ console.log("Resuming WebSocket connections...");
+ // 设置重置标志,清空实时转录显示
+ resetTranscriptionFlag = true;
+ asrResultOnline.value = '';
+ if (webReader) {
+ const ret = webReader.wsStart();
+ if (ret === 1) {
+ isRec = true;
+ }
+ }
+ // offline/2pass
+ if (webReader2) {
+ try {
+ webReader2.wsStart("offline");
+ isRec = true;
+ } catch(e) {
+ console.warn("Failed to restart offline WS:", e);
+ }
+ }
+
+ showToast('录音已恢复', 'fa-play');
+ } catch (err) {
+ console.error('Error resuming recording:', err);
+ setGlobalError(`恢复录音失败: ${err.message}`);
+ }
+ };
+
+ // 加载草稿列表
+ const loadDrafts = async () => {
+ try {
+ const resp = await fetch('/tool/speakr/api/draft/list');
+ const data = await resp.json();
+ draftRecordings.value = Array.isArray(data) ? data : [];
+ } catch (err) {
+ console.error('Error loading drafts:', err);
+ }
+ };
+
+ // 继续草稿录音
+ const continueDraftRecording = async (draftId) => {
+ try {
+ // 获取草稿详情
+ const resp = await fetch(`/tool/speakr/api/draft/${draftId}`);
+ const draft = await resp.json();
+
+ if (draft.error) {
+ throw new Error(draft.error);
+ }
+
+ // 恢复状态
+ currentDraftId.value = draftId;
+ recordingMode.value = draft.recording_mode || 'microphone';
+ recordingTime.value = Math.round(draft.total_duration || 0);
+ pausedDuration.value = recordingTime.value;
+ recordingNotes.value = draft.notes || '';
+ // Load historical transcription into asrResult
+ console.log('Loading draft transcription:', draft.combined_transcription ? draft.combined_transcription.length : 0, 'chars');
+ asrResult.value = draft.combined_transcription || '';
+ asrResultOnline.value = '';
+ asrResultOffline.value = '';
+ pausedTranscription.value = draft.combined_transcription || '';
+ segmentRenderStartIndex = 0;
+
+ // 设置暂停状态
+ isPaused.value = true;
+ isRecording.value = false;
+
+ // 切换到录音视图
+ currentView.value = 'recording';
+
+ showToast('草稿已加载,点击恢复继续录音', 'fa-folder-open');
+ } catch (err) {
+ console.error('Error continuing draft:', err);
+ setGlobalError(`加载草稿失败: ${err.message}`);
+ }
+ };
+
+ // 删除草稿
+ const deleteDraft = async (draftId) => {
+ if (!confirm('确定要删除这个草稿吗?此操作不可撤销。')) return false;
+
+ try {
+ const resp = await fetch(`/tool/speakr/api/draft/${draftId}`, {
+ method: 'DELETE'
+ });
+ const data = await resp.json();
+
+ if (data.success) {
+ showToast('草稿已删除', 'fa-trash');
+ loadDrafts();
+
+ // 如果删除的是当前草稿,重置状态
+ if (currentDraftId.value === draftId) {
+ currentDraftId.value = null;
+ isPaused.value = false;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentRenderStartIndex = 0;
+ }
+ return true;
+ } else {
+ throw new Error(data.error);
+ }
+ } catch (err) {
+ console.error('Error deleting draft:', err);
+ setGlobalError(`删除草稿失败: ${err.message}`);
+ return false;
+ }
+ };
+
+ // 重命名草稿
+ const startDraftRename = (draft) => {
+ editingDraftId.value = draft.id;
+ editingDraftName.value = draft.name;
+ // 等待 DOM 更新后聚焦输入框
+ nextTick(() => {
+ if (draftNameInput.value && draftNameInput.value[0]) {
+ draftNameInput.value[0].focus();
+ } else if (draftNameInput.value) {
+ draftNameInput.value.focus();
+ }
+ });
+ };
+
+ const saveDraftRename = async (draft) => {
+ if (!editingDraftId.value) return;
+
+ const newName = editingDraftName.value.trim();
+ editingDraftId.value = null; // 退出编辑模式
+
+ if (!newName || newName === draft.name) return;
+
+ try {
+ const resp = await fetch(`/tool/speakr/api/draft/${draft.id}/rename`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newName })
+ });
+ const data = await resp.json();
+
+ if (data.success) {
+ draft.name = newName;
+ showToast('重命名成功', 'fa-check');
+ } else {
+ throw new Error(data.error);
+ }
+ } catch (err) {
+ console.error('Error renaming draft:', err);
+ showToast(`重命名失败: ${err.message}`, 'fa-exclamation-circle');
+ }
+ };
+
+ const uploadRecordedAudio = async () => {
+ // 如果有草稿,先获取合并后的音频文件,然后走统一上传流程
+ if (currentDraftId.value) {
+ try {
+ showToast('Finalizing draft...', 'fa-spinner fa-spin', 0);
+
+ const savedNotes = recordingNotes.value;
+ const savedTags = [...selectedTags.value];
+ const savedAsrOptions = {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ };
+
+ const resp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/finalize`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ notes: savedNotes,
+ tag_ids: savedTags.map((tag) => tag.id || tag),
+ ...savedAsrOptions,
+ }),
+ });
+
+ const data = await resp.json();
+ if (!resp.ok || !data.success || !data.recording) {
+ throw new Error(data.error || 'Finalize draft failed');
+ }
+
+ const recording = data.recording;
+ const existingIndex = recordings.value.findIndex((r) => r.id === recording.id);
+ if (existingIndex === -1) {
+ recordings.value.unshift(recording);
+ totalRecordings.value += 1;
+ } else {
+ recordings.value[existingIndex] = recording;
+ }
+
+ const queueItem = {
+ file: {
+ name: recording.original_filename || recording.title || `draft-${recording.id}.webm`,
+ size: recording.file_size || 0,
+ },
+ notes: savedNotes,
+ tags: savedTags,
+ asrOptions: savedAsrOptions,
+ status: 'queued',
+ recordingId: recording.id,
+ clientId: `reload-draft-${recording.id}`,
+ error: null,
+ willAutoSummarize: true,
+ };
+ uploadQueue.value.push(queueItem);
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ currentDraftId.value = null;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentRenderStartIndex = 0;
+ loadDrafts();
+
+ if (audioBlobURL.value) {
+ URL.revokeObjectURL(audioBlobURL.value);
+ }
+ audioBlobURL.value = null;
+ audioChunks.value = [];
+ isRecording.value = false;
+ recordingNotes.value = '';
+ selectedTagIds.value = [];
+ asrLanguage.value = '';
+ asrMinSpeakers.value = '';
+ asrMaxSpeakers.value = '';
+ recordingTime.value = 0;
+
+ currentView.value = 'upload';
+ selectedRecording.value = null;
+
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+
+ showToast('Draft submitted for processing', 'fa-check-circle');
+ } catch (err) {
+ console.error('Error finalizing draft:', err);
+ setGlobalError(`Upload failed: ${err.message}`);
+ }
+ return;
+ }
+
+ if (!audioBlobURL.value) {
+ setGlobalError("No recorded audio to upload.");
+ return;
+ }
+
+ const previousQueueLength = uploadQueue.value.length;
+
+ const recordedFile = new File(
+ audioChunks.value,
+ createLocalizedRecordingFilename(),
+ { type: "audio/webm" }
+ );
+
+
+ addFilesToQueue([
+ {
+ file: recordedFile,
+ notes: recordingNotes.value,
+ tags: selectedTags.value,
+ asrOptions: {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ },
+ },
+ ]);
+
+
+ if (uploadQueue.value.length === previousQueueLength) {
+ return;
+ }
+
+ const queueItem = uploadQueue.value[uploadQueue.value.length - 1];
+
+ showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0);
+
+ const waitForServerResponse = () => {
+ return new Promise((resolve, reject) => {
+ const checkInterval = setInterval(() => {
+
+ if (!queueItem || queueItem.status === 'failed') {
+ clearInterval(checkInterval);
+ reject(new Error(queueItem?.error || "Upload failed"));
+ return;
+ }
+
+ const successStates = ['pending', 'processing', 'transcribing', 'summarizing', 'completed'];
+ if (successStates.includes(queueItem.status)) {
+ clearInterval(checkInterval);
+ resolve(true);
+ }
+
+ }, 200);
+ });
+ };
+ try {
+
+ await waitForServerResponse();
+
+ discardRecording();
+
+ if (isRec === true) {
+ if (typeof stopRecording === 'function') stopRecording();
+ }
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+
+ showToast("上传成功,正在处理中...", "fa-check-circle");
+
+ } catch (error) {
+ console.error("Server upload failed:", error);
+ showToast(`上传失败: ${error.message || '请检查网络'}`, "fa-exclamation-triangle", 4000);
+ }
+ };
+const downloadRecordedAudio = () => {
+ if (!audioBlobURL.value) {
+ showToast("No recording available to download", "fa-exclamation-circle");
+ return;
+ }
+
+ const filename = createLocalizedRecordingFilename();
+
+ const a = document.createElement('a');
+ a.style.display = 'none';
+ a.href = audioBlobURL.value;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+
+ // 清理
+ setTimeout(() => {
+ document.body.removeChild(a);
+ }, 100);
+
+ showToast("录音已开始下载", "fa-download");
+ };
+ const discardRecording = async () => {
+ if (currentDraftId.value) {
+ const deleted = await deleteDraft(currentDraftId.value);
+ if (!deleted) return;
+ }
+
+ if (audioBlobURL.value) {
+ URL.revokeObjectURL(audioBlobURL.value);
+ }
+ audioBlobURL.value = null;
+ audioChunks.value = [];
+ isRecording.value = false;
+ recordingTime.value = 0;
+ if (recordingInterval.value) clearInterval(recordingInterval.value);
+ recordingNotes.value = "";
+ // Clear tags and ASR options for fresh start
+ selectedTagIds.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+ };
+
+ const drawSingleVisualizer = (analyserNode, canvasElement) => {
+ if (!analyserNode || !canvasElement) return;
+
+ const bufferLength = analyserNode.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+ analyserNode.getByteFrequencyData(dataArray);
+
+ const canvasCtx = canvasElement.getContext("2d");
+ const WIDTH = canvasElement.width;
+ const HEIGHT = canvasElement.height;
+
+ canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
+
+ const barWidth = (WIDTH / bufferLength) * 1.5;
+ let barHeight;
+ let x = 0;
+
+ // Use theme-specific colors that work with all color schemes
+ const buttonColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--bg-button")
+ .trim();
+ const buttonHoverColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--bg-button-hover")
+ .trim();
+
+ // Create gradient that works in both light and dark modes
+ const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
+ if (isDarkMode.value) {
+ // Dark mode: use button colors with transparency
+ gradient.addColorStop(0, buttonColor);
+ gradient.addColorStop(0.6, buttonHoverColor);
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0.2)");
+ } else {
+ // Light mode: use more saturated colors for visibility
+ gradient.addColorStop(0, buttonColor);
+ gradient.addColorStop(0.5, buttonHoverColor);
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0.1)");
+ }
+
+ for (let i = 0; i < bufferLength; i++) {
+ barHeight = dataArray[i] / 2.5;
+ canvasCtx.fillStyle = gradient;
+ canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
+ x += barWidth + 2;
+ }
+ };
+
+ const drawVisualizers = () => {
+ if (!isRecording.value) {
+ if (animationFrameId.value) {
+ cancelAnimationFrame(animationFrameId.value);
+ animationFrameId.value = null;
+ }
+ return;
+ }
+
+ animationFrameId.value = requestAnimationFrame(drawVisualizers);
+
+ if (recordingMode.value === "both") {
+ drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
+ drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
+ } else {
+ drawSingleVisualizer(analyser.value, visualizer.value);
+ }
+ };
+
+ //--------------------录音控制与实时采集 end------------------
+
+ //------------------- 录音保存与详情管理 start------------------
+ // --- Recording Management ---
+ const saveMetadata = async (recordingDataToSave) => {
+ globalError.value = null;
+ if (!recordingDataToSave || !recordingDataToSave.id) return null;
+ console.log("Saving metadata for:", recordingDataToSave.id);
+ try {
+ const payload = {
+ id: recordingDataToSave.id,
+ title: recordingDataToSave.title,
+ participants: recordingDataToSave.participants,
+ notes: recordingDataToSave.notes,
+ summary: recordingDataToSave.summary,
+ meeting_date: recordingDataToSave.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to save metadata");
+
+ console.log("Save successful:", data.recording.id);
+ const index = recordings.value.findIndex(
+ (r) => r.id === data.recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].title = payload.title;
+ recordings.value[index].participants = payload.participants;
+ recordings.value[index].notes = payload.notes;
+ recordings.value[index].notes_html = data.recording.notes_html;
+ recordings.value[index].summary = payload.summary;
+ recordings.value[index].summary_html = data.recording.summary_html;
+ recordings.value[index].meeting_date = payload.meeting_date;
+ }
+ if (selectedRecording.value?.id === data.recording.id) {
+ selectedRecording.value.title = payload.title;
+ selectedRecording.value.participants = payload.participants;
+ selectedRecording.value.notes = payload.notes;
+ selectedRecording.value.notes_html = data.recording.notes_html;
+ selectedRecording.value.summary = payload.summary;
+ selectedRecording.value.summary_html = data.recording.summary_html;
+ selectedRecording.value.meeting_date = payload.meeting_date;
+ }
+ return data.recording;
+ } catch (error) {
+ console.error("Save Metadata Error:", error);
+ setGlobalError(`Save failed: ${error.message}`);
+ return null;
+ }
+ };
+
+ const editRecording = (recording) => {
+ editingRecording.value = JSON.parse(JSON.stringify(recording));
+ showEditModal.value = true;
+ };
+
+ const cancelEdit = () => {
+ showEditModal.value = false;
+ editingRecording.value = null;
+ };
+
+ const saveEdit = async () => {
+ const success = await saveMetadata(editingRecording.value);
+ if (success) {
+ cancelEdit();
+ }
+ };
+
+ const confirmDelete = (recording) => {
+ recordingToDelete.value = recording;
+ showDeleteModal.value = true;
+ };
+
+ const cancelDelete = () => {
+ showDeleteModal.value = false;
+ recordingToDelete.value = null;
+ };
+
+ const deleteRecording = async () => {
+ globalError.value = null;
+ if (!recordingToDelete.value) return;
+ const idToDelete = recordingToDelete.value.id;
+ const titleToDelete = recordingToDelete.value.title;
+ try {
+ const response = await fetch(`/tool/speakr/recording/${idToDelete}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to delete recording");
+
+ recordings.value = recordings.value.filter(
+ (r) => r.id !== idToDelete
+ );
+ totalRecordings.value--; // Update total count
+
+ const queueIndex = uploadQueue.value.findIndex(
+ (item) => item.recordingId === idToDelete
+ );
+ if (queueIndex !== -1) {
+ const deletedItem = uploadQueue.value.splice(queueIndex, 1)[0];
+ console.log(`Removed item ${deletedItem.clientId} from queue.`);
+ if (
+ currentlyProcessingFile.value?.clientId === deletedItem.clientId
+ ) {
+ console.log(
+ `Deleting currently processing file: ${titleToDelete}. Stopping poll and moving to next.`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ }
+
+ if (selectedRecording.value?.id === idToDelete)
+ selectedRecording.value = null;
+ cancelDelete();
+ console.log(
+ `Successfully deleted recording ${idToDelete} (${titleToDelete})`
+ );
+ } catch (error) {
+ console.error("Delete Error:", error);
+ setGlobalError(
+ `Failed to delete recording "${titleToDelete}": ${error.message}`
+ );
+ cancelDelete();
+ }
+ };
+
+ //--------------------录音保存与详情管理 end------------------
+
+ //------------------- 详情页内联编辑 start------------------
+ // --- Inline Editing ---
+ const toggleEditParticipants = () => {
+ editingParticipants.value = !editingParticipants.value;
+ if (!editingParticipants.value) {
+ saveInlineEdit("participants");
+ }
+ };
+
+ const toggleEditMeetingDate = () => {
+ editingMeetingDate.value = !editingMeetingDate.value;
+ if (!editingMeetingDate.value) {
+ saveInlineEdit("meeting_date");
+ }
+ };
+
+ const toggleEditSummary = () => {
+ editingSummary.value = !editingSummary.value;
+ if (editingSummary.value) {
+ // Store current content in temp variable
+ tempSummaryContent.value = selectedRecording.value.summary || "";
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ };
+
+ const cancelEditSummary = () => {
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ editingSummary.value = false;
+ // Restore original content
+ if (selectedRecording.value) {
+ selectedRecording.value.summary = tempSummaryContent.value;
+ }
+ };
+
+ const saveEditSummary = async () => {
+ if (summaryMarkdownEditorInstance.value) {
+ selectedRecording.value.summary =
+ summaryMarkdownEditorInstance.value.value();
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ editingSummary.value = false;
+ await saveInlineEdit("summary");
+ };
+
+ const toggleEditNotes = () => {
+ editingNotes.value = !editingNotes.value;
+ if (editingNotes.value) {
+ // Store current content in temp variable
+ tempNotesContent.value = selectedRecording.value.notes || "";
+ // Initialize markdown editor when entering edit mode
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ };
+
+ const cancelEditNotes = () => {
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ editingNotes.value = false;
+ // Restore original content
+ if (selectedRecording.value) {
+ selectedRecording.value.notes = tempNotesContent.value;
+ }
+ };
+
+ const saveEditNotes = async () => {
+ if (markdownEditorInstance.value) {
+ // Get the markdown content from the editor
+ selectedRecording.value.notes = markdownEditorInstance.value.value();
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ editingNotes.value = false;
+ await saveInlineEdit("notes");
+ };
+
+ const clickToEditNotes = () => {
+ // Allow clicking on empty notes area to start editing
+ if (
+ !editingNotes.value &&
+ (!selectedRecording.value?.notes ||
+ selectedRecording.value.notes.trim() === "")
+ ) {
+ toggleEditNotes();
+ }
+ };
+
+ const clickToEditSummary = () => {
+ // Allow clicking on empty summary area to start editing
+ if (
+ !editingSummary.value &&
+ (!selectedRecording.value?.summary ||
+ selectedRecording.value.summary.trim() === "")
+ ) {
+ toggleEditSummary();
+ }
+ };
+
+ const autoSaveNotes = async () => {
+ if (markdownEditorInstance.value && editingNotes.value) {
+ // Just save the content to the model, don't exit edit mode
+ selectedRecording.value.notes = markdownEditorInstance.value.value();
+ // Silently save to backend without changing UI state
+ try {
+ const payload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (response.ok && data.recording) {
+ // Update the HTML rendered versions if they exist
+ if (data.recording.notes_html) {
+ selectedRecording.value.notes_html = data.recording.notes_html;
+ }
+ } else {
+ console.error("Failed to auto-save notes");
+ }
+ } catch (error) {
+ console.error("Error auto-saving notes:", error);
+ }
+ }
+ };
+
+ const autoSaveSummary = async () => {
+ if (summaryMarkdownEditorInstance.value && editingSummary.value) {
+ // Just save the content to the model, don't exit edit mode
+ selectedRecording.value.summary =
+ summaryMarkdownEditorInstance.value.value();
+ // Silently save to backend without changing UI state
+ try {
+ const payload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (response.ok && data.recording) {
+ // Update the HTML rendered versions if they exist
+ if (data.recording.summary_html) {
+ selectedRecording.value.summary_html =
+ data.recording.summary_html;
+ }
+ } else {
+ console.error("Failed to auto-save summary");
+ }
+ } catch (error) {
+ console.error("Error auto-saving summary:", error);
+ }
+ }
+ };
+
+ const initializeMarkdownEditor = () => {
+ if (!notesMarkdownEditor.value) return;
+
+ try {
+ markdownEditorInstance.value = new EasyMDE({
+ element: notesMarkdownEditor.value,
+ spellChecker: false,
+ autofocus: true,
+ placeholder: "以Markdown格式输入笔记...",
+ initialValue: selectedRecording.value?.notes || "",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "image",
+ "|",
+ "preview",
+ "side-by-side",
+ "fullscreen",
+ "|",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ });
+
+ // Add auto-save functionality
+ markdownEditorInstance.value.codemirror.on("change", () => {
+ if (autoSaveTimer.value) {
+ clearTimeout(autoSaveTimer.value);
+ }
+ autoSaveTimer.value = setTimeout(() => {
+ autoSaveNotes();
+ }, autoSaveDelay);
+ });
+ } catch (error) {
+ console.error("Failed to initialize markdown editor:", error);
+ // Fallback to regular textarea editing
+ editingNotes.value = true;
+ }
+ };
+ const getBaseInfo = async () => {
+ try {
+ const response = await fetch(`getUserConfig`, {
+ method: "GET",
+ });
+ const data = await response.json();
+ if (data.LEADER_SPEECH_BUTTON) {
+ baseInfo.value = {
+ name: data.LEADER_SPEECH_BUTTON,
+ fontSize: data.LEADER_SPEECH_TEXT_SIZE,
+ };
+ }
+ configwords = data.END_PHRASE_PATTERNS || "";
+ } catch (error) {
+ console.error("获取基础配置出错:", error);
+ configwords = "";
+ }
+ };
+ const initializeRecordingMarkdownEditor = () => {
+ if (!recordingNotesEditor.value) {
+ console.log("Recording notes editor ref not found");
+ return;
+ }
+
+ // Check if EasyMDE is available
+ if (typeof EasyMDE === "undefined") {
+ console.error("EasyMDE is not loaded");
+ return;
+ }
+
+ // Clean up existing instance if any
+ if (recordingMarkdownEditorInstance.value) {
+ recordingMarkdownEditorInstance.value.toTextArea();
+ recordingMarkdownEditorInstance.value = null;
+ }
+
+ try {
+ console.log("Initializing recording markdown editor");
+ recordingMarkdownEditorInstance.value = new EasyMDE({
+ element: recordingNotesEditor.value,
+ spellChecker: false,
+ autofocus: false,
+ placeholder: "Type your notes in Markdown format...",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "|",
+ "preview",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ initialValue: recordingNotes.value || "",
+ maxHeight: "300px", // Add height constraint to prevent unlimited growth
+ minHeight: "150px", // Minimum height for usability
+ });
+
+ // Sync changes back to the reactive variable
+ recordingMarkdownEditorInstance.value.codemirror.on("change", () => {
+ recordingNotes.value =
+ recordingMarkdownEditorInstance.value.value();
+ });
+
+ console.log("Recording markdown editor initialized successfully");
+ } catch (error) {
+ console.error(
+ "Failed to initialize recording markdown editor:",
+ error
+ );
+ // Keep as regular textarea if EasyMDE fails
+ }
+ };
+ // 识别启动、停止、清空操作
+
+ const toggleTranscriptionViewMode = () => {
+ transcriptionViewMode.value =
+ transcriptionViewMode.value === "simple" ? "bubble" : "simple";
+ localStorage.setItem(
+ "transcriptionViewMode",
+ transcriptionViewMode.value
+ );
+ };
+
+ const toggleChatMaximize = () => {
+ if (isChatMaximized.value) {
+ // If maximized, restore to normal state
+ isChatMaximized.value = false;
+ } else {
+ // If not maximized, maximize and ensure chat is open
+ isChatMaximized.value = true;
+ if (!showChat.value) {
+ showChat.value = true;
+ }
+ }
+ };
+
+ const saveInlineEdit = async (field) => {
+ if (!selectedRecording.value) return;
+
+ const fullPayload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+
+ try {
+ const updatedRecording = await saveMetadata(fullPayload);
+ if (updatedRecording) {
+ if (field === "notes") {
+ selectedRecording.value.notes_html = updatedRecording.notes_html;
+ } else if (field === "summary") {
+ selectedRecording.value.summary_html =
+ updatedRecording.summary_html;
+ }
+
+ switch (field) {
+ case "participants":
+ editingParticipants.value = false;
+ break;
+ case "meeting_date":
+ editingMeetingDate.value = false;
+ break;
+ case "summary":
+ editingSummary.value = false;
+ break;
+ case "notes":
+ editingNotes.value = false;
+ break;
+ }
+ showToast(
+ `${
+ field.charAt(0).toUpperCase() + field.slice(1).replace("_", " ")
+ } updated successfully`
+ );
+ }
+ } catch (error) {
+ console.error(`Save ${field} Error:`, error);
+ setGlobalError(`Failed to save ${field}: ${error.message}`);
+ }
+ };
+
+ //--------------------详情页内联编辑 end------------------
+
+ //------------------- 聊天问答功能 start------------------
+ // --- Chat Functionality ---
+ // Helper function to check if chat is scrolled to bottom (within bottom 5%)
+ const isChatScrolledToBottom = () => {
+ if (!chatMessagesRef.value) return true;
+ const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
+ const scrollableHeight = scrollHeight - clientHeight;
+ if (scrollableHeight <= 0) return true; // No scrolling possible
+ const scrollPercentage = scrollTop / scrollableHeight;
+ return scrollPercentage >= 0.95; // Within bottom 5%
+ };
+
+ // Helper function to scroll chat to bottom with smooth behavior
+ const scrollChatToBottom = () => {
+ if (chatMessagesRef.value) {
+ requestAnimationFrame(() => {
+ if (chatMessagesRef.value) {
+ chatMessagesRef.value.scrollTop =
+ chatMessagesRef.value.scrollHeight;
+ }
+ });
+ }
+ };
+
+ const sendChatMessage = async () => {
+ if (
+ !chatInput.value.trim() ||
+ isChatLoading.value ||
+ !selectedRecording.value ||
+ selectedRecording.value.status !== "COMPLETED"
+ ) {
+ return;
+ }
+
+ const message = chatInput.value.trim();
+
+ if (!Array.isArray(chatMessages.value)) {
+ chatMessages.value = [];
+ }
+
+ chatMessages.value.push({ role: "user", content: message });
+ chatInput.value = "";
+ isChatLoading.value = true;
+
+ await nextTick();
+ // Always scroll to bottom when user sends a new message
+ scrollChatToBottom();
+
+ let assistantMessage = null;
+
+ try {
+ const messageHistory = chatMessages.value
+ .slice(0, -1)
+ .map((msg) => ({ role: msg.role, content: msg.content }));
+
+ const response = await fetch("/tool/speakr/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ recording_id: selectedRecording.value.id,
+ message: message,
+ message_history: messageHistory,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to get chat response");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ const processStream = async () => {
+ let isFirstChunk = true;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop();
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ const jsonStr = line.substring(6);
+ if (jsonStr) {
+ try {
+ const data = JSON.parse(jsonStr);
+ if (data.thinking) {
+ // Check scroll position BEFORE updating content
+ const shouldScroll = isChatScrolledToBottom();
+
+ if (isFirstChunk) {
+ isChatLoading.value = false;
+ assistantMessage = reactive({
+ role: "assistant",
+ content: "",
+ html: "",
+ thinking: data.thinking,
+ thinkingExpanded: false,
+ });
+ chatMessages.value.push(assistantMessage);
+ isFirstChunk = false;
+ } else if (assistantMessage) {
+ // Append to existing thinking content
+ if (assistantMessage.thinking) {
+ assistantMessage.thinking += "\n\n" + data.thinking;
+ } else {
+ assistantMessage.thinking = data.thinking;
+ }
+ }
+
+ // Scroll if we were at bottom before the update
+ if (shouldScroll) {
+ await nextTick();
+ scrollChatToBottom();
+ }
+ }
+ if (data.delta) {
+ // Check scroll position BEFORE updating content
+ const shouldScroll = isChatScrolledToBottom();
+
+ if (isFirstChunk) {
+ isChatLoading.value = false;
+ assistantMessage = reactive({
+ role: "assistant",
+ content: "",
+ html: "",
+ thinking: "",
+ thinkingExpanded: false,
+ });
+ chatMessages.value.push(assistantMessage);
+ isFirstChunk = false;
+ }
+
+ assistantMessage.content += data.delta;
+ assistantMessage.html = marked.parse(
+ assistantMessage.content
+ );
+
+ // Scroll if we were at bottom before the update
+ if (shouldScroll) {
+ await nextTick();
+ scrollChatToBottom();
+ }
+ }
+ if (data.end_of_stream) {
+ return;
+ }
+ if (data.error) {
+ throw new Error(data.error);
+ }
+ } catch (e) {
+ console.error("Error parsing stream data:", e);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ await processStream();
+ } catch (error) {
+ console.error("Chat Error:", error);
+ if (assistantMessage) {
+ assistantMessage.content = `Error: ${error.message}`;
+ assistantMessage.html = `Error: ${error.message} `;
+ } else {
+ chatMessages.value.push({
+ role: "assistant",
+ content: `Error: ${error.message}`,
+ html: `Error: ${error.message} `,
+ });
+ }
+ } finally {
+ isChatLoading.value = false;
+ await nextTick();
+ // Final scroll only if user is at bottom
+ if (isChatScrolledToBottom()) {
+ scrollChatToBottom();
+ }
+ }
+ };
+
+ //--------------------聊天问答功能 end------------------
+
+ //------------------- 分栏拖拽与尺寸持久化 start------------------
+ // --- Column Resizing ---
+ const startColumnResize = (event) => {
+ isResizing.value = true;
+ const startX = event.clientX;
+ const startLeftWidth = leftColumnWidth.value;
+
+ const handleMouseMove = (e) => {
+ if (!isResizing.value) return;
+
+ const container = document.getElementById("mainContentColumns");
+ if (!container) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const deltaX = e.clientX - startX;
+ const containerWidth = containerRect.width;
+ const deltaPercent = (deltaX / containerWidth) * 100;
+
+ let newLeftWidth = startLeftWidth + deltaPercent;
+ newLeftWidth = Math.max(20, Math.min(80, newLeftWidth)); // Constrain between 20% and 80%
+
+ leftColumnWidth.value = newLeftWidth;
+ rightColumnWidth.value = 100 - newLeftWidth;
+ };
+
+ const handleMouseUp = () => {
+ isResizing.value = false;
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+
+ // Save to localStorage
+ localStorage.setItem("transcriptColumnWidth", leftColumnWidth.value);
+ localStorage.setItem("summaryColumnWidth", rightColumnWidth.value);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ event.preventDefault();
+ };
+
+ //--------------------分栏拖拽与尺寸持久化 end------------------
+
+ //------------------- 聊天输入交互 start------------------
+ // --- Chat Input Handling ---
+ const handleChatKeydown = (event) => {
+ if (event.key === "Enter") {
+ if (event.ctrlKey || event.shiftKey) {
+ // Ctrl+Enter or Shift+Enter: add new line (default behavior)
+ return;
+ } else {
+ // Enter: send message
+ event.preventDefault();
+ sendChatMessage();
+ }
+ }
+ };
+
+ //--------------------聊天输入交互 end------------------
+
+ //------------------- 音频播放器辅助逻辑 start------------------
+ // --- Audio Player ---
+ const seekAudio = (time, context = "main") => {
+ let audioPlayer = null;
+ if (context === "modal") {
+ // The audio player in the modal has the class directly on the audio element
+ audioPlayer = document.querySelector(
+ "audio.speaker-modal-transcript"
+ );
+ } else {
+ audioPlayer = document.querySelector(".main-content-area audio");
+ }
+
+ if (audioPlayer) {
+ const wasPlaying = !audioPlayer.paused;
+ audioPlayer.currentTime = time;
+ if (wasPlaying) {
+ audioPlayer.play();
+ }
+ } else {
+ console.warn(`Audio player not found for context: ${context}`);
+ // Fallback to old method if new one fails
+ const oldPlayer = document.querySelector("audio");
+ if (oldPlayer) {
+ const wasPlaying = !oldPlayer.paused;
+ oldPlayer.currentTime = time;
+ if (wasPlaying) {
+ oldPlayer.play();
+ }
+ }
+ }
+ };
+
+ const seekAudioFromEvent = (event) => {
+ const segmentElement = event.target.closest("[data-start-time]");
+ if (!segmentElement) return;
+
+ const time = parseFloat(segmentElement.dataset.startTime);
+ if (isNaN(time)) return;
+
+ // Determine context by checking if we're inside the speaker modal
+ const isInSpeakerModal =
+ event.target.closest(".speaker-modal-transcript") !== null;
+ const context = isInSpeakerModal ? "modal" : "main";
+
+ seekAudio(time, context);
+ };
+
+ const onPlayerVolumeChange = (event) => {
+ const newVolume = event.target.volume;
+ playerVolume.value = newVolume;
+ localStorage.setItem("playerVolume", newVolume);
+ };
+
+ //--------------------音频播放器辅助逻辑 end------------------
+
+ //------------------- 国际化辅助方法 start------------------
+ // --- i18n Helper Functions ---
+ // Use the same safeT function that's globally available
+ const t = safeT;
+
+ const tc = (key, count, params = {}) => {
+ return window.i18n ? window.i18n.tc(key, count, params) : key;
+ };
+
+ const changeLanguage = async (langCode) => {
+ if (window.i18n) {
+ await window.i18n.setLocale(langCode);
+ currentLanguage.value = langCode;
+ const lang = availableLanguages.value.find(
+ (l) => l.code === langCode
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+ showLanguageMenu.value = false;
+ isUserMenuOpen.value = false;
+
+ // Save preference to backend
+ try {
+ await fetch("/tool/speakr/api/user/preferences", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrfToken.value,
+ },
+ body: JSON.stringify({ language: langCode }),
+ });
+ } catch (error) {
+ console.error("Failed to save language preference:", error);
+ }
+ }
+ };
+
+ //--------------------国际化辅助方法 end------------------
+
+ //------------------- Toast 提示与复制下载反馈 start------------------
+ // --- Toast Notifications ---
+ const showToast = (
+ message,
+ icon = "fa-check-circle",
+ duration = 2000
+ ) => {
+ const toastContainer = document.getElementById("toastContainer");
+
+ const toast = document.createElement("div");
+ toast.className = "toast";
+ toast.innerHTML = ` ${message}`;
+
+ toastContainer.appendChild(toast);
+
+ setTimeout(() => {
+ toast.classList.add("show");
+ }, 10);
+
+ setTimeout(() => {
+ toast.classList.remove("show");
+ setTimeout(() => {
+ toastContainer.removeChild(toast);
+ }, 300);
+ }, duration);
+ };
+
+ const animateCopyButton = (button) => {
+ button.classList.add("copy-success");
+
+ const originalContent = button.innerHTML;
+ button.innerHTML = ' ';
+
+ setTimeout(() => {
+ button.classList.remove("copy-success");
+ button.innerHTML = originalContent;
+ }, 1500);
+ };
+
+ const copyMessage = (text, event) => {
+ const button = event.currentTarget;
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ showToast("Copied to clipboard!");
+ animateCopyButton(button);
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(text, button);
+ });
+ } else {
+ fallbackCopyTextToClipboard(text, button);
+ }
+ };
+
+ const fallbackCopyTextToClipboard = (text, button = null) => {
+ try {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+
+ textArea.focus();
+ textArea.select();
+ const successful = document.execCommand("copy");
+
+ document.body.removeChild(textArea);
+
+ if (successful) {
+ showToast("Copied to clipboard!");
+ if (button) animateCopyButton(button);
+ } else {
+ showToast(
+ "Copy failed. Your browser may not support this feature.",
+ "fa-exclamation-circle"
+ );
+ }
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ showToast("Unable to copy: " + err.message, "fa-exclamation-circle");
+ }
+ };
+
+ const copyTranscription = (event) => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.transcription
+ ) {
+ showToast(
+ "No transcription available to copy.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ const button = event.currentTarget;
+ let textToCopy = "";
+
+ try {
+ const transcriptionData = JSON.parse(
+ selectedRecording.value.transcription
+ );
+ if (Array.isArray(transcriptionData)) {
+ const wasDiarized = transcriptionData.some(
+ (segment) => segment.speaker
+ );
+ if (wasDiarized) {
+ textToCopy = transcriptionData
+ .map((segment) => {
+ const speakerName = segment.speaker;
+ return `[${speakerName}]: ${segment.sentence}`;
+ })
+ .join("\n");
+ } else {
+ textToCopy = transcriptionData
+ .map((segment) => segment.sentence)
+ .join("\n");
+ }
+ } else {
+ textToCopy = selectedRecording.value.transcription;
+ }
+ } catch (e) {
+ textToCopy = selectedRecording.value.transcription;
+ }
+
+ animateCopyButton(button);
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("转录内容已复制到剪贴板!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const copySummary = (event) => {
+ if (!selectedRecording.value || !selectedRecording.value.summary) {
+ showToast("No summary available to copy.", "fa-exclamation-circle");
+ return;
+ }
+ const button = event.currentTarget;
+ const textToCopy = selectedRecording.value.summary;
+ animateCopyButton(button);
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("摘要已复制到剪贴板!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const copyNotes = (event) => {
+ if (!selectedRecording.value || !selectedRecording.value.notes) {
+ showToast("No notes available to copy.", "fa-exclamation-circle");
+ return;
+ }
+ const button = event.currentTarget;
+ const textToCopy = selectedRecording.value.notes;
+ animateCopyButton(button);
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("Notes copied to clipboard!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const downloadSummary = async () => {
+ if (!selectedRecording.value || !selectedRecording.value.summary) {
+ showToast(
+ "No summary available to download.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/summary`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download summary",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "摘要.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Summary downloaded successfully!");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download summary", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadTranscript = async () => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.transcription
+ ) {
+ showToast(
+ "No transcription available to download.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ try {
+ // First, fetch available templates
+ const templatesResponse = await fetch(
+ "/tool/speakr/api/transcript-templates"
+ );
+ let templates = [];
+ if (templatesResponse.ok) {
+ templates = await templatesResponse.json();
+ }
+
+ // If there are templates, show a selection dialog
+ let templateId = null;
+ if (templates.length > 0) {
+ // Create a simple modal for template selection
+ const modal = document.createElement("div");
+ modal.className =
+ "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50";
+ modal.innerHTML = `
+
+
Select Template
+
+ ${templates
+ .map(
+ (t) => `
+
+ ${
+ t.name
+ }
+ ${
+ t.description
+ ? `${t.description}
`
+ : ""
+ }
+ ${
+ t.is_default
+ ? ' Default
'
+ : ""
+ }
+
+ `
+ )
+ .join("")}
+
+
+ 取消
+ Download without template
+
+
+ `;
+ document.body.appendChild(modal);
+
+ // Wait for user selection
+ await new Promise((resolve) => {
+ modal.querySelectorAll(".template-option").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ templateId = btn.dataset.templateId;
+ modal.remove();
+ resolve();
+ });
+ });
+
+ modal
+ .querySelector(".cancel-btn")
+ .addEventListener("click", () => {
+ templateId = "cancelled"; // Mark as cancelled
+ modal.remove();
+ resolve();
+ });
+
+ modal
+ .querySelector(".download-without-template-btn")
+ .addEventListener("click", () => {
+ templateId = "none"; // Use 'none' to indicate no template
+ modal.remove();
+ resolve();
+ });
+
+ modal.addEventListener("click", (e) => {
+ if (e.target === modal) {
+ templateId = "cancelled"; // Mark as cancelled when clicking outside
+ modal.remove();
+ resolve();
+ }
+ });
+ });
+
+ if (
+ templateId === null ||
+ templateId === undefined ||
+ templateId === "cancelled"
+ ) {
+ // User cancelled
+ return;
+ }
+ }
+
+ // Download the transcript with the selected template
+ const url = templateId && templateId !== "none"
+ ? `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}`
+ : `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=none`;
+
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error("Failed to download transcript");
+ }
+
+ const blob = await response.blob();
+ const contentDisposition = response.headers.get(
+ "content-disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "转录稿.docx"
+ );
+
+ // Create download link
+ const downloadUrl = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = downloadUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(downloadUrl);
+
+ showToast("Transcript downloaded successfully!");
+ } catch (error) {
+ console.error("Error downloading transcript:", error);
+ showToast("Failed to download transcript", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadNotes = async () => {
+ if (!selectedRecording.value || !selectedRecording.value.notes) {
+ showToast("No notes available to download.", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/notes`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download notes",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "笔记.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Notes downloaded successfully!", "fa-check-circle");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download notes", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadEventICS = async (event) => {
+ if (!event || !event.id) {
+ showToast("Invalid event data", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/event/${event.id}/ics`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download event",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `${event.title || "event"}.ics`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast(
+ `Event "${event.title}" downloaded. Open the file to add to your calendar.`,
+ "fa-calendar-check",
+ 3000
+ );
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download event", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadICS = async () => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.events ||
+ selectedRecording.value.events.length === 0
+ ) {
+ showToast("No events to export", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${selectedRecording.value.id}/events/ics`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to export events",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `events-${
+ selectedRecording.value.title || selectedRecording.value.id
+ }.ics`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast(
+ `Exported ${selectedRecording.value.events.length} events`,
+ "fa-calendar-check"
+ );
+ } catch (error) {
+ console.error("Download all events ICS error:", error);
+ showToast("Failed to export events", "fa-exclamation-circle");
+ }
+ };
+
+ const formatEventDateTime = (dateTimeStr) => {
+ if (!dateTimeStr) return "";
+ try {
+ const date = new Date(dateTimeStr);
+ const options = {
+ weekday: "short",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ };
+ return date.toLocaleString(undefined, options);
+ } catch (e) {
+ return dateTimeStr;
+ }
+ };
+
+ const downloadChat = async () => {
+ if (!selectedRecording.value || chatMessages.value.length === 0) {
+ showToast("No chat messages to download.", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/chat`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken.value,
+ },
+ body: JSON.stringify({
+ messages: chatMessages.value,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download chat",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "对话记录.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Notes downloaded successfully!");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download notes", "fa-exclamation-circle");
+ }
+ };
+
+ const clearChat = () => {
+ if (chatMessages.value.length > 0) {
+ chatMessages.value = [];
+ showToast("Chat cleared", "fa-broom");
+ }
+ };
+
+ const openShareModal = async (recording) => {
+ recordingToShare.value = recording;
+ shareOptions.share_summary = true;
+ shareOptions.share_notes = true;
+ generatedShareLink.value = "";
+ existingShareDetected.value = false;
+ showShareModal.value = true;
+
+ // Check for existing share
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${recording.id}/share`,
+ {
+ method: "GET",
+ }
+ );
+ const data = await response.json();
+ if (response.ok && data.exists) {
+ generatedShareLink.value = data.share_url;
+ existingShareDetected.value = true;
+ shareOptions.share_summary = data.share.share_summary;
+ shareOptions.share_notes = data.share.share_notes;
+ }
+ } catch (error) {
+ console.error("Error checking for existing share:", error);
+ }
+ };
+
+ const closeShareModal = () => {
+ showShareModal.value = false;
+ recordingToShare.value = null;
+ existingShareDetected.value = false;
+ };
+
+ const createShare = async (forceNew = false) => {
+ if (!recordingToShare.value) return;
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${recordingToShare.value.id}/share`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...shareOptions,
+ force_new: forceNew,
+ }),
+ }
+ );
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to create share link");
+
+ generatedShareLink.value = data.share_url;
+ existingShareDetected.value = data.existing && !forceNew;
+
+ if (data.existing && !forceNew) {
+ // Show that we're using an existing share
+ showToast("Using existing share link", "fa-link");
+ } else {
+ showToast("Share link created successfully!", "fa-check-circle");
+ }
+ } catch (error) {
+ setGlobalError(`Failed to create share link: ${error.message}`);
+ }
+ };
+
+ const copyShareLink = () => {
+ if (!generatedShareLink.value) return;
+ navigator.clipboard.writeText(generatedShareLink.value).then(() => {
+ showToast("Share link copied to clipboard!");
+ });
+ };
+
+ const openSharesList = async () => {
+ isLoadingShares.value = true;
+ showSharesListModal.value = true;
+ try {
+ const response = await fetch("/tool/speakr/api/shares");
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to load shared items");
+ userShares.value = data;
+ } catch (error) {
+ setGlobalError(`Failed to load shared items: ${error.message}`);
+ } finally {
+ isLoadingShares.value = false;
+ }
+ };
+
+ const closeSharesList = () => {
+ showSharesListModal.value = false;
+ userShares.value = [];
+ };
+
+ const updateShare = async (share) => {
+ try {
+ const response = await fetch(`/tool/speakr/api/share/${share.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ share_summary: share.share_summary,
+ share_notes: share.share_notes,
+ }),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update share");
+ showToast("Share permissions updated.", "fa-check-circle");
+ } catch (error) {
+ setGlobalError(`Failed to update share: ${error.message}`);
+ }
+ };
+
+ const confirmDeleteShare = (share) => {
+ shareToDelete.value = share;
+ showShareDeleteModal.value = true;
+ };
+
+ const cancelDeleteShare = () => {
+ shareToDelete.value = null;
+ showShareDeleteModal.value = false;
+ };
+
+ const deleteShare = async () => {
+ if (!shareToDelete.value) return;
+ const shareId = shareToDelete.value.id;
+ try {
+ const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to delete share");
+ userShares.value = userShares.value.filter((s) => s.id !== shareId);
+ showToast("Share link deleted successfully.", "fa-check-circle");
+ showShareDeleteModal.value = false;
+ shareToDelete.value = null;
+ } catch (error) {
+ setGlobalError(`Failed to delete share: ${error.message}`);
+ }
+ };
+
+ //--------------------Toast 提示与复制下载反馈 end------------------
+
+ //------------------- 响应式监听与界面联动 start------------------
+ // --- Watchers ---
+ watch(
+ uploadQueue,
+ (newQueue, oldQueue) => {
+ if (
+ newQueue.length === 0 &&
+ oldQueue.length > 0 &&
+ !isProcessingActive.value
+ ) {
+ console.log("Upload queue processing finished.");
+ setTimeout(() => (progressPopupMinimized.value = true), 500);
+ setTimeout(() => {
+ if (
+ completedInQueue.value === totalInQueue.value &&
+ !isProcessingActive.value
+ ) {
+ progressPopupClosed.value = true;
+ }
+ }, 2000);
+ }
+ },
+ { deep: true }
+ );
+
+ // Watch for changes to speakerMap to handle "This is Me" functionality
+ watch(
+ speakerMap,
+ (newSpeakerMap) => {
+ if (!newSpeakerMap) return;
+
+ Object.keys(newSpeakerMap).forEach((speakerId) => {
+ const speakerData = newSpeakerMap[speakerId];
+ if (
+ speakerData.isMe &&
+ currentUserName.value &&
+ !speakerData.name
+ ) {
+ // Automatically fill in the user's name when "This is Me" is checked
+ speakerData.name = currentUserName.value;
+ } else if (
+ !speakerData.isMe &&
+ speakerData.name === currentUserName.value
+ ) {
+ // Clear the name if "This is Me" is unchecked and the name matches the current user
+ speakerData.name = "";
+ }
+ });
+ },
+ { deep: true }
+ );
+
+ // Watch for tab changes to save content properly
+ watch(selectedTab, (newTab, oldTab) => {
+ // Close maximized chat when switching tabs
+ if (isChatMaximized.value) {
+ isChatMaximized.value = false;
+ }
+
+ // Save content when switching away from notes tab but keep editor open
+ if (
+ oldTab === "notes" &&
+ editingNotes.value &&
+ markdownEditorInstance.value
+ ) {
+ // Save the current content from the editor
+ const currentContent = markdownEditorInstance.value.value();
+ selectedRecording.value.notes = currentContent;
+ // Call auto-save instead of saveInlineEdit to keep editor open
+ autoSaveNotes();
+ // Store that we need to recreate the editor when coming back
+ tempNotesContent.value = currentContent;
+ }
+ // Save content when switching away from summary tab but keep editor open
+ if (
+ oldTab === "summary" &&
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value
+ ) {
+ // Save the current content from the editor
+ const currentContent = summaryMarkdownEditorInstance.value.value();
+ selectedRecording.value.summary = currentContent;
+ // Call auto-save instead of saveInlineEdit to keep editor open
+ autoSaveSummary();
+ // Store that we need to recreate the editor when coming back
+ tempSummaryContent.value = currentContent;
+ }
+
+ // Re-initialize editors when switching back to tabs
+ if (newTab === "notes" && editingNotes.value) {
+ // Destroy old instance if exists
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ // Re-initialize the editor in next tick
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ if (newTab === "summary" && editingSummary.value) {
+ // Destroy old instance if exists
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ // Re-initialize the editor in next tick
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ });
+
+ // Watch for mobile tab changes similarly
+ watch(mobileTab, (newTab, oldTab) => {
+ // Save content when switching away from notes tab but keep editor open
+ if (
+ oldTab === "notes" &&
+ editingNotes.value &&
+ markdownEditorInstance.value
+ ) {
+ const currentContent = markdownEditorInstance.value.value();
+ selectedRecording.value.notes = currentContent;
+ autoSaveNotes(); // Use auto-save instead
+ tempNotesContent.value = currentContent;
+ }
+ // Save content when switching away from summary tab but keep editor open
+ if (
+ oldTab === "summary" &&
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value
+ ) {
+ const currentContent = summaryMarkdownEditorInstance.value.value();
+ selectedRecording.value.summary = currentContent;
+ autoSaveSummary(); // Use auto-save instead
+ tempSummaryContent.value = currentContent;
+ }
+
+ // Re-initialize editors when switching back to tabs on mobile
+ if (newTab === "notes" && editingNotes.value) {
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ if (newTab === "summary" && editingSummary.value) {
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ });
+
+ watch(selectedRecording, (newVal, oldVal) => {
+ if (newVal?.id !== oldVal?.id) {
+ // Save any pending edits before switching recordings
+ if (
+ editingNotes.value &&
+ markdownEditorInstance.value &&
+ oldVal?.id
+ ) {
+ selectedRecording.value = oldVal; // Temporarily restore old recording
+ saveEditNotes(); // This will save and cleanup
+ selectedRecording.value = newVal; // Switch back to new recording
+ }
+ if (
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value &&
+ oldVal?.id
+ ) {
+ selectedRecording.value = oldVal; // Temporarily restore old recording
+ saveEditSummary(); // This will save and cleanup
+ selectedRecording.value = newVal; // Switch back to new recording
+ }
+
+ chatMessages.value = [];
+ showChat.value = false;
+ selectedTab.value = "summary";
+
+ editingParticipants.value = false;
+ editingMeetingDate.value = false;
+ editingSummary.value = false;
+ editingNotes.value = false;
+
+ // Fix WebM duration issue by forcing metadata load
+ if (newVal?.id) {
+ nextTick(() => {
+ const audioElements = document.querySelectorAll("audio");
+ audioElements.forEach((audio) => {
+ if (
+ audio.src &&
+ audio.src.includes(`tool/speakr/audio/${newVal.id}`)
+ ) {
+ // For WebM files, we need to seek to end to get duration
+ const fixDuration = () => {
+ if (!isFinite(audio.duration) || audio.duration === 0) {
+ audio.currentTime = 1e101; // Seek to "infinity" to load duration
+ audio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ audio.currentTime = 0;
+ audio.removeEventListener("timeupdate", resetTime);
+ },
+ { once: true }
+ );
+ }
+ };
+
+ // Try to fix duration when metadata loads
+ audio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+
+ // Also try immediately in case metadata is already loaded
+ if (audio.readyState >= 1) {
+ fixDuration();
+ }
+ }
+ });
+ });
+ }
+ }
+ });
+ // 会议记录页面
+ //------------------- 录音视图切换与实时 ASR 管线 start------------------
+ watch(currentView, (newView) => {
+ if (newView === "recording") {
+ // Initialize recording markdown editor when switching to recording view
+ nextTick(() => {
+ initializeRecordingMarkdownEditor();
+ getBaseInfo();
+ is_final.value = false;
+ // 清空上一次的实时转录文本
+ asrResultOnline.value = "";
+ // ===== 清理旧的 Recorder 实例 =====
+ // 根本原因修复:旧 rec 的 onProcess 回调在旧闭包中持续活跃,
+ // 向已停止的旧 wsconnecter 发送数据,导致缓冲区溢出和 WebSocket 未连接刷屏
+ if (rec) {
+ try { rec.close(); } catch(e) { console.warn('关闭旧rec失败:', e); }
+ rec = null;
+ }
+ if (rec2) {
+ try { rec2.close(); } catch(e) { console.warn('关闭旧rec2失败:', e); }
+ rec2 = null;
+ }
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ asrAudioContext = null;
+ }
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ asrAudioContext2 = null;
+ }
+ //语音实时转换相关
+ //online的ws示例
+ var wsconnecter = new WebSocketConnectMethod({
+ msgHandle: getJsonMessage,
+ stateHandle: getConnState,
+ });
+ //offline的ws示例
+ var wsconnecter2 = new WebSocketConnectMethod({
+ msgHandle: getJsonMessage2,
+ stateHandle: getConnState2,
+ });
+ webReader = wsconnecter;
+ webReader2 = wsconnecter2;
+ var audioBlob;
+ var sampleBuf = new Int16Array();
+ var sampleBuf2 = new Int16Array();
+ const asrChunkSize = 960;
+ const asrBufferWarnSamples = 20000;
+ const asrBufferHardLimitSamples = 16000 * 5;
+ var rec_text = "";
+ var offline_text = "";
+ var rec_textOnline = "";
+ var offlineSentenceHandoffText = "";
+ var rec_textoffline = "";
+ //------------------- 实时 ASR 待矫正句子队列 start------------------
+ // pendingSentenceQueue 是 2pass-offline 文本进入正式转录前的等待队列。
+ // 每条 offline 句子先入队,再等待 /api/asr/correct 的 LLM 矫正结果。
+ // LLM 返回后由 finalizePendingSentence 标记 processed,再按队首顺序 flush 到 segmentList。
+ var pendingSentenceQueue = [];
+ let pendingSentenceSequence = 0;
+ var lastOnlineTimestamp = null; // 记录上次在线转录的时间戳
+
+ // extractTimestampMs: FunASR 返回的 timestamp 通常是二维数组或 JSON 字符串。
+ // 这里取最后一段的结束时间,用它判断消息新旧、辅助说话人回填和去重。
+ function extractTimestampMs(timestampPayload) {
+ if (!timestampPayload) {
+ // 没有 timestamp 时无法做时间屏障判断,后续逻辑会把它当作无时间消息处理。
+ return null;
+ }
+
+ try {
+ const timestampData =
+ typeof timestampPayload === "string"
+ ? JSON.parse(timestampPayload)
+ : timestampPayload;
+
+ if (!Array.isArray(timestampData) || timestampData.length === 0) {
+ return null;
+ }
+
+ // FunASR timestamp 的最后一项代表当前句子末尾附近的时间。
+ const lastItem = timestampData[timestampData.length - 1];
+ if (!Array.isArray(lastItem) || lastItem.length === 0) {
+ return null;
+ }
+
+ // 优先取结束时间;部分返回只有 start 时退回取第一个值。
+ const endOrStart =
+ lastItem.length > 1 ? Number(lastItem[1]) : Number(lastItem[0]);
+ return Number.isFinite(endOrStart) ? endOrStart : null;
+ } catch (error) {
+ // timestamp 解析失败时不阻断转写,只放弃基于时间戳的旧消息判断。
+ return null;
+ }
+ }
+
+ // getLatestRealtimeTimestamp: 手动编辑实时转写时需要建立时间屏障。
+ // 这里从正式 segment、pending 队列和 online 临时文本中取最新时间,避免旧消息回灌。
+ function getLatestRealtimeTimestamp() {
+ const latestSegmentTimestamp = segmentList.reduce(
+ (latest, segment) =>
+ Number.isFinite(segment?.timestampMs)
+ ? Math.max(latest, segment.timestampMs)
+ : latest,
+ -1
+ );
+ const latestPendingTimestamp = pendingSentenceQueue.reduce(
+ (latest, item) =>
+ Number.isFinite(item.timestampMs)
+ ? Math.max(latest, item.timestampMs)
+ : latest,
+ -1
+ );
+ const latestOnlineTimestamp = Number.isFinite(lastOnlineTimestamp)
+ ? lastOnlineTimestamp
+ : -1;
+
+ return Math.max(
+ latestSegmentTimestamp,
+ latestPendingTimestamp,
+ latestOnlineTimestamp
+ );
+ }
+
+ // 只保留尾部文本作为 overlap 参照,避免手动编辑后 offline 结果把已编辑内容重复带回来。
+ function getRealtimeCarryoverTail(text, maxLength = 160) {
+ return (text || "").slice(-maxLength);
+ }
+
+ // getSharedRealtimeSuffix: 找原始实时文本和用户编辑后文本之间仍然相同的尾部。
+ // 该函数用于辅助构造 realtimeEditCarryover,减少后续 offline 文本重复拼接。
+ function getSharedRealtimeSuffix(
+ originalText,
+ editedText,
+ maxLength = 80
+ ) {
+ const original = originalText || "";
+ const edited = editedText || "";
+ const maxSuffixLength = Math.min(
+ maxLength,
+ original.length,
+ edited.length
+ );
+
+ for (let size = maxSuffixLength; size >= 1; size--) {
+ const suffix = edited.slice(-size);
+ if (original.endsWith(suffix)) {
+ return suffix;
+ }
+ }
+
+ return "";
+ }
+
+ // buildRealtimeEditCarryover: 用户手动编辑实时转写后,记录一小段尾巴。
+ // 下一条 2pass-offline 到达时,会用这段尾巴裁掉重复内容。
+ function buildRealtimeEditCarryover(baseText) {
+ const tailText = getRealtimeCarryoverTail(baseText);
+ if (tailText.length < 4) {
+ return null;
+ }
+
+ return {
+ tailText,
+ };
+ }
+
+ // trimOfflineTextWithCarryover: 裁掉 offline 文本中与手动编辑尾巴重叠的部分。
+ // 例:用户已编辑到 "今天会议讨论A",offline 又返回 "会议讨论A和B",则只保留 "和B"。
+ function trimOfflineTextWithCarryover(rawText) {
+ const incomingText = rawText || "";
+ if (!realtimeEditCarryover || !incomingText) {
+ // 没有手动编辑 carryover,或当前 offline 文本为空,直接返回原文。
+ return incomingText;
+ }
+
+ const tailText = realtimeEditCarryover.tailText || "";
+ const minOverlapLength = 4;
+ const maxOverlapLength = Math.min(
+ 80,
+ tailText.length,
+ incomingText.length
+ );
+
+ // 从最长 overlap 开始找,优先裁掉最大重复片段,减少重复显示。
+ for (
+ let size = maxOverlapLength;
+ size >= minOverlapLength;
+ size--
+ ) {
+ const overlapText = tailText.slice(-size);
+ const matchIndex = incomingText.lastIndexOf(overlapText);
+ if (matchIndex === -1) {
+ continue;
+ }
+
+ // 找到重叠片段后,只把重叠之后的新内容作为本次 pending 文本。
+ const remainingText = incomingText.slice(
+ matchIndex + overlapText.length
+ );
+ // 更新 carryover 尾巴,让后续 offline 文本继续用最新上下文去重。
+ realtimeEditCarryover.tailText = getRealtimeCarryoverTail(
+ tailText + remainingText
+ );
+ return remainingText;
+ }
+
+ // 没有找到 overlap,说明后续 offline 文本已经和编辑尾巴接不上,停止 carryover 逻辑。
+ realtimeEditCarryover = null;
+ return incomingText;
+ }
+ function clearRealtimePipeline({
+ preserveTimestampBarrier = false,
+ } = {}) {
+ sampleBuf = new Int16Array();
+ sampleBuf2 = new Int16Array();
+ rec_text = "";
+ offline_text = "";
+ rec_textOnline = "";
+ offlineSentenceHandoffText = "";
+ rec_textoffline = "";
+ pendingSentenceQueue = [];
+ pendingSentenceSequence = 0;
+ lastOnlineTimestamp = null;
+ asrResultOnline.value = "";
+ asrResultOffline.value = "";
+
+ if (!preserveTimestampBarrier) {
+ realtimeTimestampBarrierMs = -1;
+ }
+
+ syncRealtimePreview();
+ }
+
+ // isStaleRealtimeMessage: 判断一条 WebSocket 回包是否属于旧录音周期或旧时间段。
+ // 返回 true 时,ensurePendingSentence 会直接丢弃,避免暂停/恢复/手动编辑后的旧消息污染当前文本。
+ function isStaleRealtimeMessage(
+ messageTimestampMs,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ if (messageEpoch !== liveTranscriptionEpoch) {
+ // epoch 不一致表示消息来自上一轮实时转写,必须丢弃。
+ return true;
+ }
+
+ return (
+ // 时间戳小于等于屏障,说明这条消息早于用户编辑/重置点,不能再回灌。
+ Number.isFinite(messageTimestampMs) &&
+ realtimeTimestampBarrierMs >= 0 &&
+ messageTimestampMs <= realtimeTimestampBarrierMs
+ );
+ }
+ clearRealtimePipelineRef = clearRealtimePipeline;
+ getLatestRealtimeTimestampRef = getLatestRealtimeTimestamp;
+ buildRealtimeEditCarryoverRef = buildRealtimeEditCarryover;
+ renderFullTextRef = renderFullText;
+
+ // getPendingSentencePreviewText: pending 队列还未正式落到 segmentList,
+ // 但用户仍需要在实时预览区先看到这些句子。
+ function getPendingSentencePreviewText() {
+ return pendingSentenceQueue
+ .filter((item) => item.epoch === liveTranscriptionEpoch)
+ .map((item) => item.correctedText || item.rawText)
+ .join("");
+ }
+
+ // syncRealtimePreview: 刷新右侧/实时区域的临时展示文本。
+ // 展示顺序:待矫正 offline 句子 + online/offline 交接文本 + 当前 online 临时文本。
+ function syncRealtimePreview() {
+ asrResultOnline.value =
+ getPendingSentencePreviewText() +
+ offlineSentenceHandoffText +
+ rec_textOnline;
+ }
+
+ // findPendingSentenceBySource: 用 sourceText + timestamp + epoch 做幂等去重。
+ // 同一条 offline 句子如果重复返回,直接复用已有 pending/segment,避免重复矫正和重复落句。
+ function findPendingSentenceBySource(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ const normalizedSourceText = sourceText || "";
+ const normalizedTimestamp = pendingTimestamp || "";
+ return (
+ // 先查 pending 队列:句子可能正在等待 LLM 矫正。
+ pendingSentenceQueue.find(
+ (item) =>
+ item.epoch === messageEpoch &&
+ item.sourceText === normalizedSourceText &&
+ item.timestamp === normalizedTimestamp
+ ) ||
+ // 再查正式 segmentList:句子可能已经矫正完成并落入正式转录。
+ segmentList.find(
+ (item) =>
+ item.epoch === messageEpoch &&
+ item.sourceText === normalizedSourceText &&
+ item.timestamp === normalizedTimestamp
+ ) ||
+ null
+ );
+ }
+
+ // ensurePendingSentence: 2pass-offline 文本进入 LLM 矫正前的统一入口。
+ // 它负责:丢弃旧消息、去重、裁掉手动编辑造成的重复尾巴、创建 pending item、刷新预览。
+ // 注意:这里不会正式写入 segmentList,正式落句在 finalizePendingSentence/flushProcessedPendingSentences。
+ function ensurePendingSentence(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ // 先把 FunASR timestamp 归一化为毫秒数,用于旧消息过滤和后续 speaker 对齐。
+ const timestampMs = extractTimestampMs(pendingTimestamp);
+ if (isStaleRealtimeMessage(timestampMs, messageEpoch)) {
+ // 旧录音周期或旧时间屏障之前的消息直接丢弃,不进入 pending 队列。
+ return null;
+ }
+
+ // 同一 sourceText/timestamp/epoch 已经存在时直接复用,保证重复 WebSocket 回包幂等。
+ const existingItem = findPendingSentenceBySource(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch
+ );
+ if (existingItem) {
+ // 返回已有 item,调用方会看到它已请求/已处理状态,从而避免重复请求 LLM。
+ return existingItem;
+ }
+
+ // 裁掉与手动编辑尾巴重叠的部分,得到真正需要进入矫正流程的新文本。
+ const trimmedText = trimOfflineTextWithCarryover(sourceText || "");
+ if (!trimmedText) {
+ // 裁剪后没有新内容,说明这条 offline 文本只是重复片段,不入队。
+ syncRealtimePreview();
+ return null;
+ }
+
+ // pendingItem 是 LLM 矫正流程的最小状态单元。
+ const pendingItem = {
+ id: pendingSentenceSequence++, // 本地递增 ID,LLM 返回时用它定位这条 pending 句子。
+ sourceText: sourceText || "", // FunASR 原始文本,用于去重和保留来源。
+ rawText: trimmedText, // 裁剪后的文本,后续会作为 /api/asr/correct 的输入。
+ timestamp: pendingTimestamp || "", // 原始 timestamp 字符串/对象,用于去重和渲染时断句。
+ timestampMs, // 毫秒时间点,用于旧消息过滤和 offline-speaker 对齐。
+ correctedText: "", // LLM/标准词映射后的文本,finalize 前保持为空。
+ processed: false, // false 表示还不能 flush 到正式 segmentList。
+ correctionRequested: false, // 防止同一句重复发起 LLM 矫正请求。
+ epoch: messageEpoch, // 当前实时转写周期,防止上一轮录音的异步结果混入。
+ };
+ // 新 pending 先进入等待队列,让实时预览能立即显示,同时等待 LLM 矫正结果。
+ pendingSentenceQueue.push(pendingItem);
+ // 刷新临时预览;正式主文本 asrResult 要等 processed 后由 flushProcessedPendingSentences 更新。
+ syncRealtimePreview();
+ return pendingItem;
+ }
+ //--------------------实时 ASR 待矫正句子队列 end------------------
+ function flushProcessedPendingSentences() {
+ let didFlush = false;
+ while (pendingSentenceQueue.length > 0) {
+ if (pendingSentenceQueue[0].epoch !== liveTranscriptionEpoch) {
+ pendingSentenceQueue.shift();
+ continue;
+ }
+ if (!pendingSentenceQueue[0].processed) {
+ break;
+ }
+
+ const item = pendingSentenceQueue.shift();
+ segmentList.push({
+ text: item.correctedText || item.rawText,
+ sourceText: item.sourceText,
+ timestamp: item.timestamp,
+ timestampMs: item.timestampMs,
+ speaker: null,
+ processed: true,
+ epoch: item.epoch,
+ });
+ didFlush = true;
+ }
+
+ if (didFlush) {
+ renderFullText();
+ } else {
+ syncRealtimePreview();
+ }
+ }
+ function finalizePendingSentence(
+ pendingSentenceId,
+ finalText,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ const target = pendingSentenceQueue.find(
+ (item) =>
+ item.id === pendingSentenceId && item.epoch === messageEpoch
+ );
+ if (!target) {
+ return;
+ }
+
+ target.correctedText = finalText || target.rawText || "";
+ target.processed = true;
+ target.correctionRequested = false;
+ flushProcessedPendingSentences();
+ }
+ async function record() {
+ // rec.open(function () {
+ // rec.start();
+ // });
+ // await startRecording();
+ await openMixedStream();
+ }
+ async function record2() {
+ // rec2.open(function () {
+ // rec2.start();
+ // });
+ // await startRecording();
+ await openMixedStream2();
+ }
+ //------------------- 实时 ASR online PCM 发送通道 start------------------
+ // rec 和 rec2 采集的是同一份混合音频,PCM 重采样和 960-sample 切片逻辑一致。
+ // rec 只负责 online 链路:wsconnecter.wsStart() 不传 URL 参数,
+ // wsconnecter.js 会在握手 JSON 中发送 mode:"online",用于页面实时预览文本。
+ // ********************每收到一帧音频时的回调********************
+ function recProcess(
+ buffer, // Recorder 已经采集到的 PCM 缓冲数组,最后一项是本次新增的音频片段
+
+ powerLevel, // 当前音量强度,当前逻辑没有使用
+ bufferDuration, // 已录制的总时长,当前逻辑没有使用
+ bufferSampleRate, // 浏览器采集到的原始采样率,常见是 48000Hz
+ newBufferIdx, // 本次新增音频片段在 buffer 中的起始索引,当前逻辑没有使用
+ asyncEnd // 异步处理结束回调,当前逻辑没有使用
+ ) {
+ // 调试断点:打开浏览器开发者工具时,会在每次音频回调处暂停
+ // 只有处于录音状态时才处理音频,避免停止后旧回调继续发送数据
+ if (isRec === true) {
+ // 取出本次新增的最后一段 PCM 音频数据
+ var data_48k = buffer[buffer.length - 1];
+
+ // Recorder.SampleData 需要传入“音频片段数组”,所以把单段数据包一层数组
+ var array_48k = new Array(data_48k);
+ // 将浏览器原始采样率的音频重采样到 16000Hz,符合 ASR 服务输入要求
+ var data_16k = Recorder.SampleData(
+ array_48k,
+ bufferSampleRate,
+ 16000
+ ).data;
+
+ // 把新来的 16k PCM 数据追加到本地待发送缓冲区
+ sampleBuf = Int16Array.from([...sampleBuf, ...data_16k]);
+
+ // 如果缓冲超过硬限制,说明发送速度跟不上采集速度,需要丢掉最旧的数据
+ if (sampleBuf.length > asrBufferHardLimitSamples) {
+ // 计算本次超过硬限制的 sample 数量;Int16Array 每个 sample 约占 2 字节
+ const overflowSamples =
+ sampleBuf.length - asrBufferHardLimitSamples;
+ console.warn(
+ `⚠️ [Online] 丢弃过量本地音频缓冲: ${overflowSamples} samples`
+ );
+ // 只保留最后 asrBufferHardLimitSamples 个 sample,尽量保留最新的声音
+ sampleBuf = sampleBuf.slice(-asrBufferHardLimitSamples);
+ }
+
+ // 只要缓冲区够一个 ASR 分片,就持续切片发送
+ while (sampleBuf.length >= asrChunkSize) {
+ // 从缓冲区头部取一个固定大小的音频块,发送给在线识别 WebSocket
+ const sendBuf = sampleBuf.slice(0, asrChunkSize);
+ const sendAccepted = wsconnecter.wsSend(sendBuf);
+ // 如果 WebSocket 当前不可发送,保留剩余缓冲,等待下一次 onProcess 再尝试
+ if (!sendAccepted) {
+ break;
+ }
+ // 发送成功后,从本地缓冲中移除已经发送的这一段
+ sampleBuf = sampleBuf.slice(asrChunkSize);
+ }
+
+ // 超过软告警阈值时只打印警告,不丢数据;真正丢弃由上面的硬限制控制
+ if (sampleBuf.length > asrBufferWarnSamples) {
+ console.warn(
+ `⚠️ [Online] 音频缓冲区溢出: ${sampleBuf.length} samples (${(
+ sampleBuf.length / 16000
+ ).toFixed(2)}s)`
+ );
+ }
+ }
+ }
+ //--------------------实时 ASR online PCM 发送通道 end------------------
+ //------------------- 实时 ASR offline PCM 发送通道 start------------------
+ // rec2 与 rec 使用同样的 PCM 切片方式,但发送到独立的 wsconnecter2。
+ // wsconnecter2.wsStart(urls) 会在握手 JSON 中发送 mode:"offline",
+ // 用于 2pass-offline 正式落句、offline-speaker 说话人回填和后续 LLM 矫正。
+ function recProcess2(
+ buffer,
+ powerLevel,
+ bufferDuration,
+ bufferSampleRate,
+ newBufferIdx,
+ asyncEnd
+ ) {
+ if (isRec === true) {
+ var data_48k = buffer[buffer.length - 1];
+
+ var array_48k = new Array(data_48k);
+ var data_16k = Recorder.SampleData(
+ array_48k,
+ bufferSampleRate,
+ 16000
+ ).data;
+
+ sampleBuf2 = Int16Array.from([...sampleBuf2, ...data_16k]);
+
+ if (sampleBuf2.length > asrBufferHardLimitSamples) {
+ const overflowSamples =
+ sampleBuf2.length - asrBufferHardLimitSamples;
+ console.warn(
+ `⚠️ [Offline] 丢弃过量本地音频缓冲: ${overflowSamples} samples`
+ );
+ sampleBuf2 = sampleBuf2.slice(-asrBufferHardLimitSamples);
+ }
+
+ while (sampleBuf2.length >= asrChunkSize) {
+ const sendBuf2 = sampleBuf2.slice(0, asrChunkSize);
+ const sendAccepted = wsconnecter2.wsSend(sendBuf2);
+ if (!sendAccepted) {
+ break;
+ }
+ sampleBuf2 = sampleBuf2.slice(asrChunkSize);
+ }
+
+ if (sampleBuf2.length > asrBufferWarnSamples) {
+ console.warn(
+ `⚠️ [Offline] 音频缓冲区溢出: ${sampleBuf2.length} samples (${(
+ sampleBuf2.length / 16000
+ ).toFixed(2)}s)`
+ );
+ }
+ }
+ }
+ //--------------------实时 ASR offline PCM 发送通道 end------------------
+ function addNewlineAfterPeriod(str) {
+ return str.replace(/[。?!]/g, "$&\n");
+ }
+
+ function handleWithTimestamp(
+ tmptext,
+ tmptime,
+ keywordStr,
+ timeThreshold
+ ) {
+ if (!tmptime || tmptext.length <= 0) return tmptext;
+
+ const keywordList = keywordStr
+ .split(",")
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0);
+
+ const keywordsAsRegex = keywordList.map((k) => new RegExp(k));
+ const segments = tmptext.match(/[^。!?]+[。!?]?/g) || [];
+ // console.log(segments, "segments");
+
+ const jsontime = JSON.parse(tmptime);
+ let char_index = 0;
+ let text_withtime = "";
+
+ // 辅助函数:去除新行开头的标点符号
+ function removeLeadingPunctuation(text) {
+ // 匹配开头的标点符号(中文和英文标点)
+ return text.replace(/^[,。!?、;:,\.!?;:]+/, "");
+ }
+
+ for (let i = 0; i < segments.length; i++) {
+ let seg = segments[i];
+ if (!seg || seg === "undefined") continue;
+
+ let nowTime = jsontime[char_index][0] / 1000;
+
+ if (
+ lastTimer.value > 0 &&
+ nowTime - lastTimer.value > timeThreshold
+ ) {
+ const lastChar = text_withtime.slice(-1);
+ const endsWithPunc = /[。!?]/.test(lastChar);
+ // 换行后,如果新行开头是标点,去除第一个标点
+ let processedSeg = removeLeadingPunctuation(seg);
+ text_withtime +=
+ (endsWithPunc ? "\n" : "。\n") + processedSeg;
+ } else {
+ let hitKeyword = keywordsAsRegex.some((reg) => reg.test(seg));
+ if (hitKeyword) {
+ // 关键词后换行,如果下一行开头是标点,去除第一个标点
+ let processedSeg = removeLeadingPunctuation(seg);
+ // text_withtime += processedSeg + "。" + "\n";
+ } else {
+ text_withtime += seg;
+ }
+ }
+
+ lastTimer.value = nowTime;
+ char_index++;
+ }
+
+ return text_withtime;
+ }
+ function getSpeakerBeforeIndex(endIndex) {
+ let previousSpeaker = null;
+ for (let i = 0; i < endIndex; i++) {
+ if (segmentList[i] && segmentList[i].speaker) {
+ previousSpeaker = segmentList[i].speaker;
+ }
+ }
+ return previousSpeaker;
+ }
+
+ function buildSegmentText(segments, initialSpeaker = null) {
+ let displayText = "";
+ let lastRenderedSpeaker = initialSpeaker;
+
+ segments.forEach((seg) => {
+ if (showSpeaker.value) {
+ if (
+ seg.speaker &&
+ seg.speaker !== lastRenderedSpeaker &&
+ !seg.speaker.includes("SPK")
+ ) {
+ displayText +=
+ (displayText ? "\n" : "") + seg.speaker + ":\n";
+ lastRenderedSpeaker = seg.speaker;
+ } else if (
+ seg.speaker &&
+ seg.speaker !== lastRenderedSpeaker
+ ) {
+ displayText +=
+ (displayText ? "\n" : "") + seg.speaker + ":\n";
+ lastRenderedSpeaker = seg.speaker;
+ }
+ }
+
+ let textContent = handleWithTimestamp(
+ seg.text,
+ seg.timestamp,
+ configwords,
+ 6
+ );
+ displayText += textContent;
+ });
+
+ return displayText;
+ }
+
+ function renderFullText() {
+ const initialSpeaker =
+ segmentRenderStartIndex > 0
+ ? getSpeakerBeforeIndex(segmentRenderStartIndex)
+ : null;
+ const displayText = buildSegmentText(
+ segmentList.slice(segmentRenderStartIndex),
+ initialSpeaker
+ );
+
+ rec_text = displayText;
+ // 保留历史转录内容(来自暂停/继续草稿)
+ asrResult.value = pausedTranscription.value + rec_text; // 更新主显示区域
+
+ // 更新实时显示,展示剩余的待处理句子 + 当前正在识别的内容
+ syncRealtimePreview();
+ // 移除旧的清空逻辑
+ // asrResultOnline.value = "";
+ // rec_textOnline = "";
+ }
+ // Legacy implementation kept temporarily for reference; the active
+ // realtime path uses the clean getJsonMessage defined below.
+ function getJsonMessageLegacy(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var rectxt = "" + parsedMessage["text"];
+ var asrmodel = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+
+ // 检查是否需要重置转录状态(从暂停/恢复时)
+ if (resetTranscriptionFlag) {
+ console.log("🔄 重置转录状态(暂停/恢复后)");
+ clearRealtimePipeline();
+ resetTranscriptionFlag = false;
+ }
+
+ const messageEpoch = liveTranscriptionEpoch;
+
+ if (asrmodel == "2pass-online") {
+ // ✅ 实时转录换行逻辑
+ // 规则1:凡是换行的,末尾必须是句号(。)
+ // 规则2:不修改标点符号
+ // 规则3:停顿3秒以上才换行
+
+ // 解析时间戳(获取最后一个字符的时间)
+ const currentTimestamp = extractTimestampMs(timestamp);
+ if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
+ syncRealtimePreview();
+ return;
+ }
+
+ // 检查是否需要换行
+ let shouldBreak = false;
+ if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
+ // 计算时间差(单位:秒)
+ const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
+ // 停顿超过3秒,且当前文本以句号结尾
+ if (timeDiff >= 3 && /。\s*$/.test(rectxt)) {
+ shouldBreak = true;
+ }
+ }
+
+ // 拼接文本(不修改标点)
+ if (shouldBreak) {
+ rec_textOnline = rec_textOnline + rectxt + "\n";
+ } else {
+ rec_textOnline = rec_textOnline + rectxt;
+ }
+
+ // 更新时间戳
+ lastOnlineTimestamp = currentTimestamp;
+
+ } else if (asrmodel == "2pass-offline") {
+ // Bug fix: 使用offline返回的完整文本替代可能不完整的online累积文本
+ // rectxt包含了完整的转录结果,比rec_textOnline更准确
+ ensurePendingSentence(rectxt, timestamp, messageEpoch);
+ rec_textOnline = "";
+ lastOnlineTimestamp = null; // 重置时间戳
+ } else{
+ }
+ // 更新显示:队列中的内容 + 当前实时内容
+ syncRealtimePreview();
+
+ }
+ function getJsonMessage(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var rectxt = "" + parsedMessage["text"];
+ var asrmodel = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+
+ if (resetTranscriptionFlag) {
+ console.log("重置实时转录状态(暂停/恢复后)");
+ clearRealtimePipeline();
+ resetTranscriptionFlag = false;
+ }
+
+ const messageEpoch = liveTranscriptionEpoch;
+
+ if (asrmodel == "2pass-online") {
+ const currentTimestamp = extractTimestampMs(timestamp);
+ if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
+ syncRealtimePreview();
+ return;
+ }
+
+ let shouldBreak = false;
+ if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
+ const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
+ if (timeDiff >= 3 && /。\s*$/.test(rectxt)) {
+ shouldBreak = true;
+ }
+ }
+
+ if (shouldBreak) {
+ rec_textOnline = rec_textOnline + rectxt + "\n";
+ } else {
+ rec_textOnline = rec_textOnline + rectxt;
+ }
+
+ lastOnlineTimestamp = currentTimestamp;
+ } else if (asrmodel == "2pass-offline") {
+ // 离线句子的正式入队只走 getJsonMessage2,避免双通路重复落句。
+ rec_textOnline = "";
+ offlineSentenceHandoffText = rectxt || rec_textOnline || "";
+ rec_textOnline = "";
+ lastOnlineTimestamp = null;
+ }
+
+ syncRealtimePreview();
+
+ }
+ async function getJsonMessage2(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var asrmodeltype = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+ const messageEpoch = liveTranscriptionEpoch;
+ const messageTimestampMs = extractTimestampMs(timestamp); // 获取最后一个时间
+
+ // ============================
+ // 场景 A: 2pass-offline (文本先到)
+ // ============================
+ if (asrmodeltype == "2pass-offline") {
+ if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
+ return;
+ }
+
+ var rectxt = "" + parsedMessage["text"];
+ rec_textOnline = "";
+ lastOnlineTimestamp = null;
+ // 3. 准备进行 LLM 矫正
+ // 使用当前数组里已有的文本 (就是 2pass-offline 的文本)
+ const pendingSentence = ensurePendingSentence(
+ rectxt,
+ timestamp,
+ messageEpoch
+ );
+ offlineSentenceHandoffText = "";
+ syncRealtimePreview();
+ if (
+ !pendingSentence ||
+ pendingSentence.processed ||
+ pendingSentence.correctionRequested
+ ) {
+ return;
+ }
+
+ pendingSentence.correctionRequested = true;
+ pendingCorrectionCount++;
+ try {
+ const response = await fetch(`/tool/speakr/api/asr/correct`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: pendingSentence.rawText }), // 发送当前新增文本去矫正
+ });
+
+ if (!response.ok) {
+ throw new Error("ASR correction failed");
+ }
+
+ const data = await response.json();
+ let correctedText =
+ data.corrected_text || pendingSentence.rawText;
+
+ // 4. LLM 回来后,直接更新文本内容(自动应用)
+ finalizePendingSentence(
+ pendingSentence.id,
+ correctedText,
+ messageEpoch
+ );
+ console.log("LLM矫正完成");
+ } catch (error) {
+ finalizePendingSentence(
+ pendingSentence.id,
+ pendingSentence.rawText,
+ messageEpoch
+ );
+ console.error("LLM矫正出错:", error);
+ } finally {
+ pendingCorrectionCount = Math.max(
+ 0,
+ pendingCorrectionCount - 1
+ );
+ }
+ }
+
+ // ============================
+ // 场景 B: offline-speaker (说话人后到)
+ // ============================
+ else if (asrmodeltype == "offline-speaker") {
+ if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
+ return;
+ }
+ // 【修改点】:这里不再获取 full_text 用于替换
+ // var rectxt = "" + JSON.parse(jsonMsg.data)["full_text"]; // 这行删掉或注释
+
+ let nowSpeaker =
+ (parsedMessage["segments"] &&
+ parsedMessage["segments"][0] &&
+ parsedMessage["segments"][0].speaker) ||
+ "未命名演讲者";
+
+ // 找到最后一个还没认领说话人的句子
+ let targetIndex = -1;
+ for (let i = segmentList.length - 1; i >= 0; i--) {
+ const segment = segmentList[i];
+ if (!segment || segment.epoch !== messageEpoch) {
+ continue;
+ }
+ if (
+ segment.speaker === null &&
+ Number.isFinite(messageTimestampMs) &&
+ Number.isFinite(segment.timestampMs) &&
+ segment.timestampMs === messageTimestampMs
+ ) {
+ targetIndex = i;
+ break;
+ }
+ if (targetIndex === -1 && segment.speaker === null) {
+ targetIndex = i;
+ }
+ }
+
+ if (targetIndex !== -1) {
+ // 1. 只更新说话人
+ segmentList[targetIndex].speaker = nowSpeaker;
+
+ // 【关键】:这里绝对不更新 text,保持 2pass-offline 时的原样
+ // segmentList[targetIndex].text = ...; // 不要这行
+
+ // 2. 立即渲染
+ // 效果:文本没变(不会跳变),只是头上多了个名字
+ renderFullText();
+ }
+ }
+
+ }
+
+ // 连接状态响应
+ function getConnState(connState) {
+ if (connState === 0) {
+ record();
+ } else if (connState === 1) {
+ } else if (connState === 2) {
+ stop();
+ }
+ }
+ // 连接状态响应
+ function getConnState2(connState) {
+ if (connState === 0) {
+ record2();
+ } else if (connState === 1) {
+ } else if (connState === 2) {
+ stop();
+ }
+ }
+ async function getMicrophoneInfo() {
+ if (
+ !navigator.mediaDevices ||
+ !navigator.mediaDevices.enumerateDevices
+ ) {
+ console.warn("当前浏览器不支持获取设备信息");
+ return null;
+ }
+
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const mics = devices.filter((d) => d.kind === "audioinput");
+
+ // 当前默认麦克风(大多数浏览器)
+ const defaultMic =
+ mics.find((d) => d.deviceId === "default") || mics[0];
+
+ return {
+ name: defaultMic?.label || "未知麦克风",
+ deviceId: defaultMic?.deviceId || "",
+ all: mics.map((d) => ({
+ name: d.label,
+ deviceId: d.deviceId,
+ })),
+ };
+ }
+ async function openMixedStream() {
+ try {
+ const micInfo = await getMicrophoneInfo();
+ if (micInfo) {
+ console.log("🎤 当前麦克风:", micInfo.name, micInfo);
+ }
+ // 获取麦克风音频流
+ const micStreams = micStream;
+
+ // 尝试获取系统音频流
+ let systemStreams;
+ try {
+ systemStreams = systemStream;
+ } catch (err) {
+ console.warn("未能获取系统音频,仅使用麦克风录音:", err);
+ }
+
+ // 创建音频上下文和混音目标
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ }
+ asrAudioContext = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ const audioContext = asrAudioContext;
+ const destination = audioContext.createMediaStreamDestination();
+
+ // 把麦克风接入混音输出
+ const micSource =
+ audioContext.createMediaStreamSource(micStreams);
+ micSource.connect(destination);
+
+ // 如果有系统音频,混入
+ if (systemStream && systemStream.getAudioTracks().length > 0) {
+ const sysSource =
+ audioContext.createMediaStreamSource(systemStream);
+ sysSource.connect(destination);
+ } else {
+ console.log("⚠️ 未检测到系统音频,使用麦克风录音");
+ }
+
+ // 打开 recorder 并传入混合后的流
+ rec.open(
+ () => {
+ rec.start(destination.stream);
+ },
+ (msg) => {
+ console.error("❌ 录音权限被拒绝:", msg);
+ }
+ );
+ } catch (err) {
+ console.error("初始化录音失败:", err);
+ }
+ }
+ async function openMixedStream2() {
+ try {
+ // 获取麦克风音频流
+ const micStreams = micStream;
+
+ // 尝试获取系统音频流(Chrome 需要用户手动勾选“共享系统音频”)
+ let systemStreams;
+ try {
+ systemStreams = systemStream;
+ } catch (err) {
+ console.warn("未能获取系统音频,仅使用麦克风录音:", err);
+ }
+
+ // 创建音频上下文和混音目标
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ }
+ asrAudioContext2 = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ const audioContext = asrAudioContext2;
+ const destination = audioContext.createMediaStreamDestination();
+
+ // 把麦克风接入混音输出
+ const micSource =
+ audioContext.createMediaStreamSource(micStreams);
+ micSource.connect(destination);
+
+ // 如果有系统音频,混入
+ if (systemStream && systemStream.getAudioTracks().length > 0) {
+ const sysSource =
+ audioContext.createMediaStreamSource(systemStream);
+ sysSource.connect(destination);
+ } else {
+ console.log("⚠️ 未检测到系统音频,使用麦克风录音");
+ }
+
+ // 打开 recorder 并传入混合后的流
+ rec2.open(
+ () => {
+ rec2.start(destination.stream);
+ },
+ (msg) => {
+ console.error("❌ 录音权限被拒绝:", msg);
+ }
+ );
+ } catch (err) {
+ console.error("初始化录音失败:", err);
+ }
+ }
+
+ //------------------- 实时 ASR 双 WebSocket 启动 start------------------
+ const start = () => {
+ // 清除显示
+ // clear();
+ //控件状态更新
+
+ //启动 online 连接:不传 URL 时仍连接默认 /websocket_offline,
+ //真正区别是 wsconnecter.js 将 modeType 设置为 "online"。
+
+ //启动连接
+ var ret = wsconnecter.wsStart();
+ if (ret == 1) {
+ isRec = true;
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+ const start2 = () => {
+ // 清除显示
+ // clear();
+ //控件状态更新
+
+ //启动连接(offline)
+ var ret = wsconnecter2.wsStart("offline");
+ if (ret == 1) {
+ isRec = true;
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+ //--------------------实时 ASR 双 WebSocket 启动 end------------------
+ if (!isPaused.value) {
+ start();
+ start2();
+ }
+ //------------------- 实时 ASR 双 Recorder 实例 start------------------
+ // 这里的 rec/rec2 只负责实时 ASR PCM 推流,不是最终保存录音文件的 MediaRecorder。
+ // 两个 Recorder 都监听同一份混合音频流,分别进入 online/offline 两条 WebSocket 链路。
+
+ // 录音; 定义录音对象,wav格式
+ rec = Recorder({
+ type: "pcm",
+ bitRate: 16,
+ sampleRate: 16000,
+ onProcess: recProcess,
+ });
+ rec2 = Recorder({
+ type: "pcm",
+ bitRate: 16,
+ sampleRate: 16000,
+ onProcess: recProcess2,
+ });
+ //--------------------实时 ASR 双 Recorder 实例 end------------------
+
+ // Fix audio duration for recorded files
+ if (audioBlobURL.value) {
+ const recordingAudio = document.querySelector(
+ 'audio[src*="blob:"]'
+ );
+ if (recordingAudio) {
+ const fixDuration = () => {
+ if (
+ !isFinite(recordingAudio.duration) ||
+ recordingAudio.duration === 0
+ ) {
+
+ const originalTime = recordingAudio.currentTime;
+ recordingAudio.currentTime = 1e101;
+ recordingAudio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ recordingAudio.currentTime = originalTime;
+ recordingAudio.removeEventListener(
+ "timeupdate",
+ resetTime
+ );
+ },
+ { once: true }
+ );
+ }
+ };
+ recordingAudio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("canplay", fixDuration, {
+ once: true,
+ });
+ if (recordingAudio.readyState >= 1) {
+ setTimeout(fixDuration, 100);
+ }
+ }
+ }
+ });
+ } else {
+ liveTranscriptionEpoch += 1;
+ realtimeTimestampBarrierMs = -1;
+ clearRealtimePipelineRef = null;
+ getLatestRealtimeTimestampRef = null;
+ buildRealtimeEditCarryoverRef = null;
+ renderFullTextRef = null;
+ isRec = false;
+ analysisResult.value = "";
+ asrResult.value = "";
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ isEditingTranscription.value = false;
+ is_final.value = false;
+ if (recordingMarkdownEditorInstance.value) {
+ recordingMarkdownEditorInstance.value.toTextArea();
+ recordingMarkdownEditorInstance.value = null;
+ }
+ if (webReader) {
+ webReader.wsStop();
+ }
+ if (webReader2) {
+ webReader2.wsStop();
+ }
+ // 关闭 Recorder 实例,停止 onProcess 回调
+ if (rec) {
+ try { rec.close(); } catch(e) {}
+ rec = null;
+ }
+ if (rec2) {
+ try { rec2.close(); } catch(e) {}
+ rec2 = null;
+ }
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ asrAudioContext = null;
+ }
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ asrAudioContext2 = null;
+ }
+
+ // Clean up recording markdown editor when leaving recording view
+ }
+ });
+ //--------------------录音视图切换与实时 ASR 管线 end------------------
+
+ // 监听内容变化
+ watch(asrResult, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+ if (asrTextarea.value) {
+ // console.log(asrTextarea.value.scrollHeight,'asrTextarea.value.scrollHeight');
+
+ asrTextarea.value.scrollTop = asrTextarea.value.scrollHeight;
+ }
+ if (asrResultTextarea.value) {
+ asrResultTextarea.value.scrollTop =
+ asrResultTextarea.value.scrollHeight;
+ }
+ });
+ // 监听内容变化
+ watch(asrResultOnline, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+
+ if (asrResultTextareaOnline.value) {
+ asrResultTextareaOnline.value.scrollTop =
+ asrResultTextareaOnline.value.scrollHeight;
+ }
+ });
+ watch(asrResultOffline, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+
+ if (asrResultTextareaOnline.value) {
+ asrResultTextareaOnline.value.scrollTop =
+ asrResultTextareaOnline.value.scrollHeight;
+ }
+ });
+ let realtimeAutoScrollFrame = null;
+ const scrollRealtimeTextareaToBottom = (textarea) => {
+ if (!textarea) return;
+ textarea.scrollTop = textarea.scrollHeight;
+ };
+ const scrollVisibleRealtimeTextareas = () => {
+ [
+ asrTextarea.value,
+ asrResultTextarea.value,
+ asrResultTextareaOnline.value,
+ ].forEach(scrollRealtimeTextareaToBottom);
+ };
+ const scheduleRealtimeAutoScroll = async (force = false) => {
+ if (isEditingTranscription.value && !force) {
+ return;
+ }
+
+ await nextTick();
+ scrollVisibleRealtimeTextareas();
+
+ if (
+ typeof window === "undefined" ||
+ typeof window.requestAnimationFrame !== "function"
+ ) {
+ return;
+ }
+
+ if (realtimeAutoScrollFrame !== null) {
+ window.cancelAnimationFrame(realtimeAutoScrollFrame);
+ realtimeAutoScrollFrame = null;
+ }
+
+ realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
+ scrollVisibleRealtimeTextareas();
+ realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
+ scrollVisibleRealtimeTextareas();
+ realtimeAutoScrollFrame = null;
+ });
+ });
+ };
+ watch(fullRealtimeTranscription, () => {
+ scheduleRealtimeAutoScroll();
+ });
+ watch(isEditingTranscription, () => {
+ scheduleRealtimeAutoScroll(true);
+ });
+ watch(isyjDialogVisible, (visible) => {
+ if (visible) {
+ scheduleRealtimeAutoScroll(true);
+ }
+ });
+ watch(analysisResult, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+ if (sumupResultTextarea.value) {
+ sumupResultTextarea.value.scrollTop =
+ sumupResultTextarea.value.scrollHeight;
+ }
+ });
+ watch(audioBlobURL, (newURL) => {
+ if (newURL) {
+ nextTick(() => {
+ const recordingAudio = document.querySelector(
+ 'audio[src*="blob:"]'
+ );
+
+ if (recordingAudio) {
+ const fixDuration = () => {
+ if (
+ !isFinite(recordingAudio.duration) ||
+ recordingAudio.duration === 0
+ ) {
+ const originalTime = recordingAudio.currentTime;
+ // recordingAudio.currentTime = 1e101;
+ recordingAudio.currentTime = originalTime + 0.01
+
+ recordingAudio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ recordingAudio.currentTime = originalTime;
+ recordingAudio.removeEventListener(
+ "timeupdate",
+ resetTime
+ );
+ },
+ { once: true }
+ );
+ }
+ };
+
+ // Multiple events to ensure we catch the duration
+ recordingAudio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("loadeddata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("canplay", fixDuration, {
+ once: true,
+ });
+
+ // Also try after a delay
+ if (recordingAudio.readyState >= 1) {
+ setTimeout(fixDuration, 100);
+ }
+ }
+ });
+ }
+ });
+
+ watch(playerVolume, (newVolume) => {
+ const audioElements = document.querySelectorAll("audio");
+ audioElements.forEach((audio) => {
+ if (audio.volume !== newVolume) {
+ audio.volume = newVolume;
+ }
+ });
+ });
+
+ // Watch for search query changes
+ watch(searchQuery, (newQuery) => {
+ debouncedSearch(newQuery);
+ });
+
+ // Auto-apply filters when they change (except text query which is debounced)
+ watch(
+ filterTags,
+ () => {
+ applyAdvancedFilters();
+ },
+ { deep: true }
+ );
+
+ watch(filterDatePreset, () => {
+ applyAdvancedFilters();
+ });
+
+ watch(
+ filterDateRange,
+ () => {
+ applyAdvancedFilters();
+ },
+ { deep: true }
+ );
+
+ // Debounce text query changes
+ watch(filterTextQuery, (newValue) => {
+ clearTimeout(searchDebounceTimer.value);
+ searchDebounceTimer.value = setTimeout(() => {
+ applyAdvancedFilters();
+ }, 300);
+ });
+
+ //--------------------响应式监听与界面联动 end------------------
+
+ //------------------- 运行时配置加载 start------------------
+ // --- Configuration Loading ---
+ const loadConfiguration = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/config");
+ if (response.ok) {
+ const config = await response.json();
+ maxFileSizeMB.value = config.max_file_size_mb || 250;
+ chunkingEnabled.value =
+ config.chunking_enabled !== undefined
+ ? config.chunking_enabled
+ : true;
+ chunkingMode.value = config.chunking_mode || "size";
+ chunkingLimit.value = config.chunking_limit || 20;
+ chunkingLimitDisplay.value =
+ config.chunking_limit_display || "20MB";
+ recordingDisclaimer.value = config.recording_disclaimer || "";
+ console.log(
+ `Loaded configuration: max size ${
+ maxFileSizeMB.value
+ }MB, chunking ${
+ chunkingEnabled.value ? "enabled" : "disabled"
+ } (${chunkingLimitDisplay.value})`
+ );
+ } else {
+ console.warn("Failed to load configuration, using default values");
+ }
+ } catch (error) {
+ console.error("Error loading configuration:", error);
+ // Keep default values on error
+ }
+ };
+
+ const initializeSummaryMarkdownEditor = () => {
+ if (!summaryMarkdownEditor.value) return;
+
+ try {
+ summaryMarkdownEditorInstance.value = new EasyMDE({
+ element: summaryMarkdownEditor.value,
+ spellChecker: false,
+ autofocus: true,
+ placeholder: "Enter summary in Markdown format...",
+ initialValue: selectedRecording.value?.summary || "",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "image",
+ "|",
+ "preview",
+ "side-by-side",
+ "fullscreen",
+ "|",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ });
+
+ // Add auto-save functionality
+ summaryMarkdownEditorInstance.value.codemirror.on("change", () => {
+ if (autoSaveTimer.value) {
+ clearTimeout(autoSaveTimer.value);
+ }
+ autoSaveTimer.value = setTimeout(() => {
+ autoSaveSummary();
+ }, autoSaveDelay);
+ });
+ } catch (error) {
+ console.error("Failed to initialize summary markdown editor:", error);
+ editingSummary.value = true;
+ }
+ };
+
+ const pollInboxRecordings = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/inbox_recordings");
+ if (!response.ok) {
+ // Silently fail, as this is a background task
+ return;
+ }
+ const inboxRecordings = await response.json();
+
+ if (inboxRecordings && inboxRecordings.length > 0) {
+ inboxRecordings.forEach((recording) => {
+ // Add to main recordings list if it's not there already
+ const existingRecording = recordings.value.find(
+ (r) => r.id === recording.id
+ );
+ if (!existingRecording) {
+ recordings.value.unshift(recording);
+ totalRecordings.value++; // Update total count
+ }
+
+ // Check if this recording is already in the upload queue or currently being processed
+ const existingItem = uploadQueue.value.find(
+ (item) => item.recordingId === recording.id
+ );
+ const isCurrentlyProcessing =
+ currentlyProcessingFile.value &&
+ currentlyProcessingFile.value.recordingId === recording.id;
+
+ // Don't add if it's already in queue or being processed
+ if (existingItem || isCurrentlyProcessing) {
+ // Update status if the existing item status has changed
+ if (existingItem && existingItem.status !== recording.status) {
+ console.log(
+ `Updating status for existing recording ${recording.original_filename}: ${existingItem.status} -> ${recording.status}`
+ );
+ }
+ return;
+ }
+
+ // Only add recordings that are still processing (not completed)
+ if (recording.status === "COMPLETED") {
+ console.log(
+ `Skipping completed inbox recording: ${recording.original_filename}`
+ );
+ return;
+ }
+
+ console.log(
+ `Found new inbox recording: ${recording.original_filename} (status: ${recording.status})`
+ );
+
+ // Don't add recordings that are already being handled by main processing
+ if (
+ recording.status === "SUMMARIZING" &&
+ isProcessingActive.value
+ ) {
+ console.log(
+ `Skipping ${recording.original_filename} - already in summarization phase of main processing`
+ );
+ return;
+ }
+
+ // Add it to the queue to be displayed in the progress modal
+ const inboxItem = {
+ file: {
+ name:
+ recording.original_filename || `Recording ${recording.id}`,
+ size: recording.file_size || 0,
+ },
+ status: "pending", // It's already being processed on backend
+ recordingId: recording.id,
+ clientId: `inbox-${recording.id}-${Date.now()}`,
+ error: null,
+ isReprocessing: true, // Use reprocessing logic to poll for status
+ reprocessType: "transcription",
+ };
+ uploadQueue.value.unshift(inboxItem);
+
+ // If nothing is currently being processed, make this the active item for the main progress bar
+ if (!isProcessingActive.value && !currentlyProcessingFile.value) {
+ currentlyProcessingFile.value = inboxItem;
+ }
+
+ // Show progress modal if it's hidden
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ // Start polling its status
+ startReprocessingPoll(recording.id);
+ });
+ }
+ } catch (error) {
+ console.error("Error polling for inbox recordings:", error);
+ }
+ };
+
+ //--------------------运行时配置加载 end------------------
+
+ //------------------- 录音格式兼容检测 start------------------
+ // --- Audio Format Detection ---
+ const detectSupportedAudioFormats = () => {
+ const formats = [
+ "audio/webm;codecs=opus",
+ "audio/webm;codecs=vp9",
+ "audio/webm",
+ "audio/mp4;codecs=mp4a.40.2",
+ "audio/mp4",
+ "audio/ogg;codecs=opus",
+ "audio/wav",
+ ];
+
+ const supportedFormats = formats.filter((format) =>
+ MediaRecorder.isTypeSupported(format)
+ );
+ console.log("Supported audio recording formats:", supportedFormats);
+
+ if (supportedFormats.length === 0) {
+ console.warn(
+ "No optimized audio formats supported, will use browser default"
+ );
+ }
+
+ return supportedFormats;
+ };
+
+ //--------------------录音格式兼容检测 end------------------
+
+ //------------------- 系统音频能力检测 start------------------
+ // --- System Audio Detection ---
+ const detectSystemAudioCapabilities = async () => {
+ systemAudioSupported.value = false;
+ canRecordSystemAudio.value = false;
+ systemAudioError.value = "";
+
+ // Check if getDisplayMedia is available
+ if (
+ !navigator.mediaDevices ||
+ !navigator.mediaDevices.getDisplayMedia
+ ) {
+ systemAudioError.value = "getDisplayMedia API not supported";
+ return;
+ }
+
+ try {
+ // Test if we can request system audio (this will prompt user)
+ // We'll do this only when user actually tries to record
+ systemAudioSupported.value = true;
+ canRecordSystemAudio.value = true;
+ systemAudioError.value = "";
+ } catch (error) {
+ systemAudioError.value = error.message;
+ console.warn("System audio detection failed:", error);
+ }
+ };
+
+ const detectBrowser = () => {
+ const userAgent = navigator.userAgent;
+ if (userAgent.includes("Firefox")) {
+ browser.value = "firefox";
+ } else if (userAgent.includes("Chrome")) {
+ browser.value = "chrome";
+ } else if (userAgent.includes("Safari")) {
+ browser.value = "safari";
+ } else if (
+ userAgent.includes("MSIE") ||
+ userAgent.includes("Trident/")
+ ) {
+ browser.value = "ie";
+ } else {
+ browser.value = "unknown";
+ }
+ };
+
+ //--------------------系统音频能力检测 end------------------
+
+ //------------------- 生命周期初始化 start------------------
+ // --- Lifecycle ---
+ onMounted(async () => {
+ const loader = document.getElementById("loader");
+ const app = document.getElementById("app");
+ if (loader) {
+ loader.style.opacity = "0";
+ setTimeout(() => {
+ loader.style.display = "none";
+ }, 500);
+ }
+ if (app) {
+ app.style.opacity = "1";
+ }
+ const appDiv = document.getElementById("app");
+ if (appDiv) {
+ const asrFlag = appDiv.dataset.useAsrEndpoint;
+ useAsrEndpoint.value = asrFlag === "True" || asrFlag === "true";
+ currentUserName.value = appDiv.dataset.currentUserName || "";
+ }
+
+ // Load configuration first
+ await loadConfiguration();
+
+ // Detect system audio capabilities
+ await detectSystemAudioCapabilities();
+
+ detectBrowser();
+
+ // Check if language was changed in account settings
+ if (localStorage.getItem("ui_language_changed") === "true") {
+ localStorage.removeItem("ui_language_changed");
+ // Force reload to apply new language
+ window.location.reload();
+ return;
+ }
+
+ // i18n is already initialized before Vue app creation
+ if (window.i18n) {
+ currentLanguage.value = window.i18n.getLocale();
+ availableLanguages.value = window.i18n.getAvailableLocales();
+ const lang = availableLanguages.value.find(
+ (l) => l.code === currentLanguage.value
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+
+ // Listen for locale changes
+ window.addEventListener("localeChanged", (event) => {
+ currentLanguage.value = event.detail.locale;
+ const lang = availableLanguages.value.find(
+ (l) => l.code === currentLanguage.value
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+ });
+ }
+
+ loadRecordings();
+ loadTags();
+ loadDrafts(); // 加载草稿列表
+ initializeDarkMode();
+ initializeColorScheme();
+
+ const savedVolume = localStorage.getItem("playerVolume");
+ if (savedVolume !== null) {
+ playerVolume.value = parseFloat(savedVolume);
+ }
+
+ const savedTranscriptionViewMode = localStorage.getItem(
+ "transcriptionViewMode"
+ );
+ if (savedTranscriptionViewMode) {
+ transcriptionViewMode.value = savedTranscriptionViewMode;
+ }
+
+ // Load saved column widths
+ const savedLeftWidth = localStorage.getItem("transcriptColumnWidth");
+ const savedRightWidth = localStorage.getItem("summaryColumnWidth");
+ if (savedLeftWidth && savedRightWidth) {
+ leftColumnWidth.value = parseFloat(savedLeftWidth);
+ rightColumnWidth.value = parseFloat(savedRightWidth);
+ }
+
+ const updateMobileStatus = () => {
+ windowWidth.value = window.innerWidth;
+ };
+
+ window.addEventListener("resize", updateMobileStatus);
+ updateMobileStatus();
+
+ // Start polling for inbox recordings
+ setInterval(pollInboxRecordings, 10000);
+
+ const handleEsc = (e) => {
+ if (e.key === "Escape") {
+ if (showColorSchemeModal.value) {
+ closeColorSchemeModal();
+ }
+ if (showEditModal.value) {
+ cancelEdit();
+ }
+ if (showDeleteModal.value) {
+ cancelDelete();
+ }
+ if (showSortOptions.value) {
+ showSortOptions.value = false;
+ }
+ }
+ };
+ document.addEventListener("keydown", handleEsc);
+
+ // Click away handler for dropdowns
+ const handleClickAway = (e) => {
+ // Close user menu if clicking outside
+ if (isUserMenuOpen.value) {
+ // Check if we clicked within the user menu area (button or dropdown)
+ const userMenuButton = e.target.closest(
+ 'button[class*="flex items-center gap"]'
+ );
+ const userMenuDropdown = e.target.closest(
+ 'div[class*="absolute right-0"]'
+ );
+
+ // Check if the click was on the user menu button specifically
+ const isUserMenuButtonClick =
+ userMenuButton &&
+ userMenuButton.querySelector("i.fa-user-circle");
+
+ // If we didn't click on the user menu button or dropdown, close it
+ if (!isUserMenuButtonClick && !userMenuDropdown) {
+ isUserMenuOpen.value = false;
+ }
+ }
+
+ // Close speaker suggestions if clicking outside in the modal
+ if (showSpeakerModal.value) {
+ // If not clicking on an input field or suggestion dropdown
+ const clickedOnInput = e.target.closest('input[type="text"]');
+ const clickedOnSuggestion = e.target.closest(
+ '[class*="absolute z-10"]'
+ );
+
+ if (!clickedOnInput && !clickedOnSuggestion) {
+ // Close all suggestion dropdowns
+ Object.keys(speakerSuggestions.value).forEach((speakerId) => {
+ speakerSuggestions.value[speakerId] = [];
+ });
+ }
+ }
+ };
+ document.addEventListener("click", handleClickAway);
+ });
+
+ //--------------------生命周期初始化 end------------------
+
+ //------------------- 实时转写手动编辑模式 start------------------
+ // --- 转录文本编辑功能 ---
+ // 用户手动编辑后,以编辑结果为准,旧的实时结果不再回灌。
+ let editModeOriginalText = "";
+
+ function enterEditMode() {
+ editModeOriginalText = fullRealtimeTranscription.value || "";
+ editableText.value = editModeOriginalText;
+ editModeBufferStartIndex = segmentList.length;
+ const barrierTimestampMs = getLatestRealtimeTimestampRef
+ ? getLatestRealtimeTimestampRef()
+ : null;
+ startNewRealtimeEpoch({
+ barrierTimestampMs,
+ clearEditCarryover: false,
+ });
+ realtimeEditCarryover = buildRealtimeEditCarryoverRef
+ ? buildRealtimeEditCarryoverRef(editModeOriginalText)
+ : null;
+ isEditingTranscription.value = true;
+ }
+
+ function exitEditMode() {
+ const nextText = editableText.value ?? "";
+
+ isEditingTranscription.value = false;
+
+ pausedTranscription.value = nextText;
+ asrResult.value = nextText;
+ asrResultOnline.value = "";
+ asrResultOffline.value = "";
+ segmentRenderStartIndex = editModeBufferStartIndex;
+ editModeOriginalText = nextText;
+
+ if (renderFullTextRef) {
+ renderFullTextRef();
+ }
+ }
+
+ //--------------------实时转写手动编辑模式 end------------------
+
+ //------------------- 模板暴露与事件出口 start------------------
+ return {
+ // Core State
+ currentView,
+ dragover,
+ recordings,
+ selectedRecording,
+ selectedTab,
+ searchQuery,
+ isLoadingRecordings,
+ globalError,
+ maxFileSizeMB,
+ chunkingEnabled,
+ chunkingMode,
+ chunkingLimit,
+ chunkingLimitDisplay,
+ sortBy,
+ showAdvancedFilters,
+ filterTags,
+ filterDateRange,
+ filterDatePreset,
+ filterTextQuery,
+
+ // Pagination State
+ currentPage,
+ perPage,
+ totalRecordings,
+ totalPages,
+ hasNextPage,
+ hasPrevPage,
+ isLoadingMore,
+
+ // UI State
+ browser,
+ isSidebarCollapsed,
+ searchTipsExpanded,
+ isUserMenuOpen,
+ isDarkMode,
+ currentColorScheme,
+ showColorSchemeModal,
+ windowWidth,
+ isMobileScreen,
+ mobileTab,
+ isMetadataExpanded,
+
+ // i18n State
+ currentLanguage,
+ currentLanguageName,
+ availableLanguages,
+ showLanguageMenu,
+
+ // Upload State
+ uploadQueue,
+ currentlyProcessingFile,
+ processingProgress,
+ processingMessage,
+ isProcessingActive,
+ progressPopupMinimized,
+ progressPopupClosed,
+ totalInQueue,
+ completedInQueue,
+ finishedFilesInQueue,
+ clearCompletedUploads,
+
+ // Audio Recording
+ editingDraftId,
+ editingDraftName,
+
+ isRecording,
+ canRecordAudio,
+ canRecordSystemAudio,
+ systemAudioSupported,
+ systemAudioError,
+ audioBlobURL,
+ recordingTime,
+ recordingNotes,
+ visualizer,
+ micVisualizer,
+ systemVisualizer,
+ recordingMode,
+ recordingDisclaimer,
+ showRecordingDisclaimerModal,
+ acceptRecordingDisclaimer,
+ cancelRecordingDisclaimer,
+ showAdvancedOptions,
+ uploadLanguage,
+ uploadMinSpeakers,
+ uploadMaxSpeakers,
+ asrLanguage,
+ asrMinSpeakers,
+ asrMaxSpeakers,
+ availableTags,
+ selectedTagIds,
+ selectedTags,
+ uploadTagSearchFilter,
+ filteredAvailableTagsForUpload,
+ onTagSelected,
+ addTagToSelection,
+ removeTagFromSelection,
+ showSystemAudioHelp,
+
+ // Draft/Pause Recording
+ isPaused,
+ isStoppingRecording,
+ currentDraftId,
+ showRecordingControls,
+ pausedDuration,
+ pausedTranscription,
+ draftRecordings,
+ draftsExpanded,
+ pauseRecording,
+ resumeRecording,
+ loadDrafts,
+ continueDraftRecording,
+ deleteDraft,
+
+ // Modal State
+ showEditModal,
+ showDeleteModal,
+ showResetModal,
+ editingRecording,
+ recordingToDelete,
+ showEditTagsModal,
+ selectedNewTagId,
+ tagSearchFilter,
+ filteredAvailableTagsForModal,
+ editRecordingTags,
+ closeEditTagsModal,
+ addTagToRecording,
+ removeTagFromRecording,
+ getRecordingTags,
+ getAvailableTagsForRecording,
+ filterByTag,
+ clearTagFilter,
+ applyAdvancedFilters,
+ clearAllFilters,
+ showTextEditorModal,
+ showAsrEditorModal,
+ editingTranscriptionContent,
+ editingSegments,
+ availableSpeakers,
+
+ // Inline Editing
+ editingParticipants,
+ editingMeetingDate,
+ editingSummary,
+ editingNotes,
+
+ // Markdown Editor
+ notesMarkdownEditor,
+ markdownEditorInstance,
+ summaryMarkdownEditor,
+ summaryMarkdownEditorInstance,
+ recordingNotesEditor,
+
+ // Transcription
+ transcriptionViewMode,
+ legendExpanded,
+ highlightedSpeaker,
+ processedTranscription,
+
+ // Chat
+ showChat,
+ isChatMaximized,
+ toggleChatMaximize,
+ chatMessages,
+ chatInput,
+ isChatLoading,
+ chatMessagesRef,
+
+ // Audio Player
+ playerVolume,
+
+ // Column Resizing
+ leftColumnWidth,
+ rightColumnWidth,
+ isResizing,
+
+ // App Configuration
+ useAsrEndpoint,
+ currentUserName,
+
+ // Computed
+ filteredRecordings,
+ groupedRecordings,
+ activeRecordingMetadata,
+ datePresetOptions,
+ languageOptions,
+
+ // Color Schemes
+ colorSchemes,
+ asrResult,
+ asrResultOnline,
+ asrResultOffline,
+ fullRealtimeTranscription,
+ asrTextarea,
+ asrResultTextarea,
+ asrResultTextareaOnline,
+ analysisResult,
+ sumupResultTextarea,
+ isDialogVisible,
+ isyjDialogVisible,
+ baseInfo,
+ isModalOpen,
+ urlinfo,
+ urlmsg,
+ zjloding,
+ // Methods
+ openmodel,
+ openzjmodel,
+ closeModal,
+ setGlobalError,
+ formatFileSize,
+ formatDisplayDate,
+ formatStatus,
+ getStatusClass,
+ formatTime,
+ t,
+ tc,
+ changeLanguage,
+ toggleDarkMode,
+ applyColorScheme,
+ initializeColorScheme,
+ openColorSchemeModal,
+ closeColorSchemeModal,
+ selectColorScheme,
+ resetColorScheme,
+ toggleSidebar,
+ switchToUploadView,
+ selectRecording,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ handleFileSelect,
+ addFilesToQueue,
+ startRecording,
+ stopRecording,
+ uploadRecordedAudio,
+ discardRecording,
+ downloadRecordedAudio,
+ loadRecordings,
+ loadMoreRecordings,
+ performSearch,
+ debouncedSearch,
+ saveMetadata,
+ editRecording,
+ cancelEdit,
+ saveEdit,
+ confirmDelete,
+ cancelDelete,
+ deleteRecording,
+ toggleEditParticipants,
+ toggleEditMeetingDate,
+ toggleEditSummary,
+ cancelEditSummary,
+ saveEditSummary,
+ toggleEditNotes,
+ cancelEditNotes,
+ saveEditNotes,
+ initializeMarkdownEditor,
+ saveInlineEdit,
+ clickToEditNotes,
+ clickToEditSummary,
+ autoSaveNotes,
+ autoSaveSummary,
+ sendChatMessage,
+ isChatScrolledToBottom,
+ scrollChatToBottom,
+ startColumnResize,
+ handleChatKeydown,
+ seekAudio,
+ seekAudioFromEvent,
+ onPlayerVolumeChange,
+ showToast,
+ copyMessage,
+ copyTranscription,
+ copySummary,
+ copyNotes,
+ downloadSummary,
+ downloadTranscript,
+ downloadNotes,
+ downloadChat,
+ clearChat,
+ downloadEventICS,
+ downloadICS,
+ formatEventDateTime,
+ toggleInbox,
+ toggleHighlight,
+ toggleTranscriptionViewMode,
+ reprocessTranscription,
+ reprocessSummary,
+ generateSummary,
+ resetRecordingStatus,
+ confirmReset,
+ cancelReset,
+ executeReset,
+ openTranscriptionEditor,
+ openTextEditorModal,
+ closeTextEditorModal,
+ saveTranscription,
+ openAsrEditorModal,
+ closeAsrEditorModal,
+ saveAsrTranscription,
+ adjustTime,
+ filterSpeakers,
+ openSpeakerSuggestions,
+ closeSpeakerSuggestions,
+ selectSpeaker,
+ addSegment,
+ removeSegment,
+ showReprocessModal,
+ reprocessType,
+ reprocessRecording,
+ cancelReprocess,
+ executeReprocess,
+ asrReprocessOptions,
+ showSpeakerModal,
+ showShareModal,
+ recordingToShare,
+ shareOptions,
+ generatedShareLink,
+ existingShareDetected,
+ userShares,
+ isLoadingShares,
+ showSharesListModal,
+ shareToDelete,
+ showShareDeleteModal,
+ confirmDeleteShare,
+ cancelDeleteShare,
+ speakerMap,
+ speakerDisplayMap,
+ modalSpeakers,
+ regenerateSummaryAfterSpeakerUpdate,
+ identifiedSpeakers,
+ identifiedSpeakersInOrder,
+ hasSpeakerNames,
+ openSpeakerModal,
+ closeSpeakerModal,
+ saveSpeakerNames,
+ highlightedTranscript,
+ highlightedSpeaker,
+ highlightSpeakerInTranscript,
+ focusSpeaker,
+ blurSpeaker,
+ clearSpeakerHighlight,
+ currentSpeakerGroupIndex,
+ speakerGroups,
+ navigateToNextSpeakerGroup,
+ navigateToPrevSpeakerGroup,
+ speakerSuggestions,
+ loadingSuggestions,
+ activeSpeakerInput,
+ searchSpeakers,
+ selectSpeakerSuggestion,
+ closeSpeakerSuggestionsOnClick,
+ autoIdentifySpeakers,
+ isAutoIdentifying,
+ formatDuration,
+ openShareModal,
+ closeShareModal,
+ createShare,
+ openSharesList,
+ closeSharesList,
+ updateShare,
+ deleteShare,
+ copyShareLink,
+ startDraftRename,
+ saveDraftRename,
+ editingDraftId,
+ editingDraftName,
+ draftNameInput,
+ draftRecordings,
+ deleteDraft,
+ continueDraftRecording,
+ loadDrafts,
+
+ // Recording Size Monitoring
+ estimatedFileSize,
+ fileSizeWarningShown,
+ recordingQuality,
+ actualBitrate,
+ maxRecordingMB,
+ sizeCheckInterval,
+ updateFileSizeEstimate,
+ startSizeMonitoring,
+ stopSizeMonitoring,
+ sumupResult,
+ lastTimer,
+ promptVisible,
+ promptList,
+ openPromptModal,
+ closePromptModal,
+ confirmPromptSelection,
+ selectedPromptId,
+ recommendPromptId,
+ recommendPromptName,
+ isMarkdown,
+ renderedMarkdown,
+ recommendPromptReason,
+ showSpeaker,
+ isEditingTranscription,
+ editableText,
+ enterEditMode,
+ exitEditMode,
+ };
+ },
+ delimiters: ["${", "}"],
+ });
+
+ //--------------------模板暴露与事件出口 end------------------
+
+ //--------------------Vue 应用主体定义 end------------------
+
+ //------------------- Vue 全局注入与挂载 start------------------
+ // Add t function as a global property BEFORE mounting so it's available in templates immediately
+ app.config.globalProperties.t = safeT;
+
+ // Also add tc for pluralization
+ app.config.globalProperties.tc = (key, count, params = {}) => {
+ if (!window.i18n || !window.i18n.tc) {
+ return key;
+ }
+ return window.i18n.tc(key, count, params);
+ };
+
+ // Provide t and tc to the app
+ app.provide("t", safeT);
+ app.provide("tc", (key, count, params = {}) => {
+ if (!window.i18n || !window.i18n.tc) {
+ return key;
+ }
+ return window.i18n.tc(key, count, params);
+ });
+
+ // Mount the app
+ app.mount("#app");
+
+ // Hide loading overlay after Vue is mounted and ready
+ Vue.nextTick(() => {
+ const overlay = document.querySelector(".app-loading-overlay");
+ if (overlay) {
+ // Small delay to ensure everything is rendered
+ setTimeout(() => {
+ overlay.classList.add("fade-out");
+ setTimeout(() => {
+ overlay.remove();
+ document.body.classList.remove("app-loading");
+ }, 300);
+ }, 100);
+ } else {
+ document.body.classList.remove("app-loading");
+ }
+ });
+ //--------------------Vue 全局注入与挂载 end------------------
+});
diff --git a/speakr/static/js/app-拆分后.js b/speakr/static/js/app-拆分后.js
new file mode 100644
index 0000000..5a723eb
--- /dev/null
+++ b/speakr/static/js/app-拆分后.js
@@ -0,0 +1,8050 @@
+const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
+// let isfilemode = false;
+
+// Wait for the DOM to be fully loaded before mounting the Vue app
+//------------------- 页面启动与 Vue 初始化 start------------------
+document.addEventListener("DOMContentLoaded", async () => {
+ // Initialize i18n before creating Vue app
+ if (window.i18n) {
+ const appElement = document.getElementById("app");
+ const userLang =
+ appElement?.dataset.userLanguage ||
+ localStorage.getItem("preferredLanguage") ||
+ "en";
+ await window.i18n.init(userLang);
+ console.log("i18n initialized with language:", userLang);
+ }
+
+ // CSRF Token Integration with Vue.js
+ const csrfToken = ref(
+ document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
+ );
+
+ // Register Service Worker only when the browser accepts the current TLS context.
+ if ("serviceWorker" in navigator && window.isSecureContext && window.location.hostname !== "localhost") {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker
+ .register("/tool/speakr/static/sw.js")
+ .then((registration) => {
+ console.log(
+ "ServiceWorker registration successful with scope: ",
+ registration.scope
+ );
+ })
+ .catch((error) => {
+ console.log("ServiceWorker registration failed: ", error);
+ });
+ });
+ }
+
+ // Create a safe t function that's always available
+ const safeT = (key, params = {}) => {
+ if (!window.i18n || !window.i18n.t) {
+ return key; // Return key as fallback without warning during initial render
+ }
+ return window.i18n.t(key, params);
+ };
+ // Realtime ASR session owns WebSocket, Recorder, PCM streaming, and transcript correction.
+ let realtimeAsrSession = null;
+ let isRec = false;
+ let pendingCorrectionCount = 0;
+ //--------------------页面启动与 Vue 初始化 end------------------
+
+ //------------------- Vue 应用主体定义 start------------------
+ const app = createApp({
+ setup() {
+ //------------------- 响应式状态与实时转写基础状态 start------------------
+ // --- Core State ---
+ // 新增说话人占位
+ let segmentList = [];
+ let lastSpeaker = "";
+ let configwords = "";
+ const currentView = ref("upload"); // 'upload' or 'recording'
+ const dragover = ref(false);
+ const recordings = ref([]);
+ const selectedRecording = ref(null);
+ const selectedTab = ref("summary"); // 'summary' or 'notes'
+ const searchQuery = ref("");
+ const isLoadingRecordings = ref(true);
+ const globalError = ref(null);
+
+ // Advanced filter state
+ const showAdvancedFilters = ref(false);
+ const filterTags = ref([]); // Selected tag IDs for filtering
+ const filterDateRange = ref({ start: "", end: "" });
+ const filterDatePreset = ref(""); // 'today', 'yesterday', 'week', 'month', etc.
+ const filterTextQuery = ref("");
+
+ // --- Pagination State ---
+ const currentPage = ref(1);
+ const perPage = ref(25);
+ const totalRecordings = ref(0);
+ const totalPages = ref(0);
+ const hasNextPage = ref(false);
+ const hasPrevPage = ref(false);
+ const isLoadingMore = ref(false);
+ const searchDebounceTimer = ref(null);
+
+ // --- Enhanced Search & Organization State ---
+ const sortBy = ref("created_at"); // 'created_at' or 'meeting_date'
+ const selectedTagFilter = ref(null); // For filtering by clicked tag
+
+ // --- UI State ---
+ const browser = ref("unknown");
+ const isSidebarCollapsed = ref(false);
+ const searchTipsExpanded = ref(false);
+ const isUserMenuOpen = ref(false);
+ const isDarkMode = ref(false);
+ const currentColorScheme = ref("blue");
+ const showColorSchemeModal = ref(false);
+ const windowWidth = ref(window.innerWidth);
+ const mobileTab = ref("transcript");
+ const isMetadataExpanded = ref(false);
+
+ // --- i18n State ---
+ const currentLanguage = ref("en");
+ const currentLanguageName = ref("English");
+ const availableLanguages = ref([]);
+ const showLanguageMenu = ref(false);
+
+ // --- Upload State ---
+ const uploadQueue = ref([]);
+ const currentlyProcessingFile = ref(null);
+ const processingProgress = ref(0);
+ const processingMessage = ref("");
+ const isProcessingActive = ref(false);
+ const pollInterval = ref(null);
+ const progressPopupMinimized = ref(false);
+ const progressPopupClosed = ref(false);
+ const maxFileSizeMB = ref(250); // Default value, will be updated from API
+ const chunkingEnabled = ref(true); // Default value, will be updated from API
+ const chunkingMode = ref("size"); // 'size' or 'duration', will be updated from API
+ const chunkingLimit = ref(20); // Value in MB or seconds, will be updated from API
+ const chunkingLimitDisplay = ref("20MB"); // Human readable display, will be updated from API
+ const recordingDisclaimer = ref(""); // Recording disclaimer text from admin settings
+ const showRecordingDisclaimerModal = ref(false); // Controls disclaimer modal visibility
+ const pendingRecordingMode = ref(null); // Stores the recording mode while showing disclaimer
+
+ // --- Audio Recording State ---
+ const editingDraftId = ref(null);
+ const editingDraftName = ref('');
+ const draftNameInput = ref(null);
+
+ const isRecording = ref(false);
+ const isStoppingRecording = ref(false);
+ const mediaRecorder = ref(null);
+ const audioChunks = ref([]);
+ const audioBlobURL = ref(null);
+ const recordingTime = ref(0);
+ const recordingInterval = ref(null);
+ const canRecordAudio = ref(
+ navigator.mediaDevices && navigator.mediaDevices.getUserMedia
+ );
+ const canRecordSystemAudio = ref(false);
+ const systemAudioSupported = ref(false);
+ const systemAudioError = ref("");
+ const recordingNotes = ref("");
+ const showSystemAudioHelp = ref(false);
+ // ASR options for recording view
+ const asrLanguage = ref(""); // Empty string for auto-detect
+ const asrMinSpeakers = ref(""); // Empty string for auto-detect
+ const asrMaxSpeakers = ref(""); // Empty string for auto-detect
+ const audioContext = ref(null);
+ const analyser = ref(null);
+ const micAnalyser = ref(null);
+ const systemAnalyser = ref(null);
+ const visualizer = ref(null);
+ const micVisualizer = ref(null);
+ const systemVisualizer = ref(null);
+ const animationFrameId = ref(null);
+ const recordingMode = ref("microphone"); // 'microphone', 'system', or 'both'
+ const activeStreams = ref([]); // Track active streams for cleanup
+
+ // --- Recording Size Monitoring ---
+ const estimatedFileSize = ref(0);
+ const fileSizeWarningShown = ref(false);
+ const recordingQuality = ref("optimized"); // 'optimized', 'standard', 'high'
+ const actualBitrate = ref(0);
+ const maxRecordingMB = ref(200); // Maximum recording size before auto-stop
+ const sizeCheckInterval = ref(null);
+
+ // --- Draft/Pause Recording State ---
+ const isPaused = ref(false);
+ const currentDraftId = ref(null);
+ const pausedDuration = ref(0); // 暂停前的累计时长
+ const pausedTranscription = ref(''); // 暂停前的转录文本
+ const draftRecordings = ref([]); // 草稿列表
+ const draftsExpanded = ref(true); // 草稿列表是否展开
+
+ // Advanced Options for ASR
+ const showAdvancedOptions = ref(false);
+ const uploadLanguage = ref(""); // Empty string for auto-detect
+ const uploadMinSpeakers = ref(""); // Empty string for auto-detect
+ const uploadMaxSpeakers = ref(""); // Empty string for auto-detect
+
+ // Tag Selection
+ const availableTags = ref([]);
+ const selectedTagIds = ref([]); // Changed to array for multiple selection
+ const uploadTagSearchFilter = ref(""); // For filtering tags in upload view
+ const selectedTags = computed(() => {
+ return selectedTagIds.value
+ .map((tagId) => availableTags.value.find((tag) => tag.id == tagId))
+ .filter(Boolean); // Filter out undefined tags
+ });
+ // 自动判断是否为 Markdown
+ const isMarkdown = computed(() => {
+ const v = analysisResult.value;
+ return (
+ v.includes("#") ||
+ v.includes("**") ||
+ v.includes("```") ||
+ v.includes("* ") ||
+ v.includes("- ")
+ );
+ });
+
+ // 解析 Markdown → HTML
+ const renderedMarkdown = Vue.computed(() => {
+ return window.marked.parse(analysisResult.value || "", {
+ breaks: true,
+ });
+ });
+ // Computed property for filtered available tags in upload view
+ const filteredAvailableTagsForUpload = computed(() => {
+ const availableForSelection = availableTags.value.filter(
+ (tag) => !selectedTagIds.value.includes(tag.id)
+ );
+ if (!uploadTagSearchFilter.value) return availableForSelection;
+
+ const filter = uploadTagSearchFilter.value.toLowerCase();
+ return availableForSelection.filter((tag) =>
+ tag.name.toLowerCase().includes(filter)
+ );
+ });
+
+ // --- Modal State ---
+ const showEditModal = ref(false);
+ const showDeleteModal = ref(false);
+ const showEditTagsModal = ref(false);
+ const selectedNewTagId = ref("");
+ const tagSearchFilter = ref(""); // For filtering tags in the modal
+ const showReprocessModal = ref(false);
+ const showResetModal = ref(false);
+ const showSpeakerModal = ref(false);
+ const showShareModal = ref(false);
+ const showSharesListModal = ref(false);
+ const showTextEditorModal = ref(false);
+ const showAsrEditorModal = ref(false);
+ const editingRecording = ref(null);
+ const editingTranscriptionContent = ref("");
+ const editingSegments = ref([]);
+ const availableSpeakers = ref([]);
+ const recordingToShare = ref(null);
+ const shareOptions = reactive({
+ share_summary: true,
+ share_notes: true,
+ });
+ const generatedShareLink = ref("");
+ const existingShareDetected = ref(false);
+ const userShares = ref([]);
+ const isLoadingShares = ref(false);
+ const shareToDelete = ref(null);
+ const showShareDeleteModal = ref(false);
+ const recordingToDelete = ref(null);
+ const recordingToReset = ref(null);
+ const reprocessType = ref(null); // 'transcription' or 'summary'
+ const reprocessRecording = ref(null);
+ const isAutoIdentifying = ref(false);
+ const asrReprocessOptions = reactive({
+ language: "",
+ min_speakers: null,
+ max_speakers: null,
+ });
+ const speakerMap = ref({});
+ const regenerateSummaryAfterSpeakerUpdate = ref(true);
+ const speakerSuggestions = ref({});
+ const loadingSuggestions = ref({});
+ const activeSpeakerInput = ref(null);
+
+ // --- Inline Editing State ---
+ const editingParticipants = ref(false);
+ const editingMeetingDate = ref(false);
+ const editingSummary = ref(false);
+ const editingNotes = ref(false);
+ const tempNotesContent = ref("");
+ const tempSummaryContent = ref("");
+ const autoSaveTimer = ref(null);
+ const autoSaveDelay = 2000; // 2 seconds debounce
+
+ // --- Markdown Editor State ---
+ const notesMarkdownEditor = ref(null);
+ const markdownEditorInstance = ref(null);
+ const summaryMarkdownEditor = ref(null);
+ const summaryMarkdownEditorInstance = ref(null);
+ const recordingNotesEditor = ref(null);
+ const recordingMarkdownEditorInstance = ref(null);
+
+ // --- Transcription State ---
+ const transcriptionViewMode = ref("simple"); // 'simple' or 'bubble'
+ const legendExpanded = ref(false);
+ const highlightedSpeaker = ref(null);
+
+ // --- Chat State ---
+ const showChat = ref(false);
+ const isChatMaximized = ref(false);
+ const chatMessages = ref([]);
+ const chatInput = ref("");
+ const isChatLoading = ref(false);
+ const chatMessagesRef = ref(null);
+
+ // --- Audio Player State ---
+ const playerVolume = ref(1.0);
+
+ // --- Column Resizing State ---
+ const leftColumnWidth = ref(60); // 60% for left column (transcript)
+ const rightColumnWidth = ref(40); // 40% for right column (summary/chat)
+ const isResizing = ref(false);
+
+ // --- App Configuration ---
+ const useAsrEndpoint = ref(false);
+ const currentUserName = ref("");
+ const asrResult = ref("");
+ const asrResultOnline = ref("");
+ const asrResultOffline = ref("");
+ const asrResultTextarea = ref("");
+ const asrResultTextareaOnline = ref("");
+ const sumupResultTextarea = ref("");
+ const baseInfo = ref({ name: "领导发言稿", fontSize: 40 });
+ const isModalOpen = ref(false);
+
+ const asrTextarea = ref(null);
+ const analysisResult = ref("");
+ const isDialogVisible = ref(false);
+ const isyjDialogVisible = ref(false);
+ const lastTimer = ref(0);
+ const urlmsg = ref("获取链接中...");
+ const urlinfo = ref("");
+ const zjloding = ref(false);
+ const showSpeaker = ref(true);
+
+ // --- 转录文本编辑状态 ---
+ const isEditingTranscription = ref(false);
+ const editableText = ref("");
+ const fullRealtimeTranscription = computed(() => {
+ return (
+ (asrResult.value || "") +
+ (asrResultOffline.value || "") +
+ (asrResultOnline.value || "")
+ );
+ });
+ let segmentRenderStartIndex = 0;
+ let liveTranscriptionEpoch = 0;
+ let realtimeTimestampBarrierMs = -1;
+ let realtimeEditCarryover = null;
+ let clearRealtimePipelineRef = null;
+ let getLatestRealtimeTimestampRef = null;
+ let buildRealtimeEditCarryoverRef = null;
+ let renderFullTextRef = null;
+ let editModeBufferStartIndex = 0;
+
+ function startNewRealtimeEpoch({
+ barrierTimestampMs = null,
+ clearTimestampBarrier = false,
+ clearEditCarryover = true,
+ } = {}) {
+ liveTranscriptionEpoch += 1;
+
+ if (clearEditCarryover) {
+ realtimeEditCarryover = null;
+ }
+
+ if (clearTimestampBarrier) {
+ realtimeTimestampBarrierMs = -1;
+ } else if (Number.isFinite(barrierTimestampMs)) {
+ realtimeTimestampBarrierMs = Math.max(
+ realtimeTimestampBarrierMs,
+ barrierTimestampMs
+ );
+ }
+
+ if (clearRealtimePipelineRef) {
+ clearRealtimePipelineRef({
+ preserveTimestampBarrier: !clearTimestampBarrier,
+ });
+ }
+ }
+
+ //--------------------响应式状态与实时转写基础状态 end------------------
+
+ //------------------- 计算属性与派生展示数据 start------------------
+ // --- Computed Properties ---
+ const isMobileScreen = computed(() => {
+ return windowWidth.value < 1024;
+ });
+
+ const datePresetOptions = computed(() => {
+ return [
+ { value: "today", label: t("sidebar.today") },
+ { value: "yesterday", label: t("sidebar.yesterday") },
+ { value: "thisweek", label: t("sidebar.thisWeek") },
+ { value: "lastweek", label: t("sidebar.lastWeek") },
+ { value: "thismonth", label: t("sidebar.thisMonth") },
+ { value: "lastmonth", label: t("sidebar.lastMonth") },
+ ];
+ });
+
+ const languageOptions = computed(() => {
+ return [
+ { value: "", label: t("form.autoDetect") },
+ { value: "en", label: t("languages.en") },
+ { value: "es", label: t("languages.es") },
+ { value: "fr", label: t("languages.fr") },
+ { value: "de", label: t("languages.de") },
+ { value: "it", label: t("languages.it") },
+ { value: "pt", label: t("languages.pt") },
+ { value: "nl", label: t("languages.nl") },
+ { value: "ru", label: t("languages.ru") },
+ { value: "zh", label: t("languages.zh") },
+ { value: "ja", label: t("languages.ja") },
+ { value: "ko", label: t("languages.ko") },
+ ];
+ });
+
+ const filteredRecordings = computed(() => {
+ return recordings.value;
+ });
+
+ const highlightedTranscript = computed(() => {
+ if (!selectedRecording.value?.transcription) return "";
+ let html = selectedRecording.value.transcription;
+ // Escape HTML to prevent injection, but keep it minimal
+ html = html.replace(//g, ">");
+
+ // 1. Replace newlines with tags for proper line breaks in HTML
+ html = html.replace(/\n/g, " ");
+
+ // 2. Get speaker colors from the speakerMap if available
+ const speakerColors = {};
+ if (speakerMap.value) {
+ Object.keys(speakerMap.value).forEach((speaker, index) => {
+ speakerColors[speaker] =
+ speakerMap.value[speaker].color ||
+ `speaker-color-${(index % 8) + 1}`;
+ });
+ }
+
+ // 3. Wrap each speaker tag in a span for styling and interaction with colors
+ html = html.replace(/\[([^\]]+)\]/g, (match, speakerId) => {
+ const isHighlighted = speakerId === highlightedSpeaker.value;
+ const colorClass = speakerColors[speakerId] || "";
+ // Replace speaker ID with name if available
+ const displayName = speakerMap.value[speakerId]?.name || speakerId;
+ const displayText = `[${displayName}]`;
+ // Use a more specific and stylish class structure with color
+ return `${displayText} `;
+ });
+
+ return html;
+ });
+
+ const activeRecordingMetadata = computed(() => {
+ if (!selectedRecording.value) return [];
+
+ const recording = selectedRecording.value;
+ const metadata = [];
+
+ if (recording.created_at) {
+ metadata.push({
+ icon: "fas fa-history",
+ text: formatDisplayDate(recording.created_at),
+ });
+ }
+
+ if (recording.file_size) {
+ metadata.push({
+ icon: "fas fa-file-audio",
+ text: formatFileSize(recording.file_size),
+ });
+ }
+
+ if (recording.duration) {
+ metadata.push({
+ icon: "fas fa-clock",
+ text: formatDuration(recording.duration),
+ });
+ }
+
+ if (recording.original_filename) {
+ const maxLength = 30;
+ const truncated =
+ recording.original_filename.length > maxLength
+ ? recording.original_filename.substring(0, maxLength) + "..."
+ : recording.original_filename;
+ metadata.push({
+ icon: "fas fa-file",
+ text: truncated,
+ fullText: recording.original_filename,
+ });
+ }
+
+ // Add tags to metadata
+ if (recording.tags && recording.tags.length > 0) {
+ metadata.push({
+ icon: "fas fa-tags",
+ text: "", // Empty text since we'll render tags specially
+ tags: recording.tags, // Pass the tags array
+ isTagItem: true, // Flag to identify this as a tag item
+ });
+ }
+
+ return metadata;
+ });
+
+ const groupedRecordings = computed(() => {
+ // Sort recordings based on the selected sort criteria
+ const sortedRecordings = [...filteredRecordings.value].sort((a, b) => {
+ const dateA = getDateForSorting(a);
+ const dateB = getDateForSorting(b);
+
+ // Handle null dates (put them at the end)
+ if (!dateA && !dateB) return 0;
+ if (!dateA) return 1;
+ if (!dateB) return -1;
+
+ return dateB - dateA; // Most recent first
+ });
+
+ const groups = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ lastWeek: [],
+ thisMonth: [],
+ lastMonth: [],
+ older: [],
+ };
+
+ sortedRecordings.forEach((recording) => {
+ const date = getDateForSorting(recording);
+ if (!date) {
+ groups.older.push(recording);
+ return;
+ }
+
+ if (isToday(date)) {
+ groups.today.push(recording);
+ } else if (isYesterday(date)) {
+ groups.yesterday.push(recording);
+ } else if (isThisWeek(date)) {
+ groups.thisWeek.push(recording);
+ } else if (isLastWeek(date)) {
+ groups.lastWeek.push(recording);
+ } else if (isThisMonth(date)) {
+ groups.thisMonth.push(recording);
+ } else if (isLastMonth(date)) {
+ groups.lastMonth.push(recording);
+ } else {
+ groups.older.push(recording);
+ }
+ });
+
+ return [
+ { title: t("sidebar.today"), items: groups.today },
+ { title: t("sidebar.yesterday"), items: groups.yesterday },
+ { title: t("sidebar.thisWeek"), items: groups.thisWeek },
+ { title: t("sidebar.lastWeek"), items: groups.lastWeek },
+ { title: t("sidebar.thisMonth"), items: groups.thisMonth },
+ { title: t("sidebar.lastMonth"), items: groups.lastMonth },
+ { title: t("sidebar.older"), items: groups.older },
+ ].filter((g) => g.items.length > 0);
+ });
+
+ const totalInQueue = computed(() => uploadQueue.value.length);
+ const completedInQueue = computed(
+ () =>
+ uploadQueue.value.filter(
+ (item) => item.status === "completed" || item.status === "failed"
+ ).length
+ );
+ const finishedFilesInQueue = computed(() =>
+ uploadQueue.value.filter((item) =>
+ ["completed", "failed"].includes(item.status)
+ )
+ );
+ const showRecordingControls = computed(() => {
+ if (currentView.value !== "recording") {
+ return false;
+ }
+
+ const hasCompletedRecording =
+ !isRecording.value && !isPaused.value && !!audioBlobURL.value;
+
+ if (hasCompletedRecording) {
+ return false;
+ }
+
+ if (isRecording.value || isPaused.value || isStoppingRecording.value) {
+ return true;
+ }
+
+ return (
+ !audioBlobURL.value &&
+ (activeStreams.value.length > 0 ||
+ recordingTime.value > 0 ||
+ !!currentDraftId.value)
+ );
+ });
+
+ const clearCompletedUploads = () => {
+ uploadQueue.value = uploadQueue.value.filter(
+ (item) => !["completed", "failed"].includes(item.status)
+ );
+ };
+
+ const identifiedSpeakers = computed(() => {
+ // Ensure we have a valid recording and transcription
+ if (!selectedRecording.value?.transcription) {
+ return [];
+ }
+
+ const transcription = selectedRecording.value.transcription;
+ let transcriptionData;
+
+ try {
+ transcriptionData = JSON.parse(transcription);
+ } catch (e) {
+ transcriptionData = null;
+ }
+
+ // Updated to handle new simplified JSON format (array of segments)
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ // JSON format - extract speakers in order of appearance
+ const speakersInOrder = [];
+ const seenSpeakers = new Set();
+ transcriptionData.forEach((segment) => {
+ if (
+ segment.speaker &&
+ String(segment.speaker).trim() &&
+ !seenSpeakers.has(segment.speaker)
+ ) {
+ seenSpeakers.add(segment.speaker);
+ speakersInOrder.push(segment.speaker);
+ }
+ });
+ return speakersInOrder; // Keep order of appearance, don't sort
+ } else if (typeof transcription === "string") {
+ // Plain text format - find speakers in order of appearance
+ const speakerRegex = /\[([^\]]+)\]:/g;
+ const speakersInOrder = [];
+ const seenSpeakers = new Set();
+ let match;
+ while ((match = speakerRegex.exec(transcription)) !== null) {
+ const speaker = match[1].trim();
+ if (speaker && !seenSpeakers.has(speaker)) {
+ seenSpeakers.add(speaker);
+ speakersInOrder.push(speaker);
+ }
+ }
+ return speakersInOrder; // Keep order of appearance, don't sort
+ }
+ return [];
+ });
+
+ // identifiedSpeakersInOrder is now just an alias since identifiedSpeakers already preserves order
+ const identifiedSpeakersInOrder = computed(() => {
+ return identifiedSpeakers.value;
+ });
+
+ const hasSpeakerNames = computed(() => {
+ // Check if any speaker has a non-empty name
+ return Object.values(speakerMap.value).some(
+ (speakerData) => speakerData.name && speakerData.name.trim() !== ""
+ );
+ });
+
+ const processedTranscription = computed(() => {
+ if (!selectedRecording.value?.transcription) {
+ return {
+ hasDialogue: false,
+ content: "",
+ speakers: [],
+ simpleSegments: [],
+ bubbleRows: [],
+ };
+ }
+
+ const transcription = selectedRecording.value.transcription;
+ let transcriptionData;
+
+ try {
+ transcriptionData = JSON.parse(transcription);
+ } catch (e) {
+ transcriptionData = null;
+ }
+
+ // Handle new simplified JSON format (array of segments)
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ const wasDiarized = transcriptionData.some(
+ (segment) => segment.speaker
+ );
+
+ if (!wasDiarized) {
+ const segments = transcriptionData.map((segment) => ({
+ sentence: segment.sentence,
+ startTime: segment.start_time,
+ }));
+ return {
+ hasDialogue: false,
+ isJson: true,
+ content: segments.map((s) => s.sentence).join("\n"),
+ simpleSegments: segments,
+ speakers: [],
+ bubbleRows: [],
+ };
+ }
+
+ // Extract unique speakers
+ const speakers = [
+ ...new Set(
+ transcriptionData
+ .map((segment) => segment.speaker)
+ .filter(Boolean)
+ ),
+ ];
+ const speakerColors = {};
+ speakers.forEach((speaker, index) => {
+ speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
+ });
+
+ const simpleSegments = transcriptionData.map((segment) => ({
+ speakerId: segment.speaker,
+ speaker: speakerMap.value[segment.speaker]?.name || segment.speaker,
+ sentence: segment.sentence,
+ startTime: segment.start_time || segment.startTime,
+ endTime: segment.end_time || segment.endTime,
+ color: speakerColors[segment.speaker] || "speaker-color-1",
+ }));
+
+ const processedSimpleSegments = [];
+ let lastSpeakerId = null;
+ simpleSegments.forEach((segment) => {
+ processedSimpleSegments.push({
+ ...segment,
+ showSpeaker: segment.speakerId !== lastSpeakerId,
+ });
+ lastSpeakerId = segment.speakerId;
+ });
+
+ const bubbleRows = [];
+ let lastBubbleSpeakerId = null;
+ simpleSegments.forEach((segment) => {
+ if (
+ bubbleRows.length === 0 ||
+ segment.speakerId !== lastBubbleSpeakerId
+ ) {
+ bubbleRows.push({
+ speaker: segment.speaker,
+ color: segment.color,
+ isMe:
+ segment.speaker &&
+ typeof segment.speaker === "string" &&
+ segment.speaker.toLowerCase().includes("me"),
+ bubbles: [],
+ });
+ lastBubbleSpeakerId = segment.speakerId;
+ }
+ bubbleRows[bubbleRows.length - 1].bubbles.push({
+ sentence: segment.sentence,
+ startTime: segment.startTime || segment.start_time,
+ color: segment.color,
+ });
+ });
+
+ return {
+ hasDialogue: true,
+ isJson: true,
+ segments: simpleSegments,
+ simpleSegments: processedSimpleSegments,
+ bubbleRows: bubbleRows,
+ speakers: speakers.map((speaker) => ({
+ name: speakerMap.value[speaker]?.name || speaker,
+ color: speakerColors[speaker],
+ })),
+ };
+ } else {
+ // Fallback for plain text transcription
+ const speakerRegex = /\[([^\]]+)\]:\s*/g;
+ const hasDialogue = speakerRegex.test(transcription);
+
+ if (!hasDialogue) {
+ return {
+ hasDialogue: false,
+ isJson: false,
+ content: transcription,
+ speakers: [],
+ simpleSegments: [],
+ bubbleRows: [],
+ };
+ }
+
+ speakerRegex.lastIndex = 0;
+ const speakers = new Set();
+ let match;
+ while ((match = speakerRegex.exec(transcription)) !== null) {
+ speakers.add(match[1]);
+ }
+
+ const speakerList = Array.from(speakers);
+ const speakerColors = {};
+ speakerList.forEach((speaker, index) => {
+ speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
+ });
+
+ const segments = [];
+ const lines = transcription.split("\n");
+ let currentSpeakerId = null;
+ let currentText = "";
+
+ for (const line of lines) {
+ const speakerMatch = line.match(/^\[([^\]]+)\]:\s*(.*)$/);
+ if (speakerMatch) {
+ if (currentSpeakerId && currentText.trim()) {
+ segments.push({
+ speakerId: currentSpeakerId,
+ speaker:
+ speakerMap.value[currentSpeakerId]?.name ||
+ currentSpeakerId,
+ sentence: currentText.trim(),
+ color: speakerColors[currentSpeakerId] || "speaker-color-1",
+ });
+ }
+ currentSpeakerId = speakerMatch[1];
+ currentText = speakerMatch[2];
+ } else if (currentSpeakerId && line.trim()) {
+ currentText += " " + line.trim();
+ } else if (!currentSpeakerId && line.trim()) {
+ segments.push({
+ speakerId: null,
+ speaker: null,
+ sentence: line.trim(),
+ color: "speaker-color-1",
+ });
+ }
+ }
+
+ if (currentSpeakerId && currentText.trim()) {
+ segments.push({
+ speakerId: currentSpeakerId,
+ speaker:
+ speakerMap.value[currentSpeakerId]?.name || currentSpeakerId,
+ sentence: currentText.trim(),
+ color: speakerColors[currentSpeakerId] || "speaker-color-1",
+ });
+ }
+
+ const simpleSegments = [];
+ let lastSpeakerId = null;
+ segments.forEach((segment) => {
+ simpleSegments.push({
+ ...segment,
+ showSpeaker: segment.speakerId !== lastSpeakerId,
+ sentence: segment.sentence || segment.text,
+ });
+ lastSpeakerId = segment.speakerId;
+ });
+
+ const bubbleRows = [];
+ let currentRow = null;
+ segments.forEach((segment) => {
+ if (!currentRow || currentRow.speakerId !== segment.speakerId) {
+ if (currentRow) bubbleRows.push(currentRow);
+ currentRow = {
+ speakerId: segment.speakerId,
+ speaker: segment.speaker,
+ color: segment.color,
+ bubbles: [],
+ isMe:
+ segment.speaker &&
+ segment.speaker.toLowerCase().includes("me"),
+ };
+ }
+ currentRow.bubbles.push({
+ sentence: segment.sentence,
+ color: segment.color,
+ });
+ });
+ if (currentRow) bubbleRows.push(currentRow);
+
+ return {
+ hasDialogue: true,
+ isJson: false,
+ segments: segments,
+ simpleSegments: simpleSegments,
+ bubbleRows: bubbleRows,
+ speakers: speakerList.map((speaker) => ({
+ name: speakerMap.value[speaker]?.name || speaker,
+ color: speakerColors[speaker] || "speaker-color-1",
+ })),
+ };
+ }
+ });
+
+ //--------------------计算属性与派生展示数据 end------------------
+
+ //------------------- 主题与配色配置 start------------------
+ // --- Color Scheme Management ---
+ const colorSchemes = {
+ light: [
+ {
+ id: "blue",
+ name: "Ocean Blue",
+ description: "Classic blue theme with professional appeal",
+ class: "",
+ },
+ {
+ id: "emerald",
+ name: "Forest Emerald",
+ description: "Fresh green theme for a natural feel",
+ class: "theme-light-emerald",
+ },
+ {
+ id: "purple",
+ name: "Royal Purple",
+ description: "Elegant purple theme with sophistication",
+ class: "theme-light-purple",
+ },
+ {
+ id: "rose",
+ name: "Sunset Rose",
+ description: "Warm pink theme with gentle energy",
+ class: "theme-light-rose",
+ },
+ {
+ id: "amber",
+ name: "Golden Amber",
+ description: "Warm yellow theme for brightness",
+ class: "theme-light-amber",
+ },
+ {
+ id: "teal",
+ name: "Ocean Teal",
+ description: "Cool teal theme for tranquility",
+ class: "theme-light-teal",
+ },
+ ],
+ dark: [
+ {
+ id: "blue",
+ name: "Midnight Blue",
+ description: "Deep blue theme for focused work",
+ class: "",
+ },
+ {
+ id: "emerald",
+ name: "Dark Forest",
+ description: "Rich green theme for comfortable viewing",
+ class: "theme-dark-emerald",
+ },
+ {
+ id: "purple",
+ name: "Deep Purple",
+ description: "Mysterious purple theme for creativity",
+ class: "theme-dark-purple",
+ },
+ {
+ id: "rose",
+ name: "Dark Rose",
+ description: "Muted pink theme with subtle warmth",
+ class: "theme-dark-rose",
+ },
+ {
+ id: "amber",
+ name: "Dark Amber",
+ description: "Warm brown theme for cozy sessions",
+ class: "theme-dark-amber",
+ },
+ {
+ id: "teal",
+ name: "Deep Teal",
+ description: "Dark teal theme for calm focus",
+ class: "theme-dark-teal",
+ },
+ ],
+ };
+
+ //--------------------主题与配色配置 end------------------
+
+ //------------------- 通用工具方法与格式化逻辑 start------------------
+ // --- Utility Methods ---
+ const openmodel = async (e) => {
+ urlmsg.value = "获取链接中...";
+ isModalOpen.value = true;
+ let urls = `/tool/speakr/api/external/summarize_text`;
+ const response = await fetch(urls, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: asrResult.value,
+ prompt_id: e ? e.id : "",
+ }),
+ });
+ const data = await response.json();
+
+ if (response) {
+ urlinfo.value = data.string;
+ urlmsg.value = "点击打开投屏页面";
+ console.log(data.value, "urlinfo.value", response, data);
+ }
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to reset status");
+ }
+ };
+ const openzjmodel = async () => {
+ zjloding.value = true;
+ let urls = `/tool/speakr/api/external/submit`;
+ try {
+ const response = await fetch(urls, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: analysisResult.value,
+ }),
+ });
+ const data = await response.json();
+
+ if (response) {
+ window.open(data.string, "_blank");
+ zjloding.value = false;
+ }
+ if (!response.ok) {
+ zjloding.value = false;
+
+ throw new Error(data.error || "Failed to reset status");
+ }
+ } catch (error) {
+ console.error("接口请求失败:", error);
+ zjloding.value = false;
+ }
+ };
+ const closeModal = () => {
+ isModalOpen.value = false;
+ };
+ const setGlobalError = (message, duration = 7000) => {
+ globalError.value = message;
+ if (duration > 0) {
+ setTimeout(() => {
+ if (globalError.value === message) globalError.value = null;
+ }, duration);
+ }
+ };
+
+ const formatFileSize = (bytes) => {
+ if (bytes == null || bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ if (bytes < 0) bytes = 0;
+ const i =
+ bytes === 0
+ ? 0
+ : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
+ const size =
+ i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
+ return size + " " + sizes[i];
+ };
+
+ const formatDisplayDate = (dateString) => {
+ if (!dateString) return "";
+ try {
+ // Try to parse the date string directly first (it might already be formatted)
+ let date = new Date(dateString);
+ // If that fails or results in invalid date, try different approaches
+ if (isNaN(date.getTime())) {
+ // Try appending time if it looks like a date-only string (YYYY-MM-DD format)
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
+ date = new Date(dateString + "T00:00:00");
+ } else {
+ // If it's already a formatted string, just return it
+ return dateString;
+ }
+ }
+
+ // If we still have an invalid date, return the original string
+ if (isNaN(date.getTime())) {
+ return dateString;
+ }
+
+ return date.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ } catch (e) {
+ console.error("Error formatting date:", e);
+ return dateString;
+ }
+ };
+
+ const formatStatus = (status) => {
+ if (!status || status === "COMPLETED") return "";
+ const statusMap = {
+ PENDING: t("status.queued"),
+ PROCESSING: t("status.processing"),
+ TRANSCRIBING: t("status.transcribing"),
+ SUMMARIZING: t("status.summarizing"),
+ FAILED: t("status.failed"),
+ UPLOADING: t("status.uploading"),
+ };
+ return (
+ statusMap[status] ||
+ status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()
+ );
+ };
+
+ const getStatusClass = (status) => {
+ switch (status) {
+ case "PENDING":
+ return "status-pending";
+ case "PROCESSING":
+ return "status-processing";
+ case "SUMMARIZING":
+ return "status-summarizing";
+ case "COMPLETED":
+ return "";
+ case "FAILED":
+ return "status-failed";
+ default:
+ return "status-pending";
+ }
+ };
+
+ const formatTime = (seconds) => {
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${minutes.toString().padStart(2, "0")}:${secs
+ .toString()
+ .padStart(2, "0")}`;
+ };
+
+ const formatDuration = (totalSeconds) => {
+ if (totalSeconds == null || totalSeconds < 0) return "N/A";
+
+ if (totalSeconds < 1) {
+ return `${totalSeconds.toFixed(2)} seconds`;
+ }
+
+ totalSeconds = Math.round(totalSeconds);
+
+ if (totalSeconds < 60) {
+ return `${totalSeconds} sec`;
+ }
+
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ let parts = [];
+ if (hours > 0) {
+ parts.push(`${hours} hr`);
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes} min`);
+ }
+ // Only show seconds if duration is less than an hour
+ if (hours === 0 && seconds > 0) {
+ parts.push(`${seconds} sec`);
+ }
+
+ return parts.join(" ");
+ };
+
+ const createLocalizedRecordingFilename = () => {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ return `录音_${timestamp}.webm`;
+ };
+
+ const getDownloadFilenameFromHeaders = (
+ contentDisposition,
+ fallbackFilename
+ ) => {
+ if (!contentDisposition) {
+ return fallbackFilename;
+ }
+
+ const utf8Match = /filename\*=utf-8''([^;]+)/i.exec(contentDisposition);
+ if (utf8Match && utf8Match[1]) {
+ return decodeURIComponent(utf8Match[1]);
+ }
+
+ const regularMatch = /filename="([^"]+)"/i.exec(contentDisposition);
+ if (regularMatch && regularMatch[1]) {
+ return regularMatch[1];
+ }
+
+ return fallbackFilename;
+ };
+
+ //--------------------通用工具方法与格式化逻辑 end------------------
+
+ //------------------- 录音体积监控逻辑 start------------------
+ // --- Recording Size Monitoring Functions ---
+ const updateFileSizeEstimate = () => {
+ if (!isRecording.value || !actualBitrate.value) return;
+
+ // Calculate estimated size based on recording time and bitrate
+ const recordingTimeSeconds = recordingTime.value;
+ const estimatedBits = actualBitrate.value * recordingTimeSeconds;
+ const estimatedBytes = estimatedBits / 8;
+ estimatedFileSize.value = estimatedBytes;
+
+ // Check if we're approaching the size limit
+ const sizeMB = estimatedBytes / (1024 * 1024);
+ const warningThresholdMB = maxRecordingMB.value * 0.8; // 80% of max size
+
+ if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) {
+ fileSizeWarningShown.value = true;
+ showToast(
+ `Recording size is ${formatFileSize(
+ estimatedBytes
+ )}. Consider stopping soon to avoid auto-stop at ${
+ maxRecordingMB.value
+ }MB.`,
+ "fa-exclamation-triangle",
+ 5000
+ );
+ }
+
+ // Auto-stop if we exceed the maximum size
+ if (sizeMB > maxRecordingMB.value) {
+ console.log(
+ `Auto-stopping recording: size ${formatFileSize(
+ estimatedBytes
+ )} exceeds limit of ${maxRecordingMB.value}MB`
+ );
+ stopRecording();
+ showToast(
+ `Recording automatically stopped at ${formatFileSize(
+ estimatedBytes
+ )} to prevent excessive file size.`,
+ "fa-stop-circle",
+ 7000
+ );
+ }
+ };
+
+ const startSizeMonitoring = () => {
+ if (sizeCheckInterval.value) {
+ clearInterval(sizeCheckInterval.value);
+ }
+
+ // Reset size monitoring state
+ estimatedFileSize.value = 0;
+ fileSizeWarningShown.value = false;
+
+ // Start monitoring every 5 seconds
+ sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 5000);
+ };
+
+ const stopSizeMonitoring = () => {
+ if (sizeCheckInterval.value) {
+ clearInterval(sizeCheckInterval.value);
+ sizeCheckInterval.value = null;
+ }
+ };
+ //--------------------录音体积监控逻辑 end------------------
+
+ //------------------- 总结提示词与外部分析流程 start------------------
+ const promptVisible = ref(false);
+ const promptList = ref([]);
+ const recommendPromptId = ref(null);
+ const recommendPromptName = ref("");
+ const recommendPromptReason = ref("");
+ const selectedPromptId = ref(
+ localStorage.getItem("selected_prompt_id") || null
+ );
+ // const selectedPromptId = ref(null)
+ let modelTypeForP = "";
+ /** 打开弹框 */
+ const openPromptModal = (e) => {
+ modelTypeForP = e;
+ recommendPromptId.value = null;
+ recommendPromptName.value = "";
+ recommendPromptReason.value = "";
+ promptVisible.value = true;
+ getPromptList(e);
+ getRecommend();
+ };
+
+ /** 关闭弹框 */
+ const closePromptModal = () => {
+ promptVisible.value = false;
+ };
+
+ /** 获取 Prompt 列表 */
+ const getPromptList = async (e) => {
+ try {
+ const res = await fetch(
+ `/tool/speakr/api/external/prompts/list`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ skip: 0, limit: 999 }),
+ }
+ );
+
+ const data = await res.json();
+ promptList.value = data.prompt_list || [];
+ } catch (e) {
+ console.error("提示词加载失败", e);
+ }
+ };
+ /** 根据文本获取Prompt推荐 */
+ const getRecommend = async (e) => {
+ try {
+ const res = await fetch(
+ `/tool/speakr/api/external/recommend_prompts`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ input_str: asrResult.value }),
+ }
+ );
+
+ const data = await res.json();
+ recommendPromptId.value = data.id;
+ recommendPromptName.value = data.name;
+ recommendPromptReason.value = data.reason;
+ } catch (e) {
+ console.error("获取推荐失败", e);
+ }
+ };
+ /** 确认选择 */
+ const confirmPromptSelection = () => {
+ if (selectedPromptId.value) {
+ localStorage.setItem("selected_prompt_id", selectedPromptId.value);
+ }
+
+ const selectedPrompt = promptList.value.find(
+ (i) => i.id == selectedPromptId.value
+ );
+ console.log("选中的提示词:", selectedPrompt);
+ if (modelTypeForP === "zj") {
+ sumupResult(selectedPrompt);
+ } else {
+ openmodel(selectedPrompt);
+ }
+
+ // emit("promptSelected", selectedPrompt)
+
+ promptVisible.value = false;
+ };
+ const sumupResult = async (e) => {
+ isDialogVisible.value = true;
+ // if (!asrResult.value) return;
+
+ try {
+ analysisResult.value = "思考中...";
+
+ const response = await fetch(
+ `/tool/speakr/api/external/summarize_text_stream`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: asrResult.value,
+ // input_str: '香港火灾情况更新** 香港特区政府召开新闻发布会,通报火灾的搜救与安置最新进展。警方表示,已完成5座大厦的搜索,其余2座仍在进行中。至当天16时,火灾已造成 **156人遇难**,目前有约35人参与搜救工作,其中5人连续工作。2. **对话内容提及天气** 有用户询问“今天天气怎么样”,地点为“安康”,表明对话中涉及对当地天气的关注。3. **对话片段含非正式表达** 有用户发言“我是走开始啊不是”,语句不通顺,可能是口语化表达或输入错误,语义不明确。',
+
+ prompt_id: e ? e.id : "",
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to create share link");
+ }
+
+ // 重置结果并开始流式处理
+ analysisResult.value = "";
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ // 解码并处理流数据
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split("\n");
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.content) {
+ analysisResult.value += data.content;
+ }
+ if (data.finished) {
+ console.log(analysisResult.value, "analysisResult.value");
+
+ showToast("总结完成!", "fa-check-circle");
+ break;
+ }
+ } catch (e) {
+ console.log("解析流数据出错:", e);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ setGlobalError(`Failed to create share link: ${error.message}`);
+ }
+ };
+ //--------------------总结提示词与外部分析流程 end------------------
+
+ //------------------- 日期分组与排序工具 start------------------
+ // --- Enhanced Date Utility Functions ---
+ const getDateForSorting = (recording) => {
+ const dateStr =
+ sortBy.value === "meeting_date"
+ ? recording.meeting_date
+ : recording.created_at;
+ if (!dateStr) return null;
+ return new Date(dateStr);
+ };
+
+ const isToday = (date) => {
+ const today = new Date();
+ return isSameDay(date, today);
+ };
+
+ const isYesterday = (date) => {
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ return isSameDay(date, yesterday);
+ };
+
+ const isThisWeek = (date) => {
+ const now = new Date();
+ const startOfWeek = new Date(now);
+ const day = now.getDay();
+ const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
+ startOfWeek.setDate(diff);
+ startOfWeek.setHours(0, 0, 0, 0);
+
+ const endOfWeek = new Date(startOfWeek);
+ endOfWeek.setDate(startOfWeek.getDate() + 6);
+ endOfWeek.setHours(23, 59, 59, 999);
+
+ return date >= startOfWeek && date <= endOfWeek;
+ };
+
+ const isLastWeek = (date) => {
+ const now = new Date();
+ const startOfLastWeek = new Date(now);
+ const day = now.getDay();
+ const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
+ startOfLastWeek.setDate(diff);
+ startOfLastWeek.setHours(0, 0, 0, 0);
+
+ const endOfLastWeek = new Date(startOfLastWeek);
+ endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
+ endOfLastWeek.setHours(23, 59, 59, 999);
+
+ return date >= startOfLastWeek && date <= endOfLastWeek;
+ };
+
+ const isThisMonth = (date) => {
+ const now = new Date();
+ return (
+ date.getFullYear() === now.getFullYear() &&
+ date.getMonth() === now.getMonth()
+ );
+ };
+
+ const isLastMonth = (date) => {
+ const now = new Date();
+ const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ return (
+ date.getFullYear() === lastMonth.getFullYear() &&
+ date.getMonth() === lastMonth.getMonth()
+ );
+ };
+
+ const isSameDay = (date1, date2) => {
+ return (
+ date1.getFullYear() === date2.getFullYear() &&
+ date1.getMonth() === date2.getMonth() &&
+ date1.getDate() === date2.getDate()
+ );
+ };
+
+ //--------------------日期分组与排序工具 end------------------
+
+ //------------------- 录音详情高级操作 start------------------
+ const reprocessTranscription = (recordingId) => {
+ const recording =
+ recordings.value.find((r) => r.id === recordingId) ||
+ selectedRecording.value;
+ confirmReprocess("transcription", recording);
+ };
+
+ const reprocessSummary = (recordingId) => {
+ const recording =
+ recordings.value.find((r) => r.id === recordingId) ||
+ selectedRecording.value;
+ confirmReprocess("summary", recording);
+ };
+
+ const generateSummary = async () => {
+ if (!selectedRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/generate_summary`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken,
+ },
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to generate summary");
+ }
+
+ // Update the recording status to show it's being processed
+ selectedRecording.value.status = "SUMMARIZING";
+
+ // Also update in recordings list if it exists
+ const recordingInList = recordings.value.find(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (recordingInList) {
+ recordingInList.status = "SUMMARIZING";
+ }
+
+ showToast("Summary generation started", "success");
+ } catch (error) {
+ console.error("Error generating summary:", error);
+ setGlobalError(`Failed to generate summary: ${error.message}`);
+ }
+ };
+
+ const resetRecordingStatus = async (recordingId) => {
+ const recording = recordings.value.find((r) => r.id === recordingId);
+ if (!recording) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/reset_status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to reset status");
+ }
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ if (selectedRecording.value?.id === recording.id) {
+ selectedRecording.value = data.recording;
+ }
+ showToast("Recording status has been reset.", "fa-check-circle");
+ } catch (error) {
+ setGlobalError(`Failed to reset status: ${error.message}`);
+ }
+ };
+
+ const confirmReprocess = (type, recording) => {
+ reprocessType.value = type;
+ reprocessRecording.value = recording;
+ showReprocessModal.value = true;
+ };
+
+ const openTranscriptionEditor = () => {
+ if (processedTranscription.value.isJson) {
+ openAsrEditorModal();
+ } else {
+ openTextEditorModal();
+ }
+ };
+
+ const openTextEditorModal = () => {
+ if (!selectedRecording.value) return;
+ editingTranscriptionContent.value =
+ selectedRecording.value.transcription;
+ showTextEditorModal.value = true;
+ };
+
+ const closeTextEditorModal = () => {
+ showTextEditorModal.value = false;
+ editingTranscriptionContent.value = "";
+ };
+
+ const saveTranscription = async () => {
+ if (!selectedRecording.value) return;
+ await saveTranscriptionContent(editingTranscriptionContent.value);
+ closeTextEditorModal();
+ };
+
+ const openAsrEditorModal = async () => {
+ if (!selectedRecording.value) return;
+ try {
+ const segments = JSON.parse(selectedRecording.value.transcription);
+ editingSegments.value = segments.map((s, i) => ({
+ ...s,
+ id: i,
+ showSuggestions: false,
+ filteredSpeakers: [],
+ }));
+
+ // Populate available speakers
+ const speakersInTranscript = [
+ ...new Set(segments.map((s) => s.speaker)),
+ ];
+ const response = await fetch("/tool/speakr/speakers");
+ const speakersFromDb = await response.json();
+ const speakerNamesFromDb = speakersFromDb.map((s) => s.name);
+ availableSpeakers.value = [
+ ...new Set([...speakersInTranscript, ...speakerNamesFromDb]),
+ ].sort();
+
+ showAsrEditorModal.value = true;
+ } catch (e) {
+ console.error(
+ "Could not parse transcription as JSON for ASR editor:",
+ e
+ );
+ setGlobalError(
+ "This transcription is not in the correct format for the ASR editor."
+ );
+ }
+ };
+
+ const closeAsrEditorModal = () => {
+ showAsrEditorModal.value = false;
+ editingSegments.value = [];
+ availableSpeakers.value = [];
+ };
+
+ const saveAsrTranscription = async () => {
+ const contentToSave = JSON.stringify(
+ editingSegments.value.map(
+ ({ id, showSuggestions, filteredSpeakers, ...rest }) => rest
+ )
+ );
+ await saveTranscriptionContent(contentToSave);
+ closeAsrEditorModal();
+ };
+
+ const adjustTime = (index, field, amount) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index][field] = parseFloat(
+ (editingSegments.value[index][field] + amount).toFixed(3)
+ );
+ }
+ };
+
+ const filterSpeakers = (index) => {
+ const segment = editingSegments.value[index];
+ if (segment) {
+ const query = segment.speaker.toLowerCase();
+ segment.filteredSpeakers = availableSpeakers.value.filter((s) =>
+ s.toLowerCase().includes(query)
+ );
+ }
+ };
+
+ const openSpeakerSuggestions = (index) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].showSuggestions = true;
+ filterSpeakers(index);
+ }
+ };
+
+ const closeSpeakerSuggestions = (index) => {
+ if (editingSegments.value[index]) {
+ window.setTimeout(() => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].showSuggestions = false;
+ }
+ }, 200); // Delay to allow click event to register
+ }
+ };
+
+ const selectSpeaker = (index, speaker) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].speaker = speaker;
+ editingSegments.value[index].showSuggestions = false;
+ }
+ };
+
+ const addSegment = () => {
+ const lastSegment =
+ editingSegments.value[editingSegments.value.length - 1];
+ editingSegments.value.push({
+ id: Date.now(),
+ speaker: lastSegment ? lastSegment.speaker : "SPEAKER_00",
+ start_time: lastSegment ? lastSegment.end_time : 0,
+ end_time: lastSegment ? lastSegment.end_time + 1 : 1,
+ sentence: "",
+ });
+ };
+
+ const removeSegment = (index) => {
+ editingSegments.value.splice(index, 1);
+ };
+
+ const saveTranscriptionContent = async (content) => {
+ if (!selectedRecording.value) return;
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/update_transcription`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ transcription: content }),
+ }
+ );
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update transcription");
+ const index = recordings.value.findIndex(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ selectedRecording.value = data.recording;
+ showToast("Transcription updated successfully!", "fa-check-circle");
+ } catch (error) {
+ console.error("Save Transcription Error:", error);
+ setGlobalError(`Failed to save transcription: ${error.message}`);
+ }
+ };
+
+ const cancelReprocess = () => {
+ showReprocessModal.value = false;
+ reprocessType.value = null;
+ reprocessRecording.value = null;
+ };
+
+ const executeReprocess = async () => {
+ if (!reprocessRecording.value || !reprocessType.value) return;
+
+ const recordingId = reprocessRecording.value.id;
+ const type = reprocessType.value;
+
+ // Close the modal first
+ cancelReprocess();
+
+ if (type === "transcription") {
+ await performReprocessTranscription(
+ recordingId,
+ asrReprocessOptions.language,
+ asrReprocessOptions.min_speakers,
+ asrReprocessOptions.max_speakers
+ );
+ } else if (type === "summary") {
+ await performReprocessSummary(recordingId);
+ }
+ };
+
+ const performReprocessTranscription = async (
+ recordingId,
+ language,
+ minSpeakers,
+ maxSpeakers
+ ) => {
+ if (!recordingId) {
+ setGlobalError("No recording ID provided for reprocessing.");
+ return;
+ }
+
+ try {
+ const payload = {
+ language: language,
+ min_speakers: minSpeakers,
+ max_speakers: maxSpeakers,
+ };
+
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reprocess_transcription`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to start transcription reprocessing"
+ );
+
+ // Ensure the recording status is properly set to PROCESSING
+ if (data.recording && data.recording.status !== "PROCESSING") {
+ console.warn(
+ `Warning: Reprocess transcription returned unexpected status: ${data.recording.status}`
+ );
+ data.recording.status = "PROCESSING";
+ }
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Transcription reprocessing started", "fa-sync-alt");
+
+ // Switch to Summary tab to show progress
+ selectedTab.value = "summary";
+
+ // Show progress modal for reprocessing
+ showProgressModalForReprocessing(recordingId, "transcription");
+
+ // Start polling for status updates
+ startReprocessingPoll(recordingId);
+ } catch (error) {
+ console.error("Reprocess Transcription Error:", error);
+ setGlobalError(
+ `Failed to start transcription reprocessing: ${error.message}`
+ );
+ }
+ };
+
+ const performReprocessSummary = async (recordingId) => {
+ if (!recordingId) {
+ setGlobalError("No recording ID provided for reprocessing.");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reprocess_summary`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to start summary reprocessing"
+ );
+
+ // Ensure the recording status is properly set to SUMMARIZING
+ if (data.recording && data.recording.status !== "SUMMARIZING") {
+ console.warn(
+ `Warning: Reprocess summary returned unexpected status: ${data.recording.status}`
+ );
+ data.recording.status = "SUMMARIZING";
+ }
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Summary reprocessing started", "fa-sync-alt");
+
+ // Switch to Summary tab to show progress
+ selectedTab.value = "summary";
+
+ // Show progress modal for reprocessing
+ showProgressModalForReprocessing(recordingId, "summary");
+
+ // Start polling for status updates
+ startReprocessingPoll(recordingId);
+ } catch (error) {
+ console.error("Reprocess Summary Error:", error);
+ setGlobalError(
+ `Failed to start summary reprocessing: ${error.message}`
+ );
+ }
+ };
+
+ // Show progress modal for reprocessing operations
+ const showProgressModalForReprocessing = (recordingId, type) => {
+ const recording = recordings.value.find((r) => r.id === recordingId);
+ if (!recording) return;
+
+ // Create a mock file item for the progress modal
+ const reprocessItem = {
+ file: {
+ name: recording.title || `Recording ${recordingId}`,
+ size: recording.file_size || 0,
+ },
+ status: "pending",
+ recordingId: recordingId,
+ clientId: `reprocess-${type}-${recordingId}-${Date.now()}`,
+ error: null,
+ isReprocessing: true,
+ reprocessType: type,
+ };
+
+ // Add to upload queue for progress tracking
+ uploadQueue.value.unshift(reprocessItem);
+
+ // Show progress modal
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ // Set as currently processing file
+ currentlyProcessingFile.value = reprocessItem;
+ processingProgress.value = 10;
+ processingMessage.value =
+ type === "transcription"
+ ? "Starting transcription reprocessing..."
+ : "Starting summary reprocessing...";
+ };
+
+ // Polling for reprocessing status updates
+ const reprocessingPolls = ref(new Map()); // Track active polls by recording ID
+
+ const startReprocessingPoll = (recordingId) => {
+ // Clear any existing poll for this recording
+ if (reprocessingPolls.value.has(recordingId)) {
+ clearInterval(reprocessingPolls.value.get(recordingId));
+ }
+
+ // console.log(`Starting reprocessing poll for recording ${recordingId}`);
+
+ const pollInterval = setInterval(async () => {
+ try {
+ const response = await fetch(`/tool/speakr/status/${recordingId}`);
+ if (!response.ok) {
+ console.error(`Status check failed for recording ${recordingId}`);
+ stopReprocessingPoll(recordingId);
+ return;
+ }
+
+ const data = await response.json();
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+ if (index !== -1) {
+ recordings.value[index] = data;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data;
+ }
+
+ const queueItem = uploadQueue.value.find(
+ (item) => item.recordingId === recordingId
+ );
+
+ // Update the item's status and name for intermediate states
+ if (queueItem) {
+ queueItem.status = data.status; // e.g., 'PROCESSING', 'SUMMARIZING'
+ queueItem.file.name = data.title || data.original_filename;
+ }
+
+ // Update progress modal if this is the currently processing item
+ if (
+ queueItem &&
+ currentlyProcessingFile.value?.clientId === queueItem.clientId
+ ) {
+ updateReprocessingProgress(data.status, queueItem);
+ }
+
+ // Stop polling if processing is complete
+ if (data.status === "COMPLETED" || data.status === "FAILED") {
+ console.log(
+ `Reprocessing ${data.status.toLowerCase()} for recording ${recordingId}`
+ );
+ stopReprocessingPoll(recordingId);
+
+ // Update queue item status to final lowercase state
+ if (queueItem) {
+ queueItem.status =
+ data.status === "COMPLETED" ? "completed" : "failed";
+ if (data.status === "FAILED") {
+ queueItem.error = data.error_message || "Reprocessing failed";
+ }
+ }
+
+ // Clear current processing if this was the active item
+ if (currentlyProcessingFile.value?.recordingId === recordingId) {
+ resetCurrentFileProcessingState();
+ }
+
+ if (data.status === "COMPLETED") {
+ showToast(
+ "Reprocessing completed successfully",
+ "fa-check-circle"
+ );
+ } else {
+ showToast("Reprocessing failed", "fa-exclamation-circle");
+ }
+ }
+ } catch (error) {
+ console.error(
+ `Error polling status for recording ${recordingId}:`,
+ error
+ );
+ stopReprocessingPoll(recordingId);
+ }
+ }, 3000);
+
+ reprocessingPolls.value.set(recordingId, pollInterval);
+ };
+
+ const updateReprocessingProgress = (status, queueItem) => {
+ switch (status) {
+ case "PENDING":
+ processingProgress.value = 20;
+ processingMessage.value = `Waiting to start ${queueItem.reprocessType} reprocessing...`;
+ break;
+ case "PROCESSING":
+ processingProgress.value = Math.round(
+ Math.min(70, processingProgress.value + Math.random() * 10)
+ );
+ processingMessage.value =
+ queueItem.reprocessType === "transcription"
+ ? "Reprocessing transcription..."
+ : "Processing audio...";
+ break;
+ case "SUMMARIZING":
+ processingProgress.value = Math.round(
+ Math.min(90, processingProgress.value + Math.random() * 10)
+ );
+ processingMessage.value =
+ queueItem.reprocessType === "summary"
+ ? "Regenerating summary..."
+ : "Generating title and summary...";
+ break;
+ case "COMPLETED":
+ processingProgress.value = 100;
+ processingMessage.value = "Reprocessing completed!";
+ break;
+ case "FAILED":
+ processingProgress.value = 100;
+ processingMessage.value = "Reprocessing failed.";
+ break;
+ default:
+ processingProgress.value = 15;
+ processingMessage.value = "Starting reprocessing...";
+ }
+ };
+
+ const stopReprocessingPoll = (recordingId) => {
+ if (reprocessingPolls.value.has(recordingId)) {
+ clearInterval(reprocessingPolls.value.get(recordingId));
+ reprocessingPolls.value.delete(recordingId);
+ console.log(`Stopped reprocessing poll for recording ${recordingId}`);
+ }
+ };
+
+ const confirmReset = (recording) => {
+ recordingToReset.value = recording;
+ showResetModal.value = true;
+ };
+
+ const cancelReset = () => {
+ showResetModal.value = false;
+ recordingToReset.value = null;
+ };
+
+ const executeReset = async () => {
+ if (!recordingToReset.value) return;
+ const recordingId = recordingToReset.value.id;
+
+ // Close the modal first
+ cancelReset();
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reset_status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to reset status");
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reset
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Recording status has been reset.", "fa-check-circle");
+ } catch (error) {
+ console.error("Reset Status Error:", error);
+ setGlobalError(`Failed to reset status: ${error.message}`);
+ }
+ };
+
+ const searchSpeakers = async (query, speakerId) => {
+ if (!query || query.length < 2) {
+ speakerSuggestions.value[speakerId] = [];
+ return;
+ }
+
+ loadingSuggestions.value[speakerId] = true;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/speakers/search?q=${encodeURIComponent(query)}`
+ );
+ if (!response.ok) throw new Error("Failed to search speakers");
+
+ const speakers = await response.json();
+ speakerSuggestions.value[speakerId] = speakers;
+ } catch (error) {
+ console.error("Error searching speakers:", error);
+ speakerSuggestions.value[speakerId] = [];
+ } finally {
+ loadingSuggestions.value[speakerId] = false;
+ }
+ };
+
+ const selectSpeakerSuggestion = (speakerId, suggestion) => {
+ if (speakerMap.value[speakerId]) {
+ speakerMap.value[speakerId].name = suggestion.name;
+ speakerSuggestions.value[speakerId] = [];
+ }
+ };
+
+ const closeSpeakerSuggestionsOnClick = (event) => {
+ // Check if the click was on an input field or dropdown
+ const clickedInput = event.target.closest('input[type="text"]');
+ const clickedDropdown = event.target.closest(".absolute.z-10");
+
+ // If not clicking on input or dropdown, close all suggestions
+ if (!clickedInput && !clickedDropdown) {
+ Object.keys(speakerSuggestions.value).forEach((speakerId) => {
+ speakerSuggestions.value[speakerId] = [];
+ });
+ }
+ };
+
+ // Create a mapping for display-friendly speaker IDs
+ const speakerDisplayMap = ref({});
+ const modalSpeakers = ref([]);
+ const is_final = ref(false);
+ const openSpeakerModal = () => {
+ // Clear any existing speaker map data first
+ speakerMap.value = {};
+ speakerDisplayMap.value = {};
+
+ // Get the same speaker order used in processedTranscription
+ const transcription = selectedRecording.value?.transcription;
+ let speakers = [];
+
+ if (transcription) {
+ try {
+ const transcriptionData = JSON.parse(transcription);
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ // Use the exact same logic as processedTranscription to get speakers
+ speakers = [
+ ...new Set(
+ transcriptionData
+ .map((segment) => segment.speaker)
+ .filter(Boolean)
+ ),
+ ];
+ }
+ } catch (e) {
+ // Fall back to identifiedSpeakers if JSON parsing fails
+ speakers = identifiedSpeakers.value;
+ }
+ }
+
+ // Set modalSpeakers for the template to use
+ modalSpeakers.value = speakers;
+
+ // Initialize speaker map with the same order and colors as the transcript
+ speakerMap.value = speakers.reduce((acc, speaker, index) => {
+ acc[speaker] = {
+ name: "",
+ isMe: false,
+ color: `speaker-color-${(index % 8) + 1}`, // Same color assignment as processedTranscription
+ };
+ // Keep the original speaker ID for display
+ speakerDisplayMap.value[speaker] = speaker;
+ return acc;
+ }, {});
+
+ highlightedSpeaker.value = null;
+ speakerSuggestions.value = {};
+ loadingSuggestions.value = {};
+ isAutoIdentifying.value = false;
+ showSpeakerModal.value = true;
+ };
+
+ const closeSpeakerModal = () => {
+ showSpeakerModal.value = false;
+ highlightedSpeaker.value = null;
+ // Clear the speaker map to prevent stale data from persisting
+ speakerMap.value = {};
+ speakerSuggestions.value = {};
+ loadingSuggestions.value = {};
+
+ // Clean up click handler if it exists
+ if (window.speakerModalClickHandler) {
+ const modalContent = document.querySelector(".modal-content");
+ if (modalContent) {
+ modalContent.removeEventListener(
+ "click",
+ window.speakerModalClickHandler
+ );
+ }
+ delete window.speakerModalClickHandler;
+ }
+ };
+
+ const saveSpeakerNames = async () => {
+ if (!selectedRecording.value) return;
+
+ // Create a filtered speaker map that excludes entries with blank names
+ const filteredSpeakerMap = Object.entries(speakerMap.value).reduce(
+ (acc, [speakerId, speakerData]) => {
+ if (speakerData.name && speakerData.name.trim() !== "") {
+ acc[speakerId] = speakerData;
+ }
+ return acc;
+ },
+ {}
+ );
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/update_speakers`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ speaker_map: filteredSpeakerMap, // Send the filtered map
+ regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value,
+ }),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update speakers");
+
+ // On success, close the modal and clear the speakerMap state *before*
+ // updating the recording data. This prevents a race condition where the
+ // view could re-render using the new data but the old, lingering speakerMap.
+ closeSpeakerModal();
+
+ // The backend returns the fully updated recording object.
+ // We can directly update our local state with this fresh data.
+ const index = recordings.value.findIndex(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ selectedRecording.value = data.recording;
+
+ showToast("Speaker names updated successfully!", "fa-check-circle");
+
+ // If a summary regeneration was requested, start polling for its status.
+ if (regenerateSummaryAfterSpeakerUpdate.value) {
+ startReprocessingPoll(selectedRecording.value.id);
+ }
+ } catch (error) {
+ console.error("Save Speaker Names Error:", error);
+ setGlobalError(`Failed to save speaker names: ${error.message}`);
+ }
+ };
+
+ // Speaker group navigation state
+ const currentSpeakerGroupIndex = ref(-1);
+ const speakerGroups = ref([]);
+
+ const findSpeakerGroups = (speakerId) => {
+ if (!speakerId) return [];
+
+ const groups = [];
+ const modalTranscript = document.querySelector(
+ "div.speaker-modal-transcript"
+ );
+ const mainTranscript = document.querySelector(
+ ".transcription-simple-view, .transcription-with-speakers, .transcription-content"
+ );
+ const transcriptContainer = modalTranscript || mainTranscript;
+
+ if (!transcriptContainer) return [];
+
+ // For JSON-based transcripts with segments
+ const allSegments =
+ transcriptContainer.querySelectorAll(".speaker-segment");
+ if (allSegments.length > 0) {
+ let currentGroup = null;
+ let lastSpeakerId = null;
+
+ allSegments.forEach((segment) => {
+ const speakerTag = segment.querySelector("[data-speaker-id]");
+ const segmentSpeakerId = speakerTag?.dataset.speakerId;
+
+ if (segmentSpeakerId === speakerId) {
+ // If this is a new group (not consecutive with previous)
+ if (lastSpeakerId !== speakerId) {
+ currentGroup = {
+ startElement: segment,
+ elements: [segment],
+ };
+ groups.push(currentGroup);
+ } else if (currentGroup) {
+ // Add to existing group
+ currentGroup.elements.push(segment);
+ }
+ }
+ lastSpeakerId = segmentSpeakerId;
+ });
+ } else {
+ // For plain text transcripts with speaker tags
+ const allTags =
+ transcriptContainer.querySelectorAll("[data-speaker-id]");
+ let currentGroup = null;
+
+ allTags.forEach((tag) => {
+ if (tag.dataset.speakerId === speakerId) {
+ // Find the parent element that contains this speaker's content
+ const parentSegment =
+ tag.closest(".speaker-segment") || tag.parentElement;
+
+ if (
+ !currentGroup ||
+ !currentGroup.lastElement ||
+ !parentSegment.previousElementSibling ||
+ parentSegment.previousElementSibling !==
+ currentGroup.lastElement
+ ) {
+ // Start a new group
+ currentGroup = {
+ startElement: parentSegment,
+ elements: [parentSegment],
+ lastElement: parentSegment,
+ };
+ groups.push(currentGroup);
+ } else {
+ // Continue the group
+ currentGroup.elements.push(parentSegment);
+ currentGroup.lastElement = parentSegment;
+ }
+ }
+ });
+ }
+
+ return groups;
+ };
+
+ const highlightSpeakerInTranscript = (speakerId) => {
+ highlightedSpeaker.value = speakerId;
+
+ if (speakerId) {
+ // Find all speaker groups for navigation
+ speakerGroups.value = findSpeakerGroups(speakerId);
+ currentSpeakerGroupIndex.value = 0;
+
+ // Scroll to the first group
+ if (speakerGroups.value.length > 0) {
+ nextTick(() => {
+ const firstGroup = speakerGroups.value[0];
+ if (firstGroup && firstGroup.startElement) {
+ firstGroup.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ });
+ }
+ } else {
+ speakerGroups.value = [];
+ currentSpeakerGroupIndex.value = -1;
+ }
+ };
+
+ const navigateToNextSpeakerGroup = () => {
+ if (speakerGroups.value.length === 0) return;
+
+ // Don't reset the speaker groups, just update the index
+ currentSpeakerGroupIndex.value =
+ (currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length;
+ const group = speakerGroups.value[currentSpeakerGroupIndex.value];
+ if (group && group.startElement) {
+ group.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ };
+
+ const navigateToPrevSpeakerGroup = () => {
+ if (speakerGroups.value.length === 0) return;
+
+ // Don't reset the speaker groups, just update the index
+ currentSpeakerGroupIndex.value =
+ currentSpeakerGroupIndex.value <= 0
+ ? speakerGroups.value.length - 1
+ : currentSpeakerGroupIndex.value - 1;
+ const group = speakerGroups.value[currentSpeakerGroupIndex.value];
+ if (group && group.startElement) {
+ group.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ };
+
+ // Enhanced speaker highlighting with focus/blur events for text inputs
+ const focusSpeaker = (speakerId) => {
+ // Set this as the active speaker input
+ activeSpeakerInput.value = speakerId;
+ // Only highlight if not already highlighted (to preserve navigation state)
+ if (highlightedSpeaker.value !== speakerId) {
+ highlightSpeakerInTranscript(speakerId);
+ }
+ };
+
+ const blurSpeaker = () => {
+ // Clear the active speaker input after a delay to allow clicking on suggestions
+ setTimeout(() => {
+ activeSpeakerInput.value = null;
+ speakerSuggestions.value = {};
+ }, 200);
+ clearSpeakerHighlight();
+ };
+
+ const clearSpeakerHighlight = () => {
+ highlightedSpeaker.value = null;
+ };
+
+ const autoIdentifySpeakers = async () => {
+ if (!selectedRecording.value) {
+ showToast("No recording selected.", "fa-exclamation-circle");
+ return;
+ }
+
+ isAutoIdentifying.value = true;
+ showToast("Starting automatic speaker identification...", "fa-magic");
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/auto_identify_speakers`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ current_speaker_map: speakerMap.value,
+ }),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ data.error || "Unknown error occurred during auto-identification."
+ );
+ }
+
+ // Check if there's a message (e.g., all speakers already identified)
+ if (data.message) {
+ showToast(data.message, "fa-info-circle");
+ return;
+ }
+
+ // Update speakerMap with the identified names (only for unidentified speakers)
+ let identifiedCount = 0;
+ for (const speakerId in data.speaker_map) {
+ const identifiedName = data.speaker_map[speakerId];
+ if (
+ speakerMap.value[speakerId] &&
+ identifiedName &&
+ identifiedName.trim() !== ""
+ ) {
+ speakerMap.value[speakerId].name = identifiedName;
+ identifiedCount++;
+ }
+ }
+
+ if (identifiedCount > 0) {
+ showToast(
+ `${identifiedCount} speaker(s) identified successfully!`,
+ "fa-check-circle"
+ );
+ } else {
+ showToast(
+ "No speakers could be identified from the context.",
+ "fa-info-circle"
+ );
+ }
+ } catch (error) {
+ console.error("Auto Identify Speakers Error:", error);
+ showToast(`Error: ${error.message}`, "fa-exclamation-circle", 5000);
+ } finally {
+ isAutoIdentifying.value = false;
+ }
+ };
+
+ const toggleInbox = async (recording) => {
+ if (!recording || !recording.id) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/toggle_inbox`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to toggle inbox status");
+
+ // Update the recording in the UI
+ recording.is_inbox = data.is_inbox;
+
+ // Update in the recordings list
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].is_inbox = data.is_inbox;
+ }
+
+ showToast(
+ `Recording ${data.is_inbox ? "moved to inbox" : "marked as read"}`
+ );
+ } catch (error) {
+ console.error("Toggle Inbox Error:", error);
+ setGlobalError(`Failed to toggle inbox status: ${error.message}`);
+ }
+ };
+
+ // Toggle highlighted status
+ const toggleHighlight = async (recording) => {
+ if (!recording || !recording.id) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/toggle_highlight`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to toggle highlighted status"
+ );
+
+ // Update the recording in the UI
+ recording.is_highlighted = data.is_highlighted;
+
+ // Update in the recordings list
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].is_highlighted = data.is_highlighted;
+ }
+
+ showToast(
+ `Recording ${data.is_highlighted ? "highlighted" : "unhighlighted"}`
+ );
+ } catch (error) {
+ console.error("Toggle Highlight Error:", error);
+ setGlobalError(
+ `Failed to toggle highlighted status: ${error.message}`
+ );
+ }
+ };
+
+ //--------------------录音详情高级操作 end------------------
+
+ //------------------- 深色模式与主题切换 start------------------
+ // --- Dark Mode ---
+ const toggleDarkMode = () => {
+ isDarkMode.value = !isDarkMode.value;
+ if (isDarkMode.value) {
+ document.documentElement.classList.add("dark");
+ localStorage.setItem("darkMode", "true");
+ } else {
+ document.documentElement.classList.remove("dark");
+ localStorage.setItem("darkMode", "false");
+ }
+ };
+
+ const initializeDarkMode = () => {
+ const prefersDark = window.matchMedia(
+ "(prefers-color-scheme: dark)"
+ ).matches;
+ const savedMode = localStorage.getItem("darkMode");
+ if (savedMode === "true" || (savedMode === null && prefersDark)) {
+ isDarkMode.value = true;
+ document.documentElement.classList.add("dark");
+ } else {
+ isDarkMode.value = false;
+ document.documentElement.classList.remove("dark");
+ }
+ };
+
+ const applyColorScheme = (schemeId, mode = null) => {
+ const targetMode = mode || (isDarkMode.value ? "dark" : "light");
+ const scheme = colorSchemes[targetMode].find((s) => s.id === schemeId);
+
+ if (!scheme) {
+ console.warn(
+ `Color scheme '${schemeId}' not found for mode '${targetMode}'`
+ );
+ return;
+ }
+
+ const allThemeClasses = [
+ ...colorSchemes.light.map((s) => s.class),
+ ...colorSchemes.dark.map((s) => s.class),
+ ].filter((c) => c !== "");
+
+ document.documentElement.classList.remove(...allThemeClasses);
+
+ if (scheme.class) {
+ document.documentElement.classList.add(scheme.class);
+ }
+
+ currentColorScheme.value = schemeId;
+ localStorage.setItem("colorScheme", schemeId);
+ };
+
+ const initializeColorScheme = () => {
+ const savedScheme = localStorage.getItem("colorScheme") || "blue";
+ currentColorScheme.value = savedScheme;
+ applyColorScheme(savedScheme);
+ };
+
+ const openColorSchemeModal = () => {
+ showColorSchemeModal.value = true;
+ };
+
+ const closeColorSchemeModal = () => {
+ showColorSchemeModal.value = false;
+ };
+
+ const selectColorScheme = (schemeId) => {
+ applyColorScheme(schemeId);
+ showToast(
+ `Applied ${
+ colorSchemes[isDarkMode.value ? "dark" : "light"].find(
+ (s) => s.id === schemeId
+ )?.name
+ } theme`,
+ "fa-palette"
+ );
+ };
+
+ const resetColorScheme = () => {
+ applyColorScheme("blue");
+ showToast("Reset to default Ocean Blue theme", "fa-undo");
+ };
+
+ // Watch for dark mode changes to reapply color scheme
+ watch(isDarkMode, () => {
+ applyColorScheme(currentColorScheme.value);
+ });
+ function confirmStopRecording() {
+ return new Promise((resolve) => {
+ // 防止重复创建
+ if (document.getElementById("recording-confirm-mask")) {
+ return;
+ }
+
+ // 遮罩层
+ const mask = document.createElement("div");
+ mask.id = "recording-confirm-mask";
+ mask.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.45);
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ `;
+
+ // 弹框
+ const dialog = document.createElement("div");
+ dialog.style.cssText = `
+ width: 360px;
+ background: #1f2937;
+ color: #fff;
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: 0 10px 30px rgba(0,0,0,.3);
+ font-family: system-ui;
+ `;
+
+ dialog.innerHTML = `
+
+ 确认停止录音
+
+
+ 当前正在录音,切换页面将会停止录音,是否继续?
+
+
+
+ 继续录音
+
+
+ 停止并切换
+
+
+ `;
+
+ mask.appendChild(dialog);
+ document.body.appendChild(mask);
+
+ const cleanup = () => {
+ document.body.removeChild(mask);
+ };
+
+ dialog.querySelector("#confirm-ok").onclick = () => {
+ cleanup();
+ resolve(true);
+ };
+
+ dialog.querySelector("#confirm-cancel").onclick = () => {
+ cleanup();
+ resolve(false);
+ };
+
+ // 点击遮罩关闭 = 取消
+ mask.onclick = (e) => {
+ if (e.target === mask) {
+ cleanup();
+ resolve(false);
+ }
+ };
+ });
+ }
+
+ //--------------------深色模式与主题切换 end------------------
+
+ //------------------- 侧边栏与视图切换控制 start------------------
+ // --- Sidebar Toggle ---
+ const toggleSidebar = () => {
+ isSidebarCollapsed.value = !isSidebarCollapsed.value;
+ };
+
+ //--------------------侧边栏与视图切换控制 end------------------
+
+ //------------------- 页面视图管理 start------------------
+ // --- View Management ---
+ const switchToUploadView = async () => {
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } else {
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ }
+ };
+
+ const selectRecording = async (recording) => {
+ if (currentView.value === "recording" && isRecording.value) {
+ // If we are in the middle of a recording, don't switch views
+ setGlobalError(
+ "Please stop the current recording before selecting another one."
+ );
+ return;
+ }
+ selectedRecording.value = recording;
+
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "detail";
+ } else {
+ currentView.value = "detail";
+ }
+ if (recording && recording.id) {
+ localStorage.setItem("lastSelectedRecordingId", recording.id);
+ } else {
+ localStorage.removeItem("lastSelectedRecordingId");
+ }
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } else {
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "detail";
+ } else {
+ currentView.value = "detail";
+ }
+ if (recording && recording.id) {
+ localStorage.setItem("lastSelectedRecordingId", recording.id);
+ } else {
+ localStorage.removeItem("lastSelectedRecordingId");
+ }
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ }
+ };
+
+ //--------------------页面视图管理 end------------------
+
+ //------------------- 文件上传与上传队列 start------------------
+ // --- File Upload ---
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ dragover.value = true;
+ };
+
+ const handleDragLeave = (e) => {
+ if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) {
+ return;
+ }
+ dragover.value = false;
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ dragover.value = false;
+ addFilesToQueue(e.dataTransfer.files);
+ };
+
+ const handleFileSelect = (e) => {
+ addFilesToQueue(e.target.files);
+ e.target.value = null;
+ };
+
+ const addFilesToQueue = (files) => {
+ let filesAdded = 0;
+ for (const file of files) {
+ const fileObject = file.file ? file.file : file;
+ const notes = file.notes || null;
+ const tags = file.tags || selectedTags.value || [];
+ const asrOptions = file.asrOptions || {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ };
+
+ // Check if it's an audio file or video container with audio
+ const isAudioFile =
+ fileObject &&
+ (fileObject.type.startsWith("audio/") ||
+ fileObject.type === "video/mp4" ||
+ fileObject.type === "video/quicktime" ||
+ fileObject.type === "video/x-msvideo" ||
+ fileObject.type === "video/webm" ||
+ fileObject.name.toLowerCase().endsWith(".amr") ||
+ fileObject.name.toLowerCase().endsWith(".3gp") ||
+ fileObject.name.toLowerCase().endsWith(".3gpp") ||
+ fileObject.name.toLowerCase().endsWith(".mp4") ||
+ fileObject.name.toLowerCase().endsWith(".mov") ||
+ fileObject.name.toLowerCase().endsWith(".avi") ||
+ fileObject.name.toLowerCase().endsWith(".mkv") ||
+ fileObject.name.toLowerCase().endsWith(".webm"));
+
+ if (isAudioFile) {
+ // Only check general file size limit (chunking handles OpenAI 25MB limit automatically)
+ if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) {
+ setGlobalError(
+ `File "${fileObject.name}" exceeds the maximum size of ${maxFileSizeMB.value} MB and was skipped.`
+ );
+ continue;
+ }
+
+ const clientId = `client-${Date.now()}-${Math.random()
+ .toString(36)
+ .substring(2, 9)}`;
+
+ // Auto-summarization will always occur for all uploads
+ const willAutoSummarize = true;
+
+ uploadQueue.value.push({
+ file: fileObject,
+ notes: notes,
+ tags: tags,
+ asrOptions: asrOptions,
+ status: "queued",
+ recordingId: null,
+ clientId: clientId,
+ error: null,
+ willAutoSummarize: willAutoSummarize,
+ });
+ filesAdded++;
+ } else if (fileObject) {
+ setGlobalError(
+ `Invalid file type "${fileObject.name}". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.`
+ );
+ }
+ }
+ if (filesAdded > 0) {
+ console.log(`Added ${filesAdded} file(s) to the queue.`);
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+ }
+ };
+
+ const resetCurrentFileProcessingState = () => {
+ if (pollInterval.value) clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ currentlyProcessingFile.value = null;
+ processingProgress.value = 0;
+ processingMessage.value = "";
+ };
+
+ const startProcessingQueue = async () => {
+ console.log("Attempting to start processing queue...");
+ if (isProcessingActive.value) {
+ console.log("Queue processor already active.");
+ return;
+ }
+
+ isProcessingActive.value = true;
+ resetCurrentFileProcessingState();
+
+ const nextFileItem = uploadQueue.value.find(
+ (item) => item.status === "queued"
+ );
+
+ if (nextFileItem) {
+ console.log(
+ `Processing next file: ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId})`
+ );
+ currentlyProcessingFile.value = nextFileItem;
+
+ // Check if this is a "reload" item (existing recording being tracked)
+ if (nextFileItem.clientId.startsWith("reload-")) {
+ // Skip upload, go directly to polling existing recording
+ console.log(
+ `Skipping upload for existing recording: ${nextFileItem.recordingId}`
+ );
+ nextFileItem.status = "processing";
+ startStatusPolling(nextFileItem, nextFileItem.recordingId);
+ return;
+ }
+
+ nextFileItem.status = "uploading";
+ processingMessage.value = "Preparing upload...";
+ processingProgress.value = 5;
+
+ try {
+ const formData = new FormData();
+ formData.append("file", nextFileItem.file);
+ if (nextFileItem.notes) {
+ formData.append("notes", nextFileItem.notes);
+ }
+
+ // Add tags if selected (multiple tags)
+ // Use tags from the queue item if available, otherwise use global selectedTagIds
+ const tagsToUse = nextFileItem.tags || selectedTags.value || [];
+ tagsToUse.forEach((tag, index) => {
+ const tagId = tag.id || tag; // Handle both tag objects and tag IDs
+ formData.append(`tag_ids[${index}]`, tagId);
+ });
+
+ // Add ASR advanced options if ASR endpoint is enabled
+ if (useAsrEndpoint.value) {
+ // Use ASR options from the queue item if available, otherwise use global values
+ const asrOpts = nextFileItem.asrOptions || {};
+ const language = asrOpts.language || uploadLanguage.value;
+ const minSpeakers =
+ asrOpts.min_speakers || uploadMinSpeakers.value;
+ const maxSpeakers =
+ asrOpts.max_speakers || uploadMaxSpeakers.value;
+
+ if (language) {
+ formData.append("language", language);
+ }
+ // Only send speaker limits if they're actually set
+ if (minSpeakers && minSpeakers !== "") {
+ formData.append("min_speakers", minSpeakers.toString());
+ }
+ if (maxSpeakers && maxSpeakers !== "") {
+ formData.append("max_speakers", maxSpeakers.toString());
+ }
+ }
+
+ processingMessage.value = "Uploading file...";
+ processingProgress.value = 10;
+
+ const response = await fetch("/tool/speakr/upload", {
+ method: "POST",
+ body: formData,
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ let errorMsg =
+ data.error || `Upload failed with status ${response.status}`;
+ if (response.status === 413)
+ errorMsg =
+ data.error ||
+ `File too large. Max: ${
+ data.max_size_mb?.toFixed(0) || maxFileSizeMB.value
+ } MB.`;
+ throw new Error(errorMsg);
+ }
+
+ if (response.status === 202 && data.id) {
+ console.log(
+ `File ${nextFileItem.file.name} uploaded. Recording ID: ${data.id}. Starting status poll.`
+ );
+ nextFileItem.status = "pending";
+ nextFileItem.recordingId = data.id;
+ processingMessage.value =
+ "Upload complete. Waiting for processing...";
+ processingProgress.value = 30;
+
+ recordings.value.unshift(data);
+ totalRecordings.value++; // Update total count
+ pollProcessingStatus(nextFileItem);
+ } else {
+ throw new Error(
+ "Unexpected success response from server after upload."
+ );
+ }
+ } catch (error) {
+ console.error(
+ `Upload/Processing Error for ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId}):`,
+ error
+ );
+ nextFileItem.status = "failed";
+ nextFileItem.error = error.message;
+ const failedRecordIndex = recordings.value.findIndex(
+ (r) => r.id === nextFileItem.recordingId
+ );
+ if (failedRecordIndex !== -1) {
+ recordings.value[failedRecordIndex].status = "FAILED";
+ recordings.value[
+ failedRecordIndex
+ ].transcription = `Upload/Processing failed: ${error.message}`;
+ } else {
+ setGlobalError(
+ `Failed to process "${nextFileItem.file.name}": ${error.message}`
+ );
+ }
+
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ } else {
+ console.log("Upload queue is empty or no files are queued.");
+ isProcessingActive.value = false;
+ }
+ };
+
+ const startStatusPolling = (fileItem, recordingId) => {
+ fileItem.recordingId = recordingId;
+ pollProcessingStatus(fileItem);
+ };
+
+ const pollProcessingStatus = (fileItem) => {
+ if (pollInterval.value) clearInterval(pollInterval.value);
+
+ const recordingId = fileItem.recordingId;
+ if (!recordingId) {
+ console.error(
+ "Cannot poll status without recording ID for",
+ fileItem.file.name
+ );
+ fileItem.status = "failed";
+ fileItem.error = "Internal error: Missing recording ID for polling.";
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ nextTick(startProcessingQueue);
+ return;
+ }
+
+ processingMessage.value = "Waiting for transcription...";
+ processingProgress.value = 40;
+
+ pollInterval.value = setInterval(async () => {
+ // Check if we should stop polling
+ const shouldStopPolling =
+ !currentlyProcessingFile.value ||
+ currentlyProcessingFile.value.clientId !== fileItem.clientId ||
+ fileItem.status === "failed" ||
+ (fileItem.status === "completed" &&
+ (!fileItem.willAutoSummarize || fileItem.summaryCompleted));
+
+ if (shouldStopPolling) {
+ console.log(
+ `Polling stopped for ${fileItem.clientId} as it's no longer active or finished.`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ if (
+ currentlyProcessingFile.value &&
+ currentlyProcessingFile.value.clientId === fileItem.clientId
+ ) {
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ return;
+ }
+
+ try {
+ console.log(
+ `Polling status for recording ID: ${recordingId} (${fileItem.file.name})`
+ );
+ const response = await fetch(`/tool/speakr/status/${recordingId}`);
+ if (!response.ok)
+ throw new Error(
+ `Status check failed with status ${response.status}`
+ );
+
+ const data = await response.json();
+ const galleryIndex = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+
+ if (galleryIndex !== -1) {
+ recordings.value[galleryIndex] = data;
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data;
+ }
+ }
+
+ const previousStatus = fileItem.status;
+ fileItem.status = data.status;
+ fileItem.file.name = data.title || data.original_filename;
+
+ if (data.status === "COMPLETED") {
+ console.log(
+ `Processing COMPLETED for ${fileItem.file.name} (ID: ${recordingId})`
+ );
+
+ // If this was previously summarizing, it's now fully complete
+ if (previousStatus === "summarizing") {
+ console.log(`Auto-summary completed for ${fileItem.file.name}`);
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true;
+
+ // This is final completion - clean up immediately and synchronously
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Completed item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ // Use immediate startProcessingQueue instead of nextTick to avoid duplication
+ startProcessingQueue();
+ return; // Exit early to prevent further processing
+ }
+ // If auto-summarization will occur and hasn't started yet, wait for it
+ else if (
+ fileItem.willAutoSummarize &&
+ !fileItem.hasCheckedForAutoSummary
+ ) {
+ processingMessage.value = "Transcription complete!";
+ processingProgress.value = 85;
+ fileItem.status = "awaiting_summary"; // Use intermediate status to keep it in upload queue
+ // Don't mark as summaryCompleted yet, continue polling
+ }
+ // No auto-summarization expected, complete normally
+ else {
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true; // No summary expected, so consider it complete
+
+ // Complete immediately for files without auto-summarization
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Completed item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ startProcessingQueue();
+ return; // Exit early to prevent further processing
+ }
+
+ // For files with auto-summarization, mark that they've been checked and continue polling
+ if (
+ fileItem.willAutoSummarize &&
+ !fileItem.hasCheckedForAutoSummary
+ ) {
+ fileItem.hasCheckedForAutoSummary = true;
+ fileItem.autoSummaryStartTime = Date.now();
+ console.log(
+ `Auto-summary expected for ${fileItem.file.name}, continuing to poll...`
+ );
+ // Don't complete yet, continue polling
+ return;
+ }
+
+ // If we have auto-summarization and we've been waiting, check if we should timeout
+ if (
+ fileItem.willAutoSummarize &&
+ fileItem.hasCheckedForAutoSummary
+ ) {
+ const waitTime = Date.now() - fileItem.autoSummaryStartTime;
+ const maxWaitTime = 60000; // 60 seconds
+
+ if (waitTime > maxWaitTime) {
+ // Timeout - complete the process
+ console.log(
+ `Auto-summary timeout for ${fileItem.file.name}, completing...`
+ );
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true; // Mark as complete due to timeout
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Timed-out item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ startProcessingQueue();
+ } else {
+ // Still waiting for auto-summary, continue polling
+ return;
+ }
+ }
+
+ // Normal completion path (no auto-summary check needed)
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Remove this item from uploadQueue immediately to prevent duplication
+ const queueIndex = uploadQueue.value.findIndex(
+ (item) => item.clientId === fileItem.clientId
+ );
+ if (queueIndex !== -1) {
+ uploadQueue.value.splice(queueIndex, 1);
+ console.log(
+ `Removed completed item ${fileItem.clientId} from queue immediately`
+ );
+ }
+
+ startProcessingQueue();
+ } else if (data.status === "FAILED") {
+ console.log(
+ `Processing FAILED for ${fileItem.file.name} (ID: ${recordingId})`
+ );
+ processingMessage.value = "Processing failed.";
+ processingProgress.value = 100;
+ fileItem.status = "failed";
+ fileItem.error =
+ data.transcription ||
+ data.summary ||
+ "Processing failed on server.";
+ setGlobalError(
+ `Processing failed for "${data.title || fileItem.file.name}".`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ } else if (data.status === "PROCESSING") {
+ // Check if this file will actually use chunking based on all conditions:
+ // 1. Chunking must be enabled in config
+ // 2. Must NOT be using ASR endpoint (ASR handles large files natively)
+ // 3. For size-based: File size must exceed the limit (can determine immediately)
+ // 4. For time-based: Can't determine client-side, but backend logs show it gets duration
+
+ const couldUseChunking =
+ chunkingEnabled.value && !useAsrEndpoint.value;
+
+ if (couldUseChunking) {
+ if (chunkingMode.value === "size") {
+ // Size-based chunking: we can determine definitively
+ const chunkThresholdBytes = chunkingLimit.value * 1024 * 1024;
+ const willUseChunking =
+ fileItem.file.size > chunkThresholdBytes;
+
+ if (willUseChunking) {
+ processingMessage.value =
+ "Processing large file (chunking in progress)...";
+ // If auto-summarization will occur, cap at 70%, otherwise 80%
+ const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 3
+ )
+ );
+ } else {
+ processingMessage.value = "转录进行中...";
+ // If auto-summarization will occur, cap at 65%, otherwise 75%
+ const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 5
+ )
+ );
+ }
+ } else {
+ // Duration-based chunking: Backend determines this after getting duration
+ // Show a neutral processing message since we can't know client-side
+ processingMessage.value =
+ "Processing file (chunking determined server-side)...";
+ const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 3
+ )
+ );
+ }
+ } else {
+ processingMessage.value = "转录进行中...";
+ const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 5
+ )
+ );
+ }
+ } else if (data.status === "SUMMARIZING") {
+ console.log(`Auto-summary started for ${fileItem.file.name}`);
+ processingMessage.value = "Generating summary...";
+ processingProgress.value = 90;
+ fileItem.status = "summarizing";
+ } else {
+ processingMessage.value = "Waiting in queue...";
+ processingProgress.value = 45;
+ }
+ } catch (error) {
+ console.error(
+ `Polling Error for ${fileItem.file.name} (ID: ${recordingId}):`,
+ error
+ );
+ fileItem.status = "failed";
+ fileItem.error = `Error checking status: ${error.message}`;
+ setGlobalError(
+ `Error checking status for "${fileItem.file.name}": ${error.message}.`
+ );
+ const galleryIndex = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+ if (galleryIndex !== -1)
+ recordings.value[galleryIndex].status = "FAILED";
+
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ }, 5000);
+ };
+
+ //--------------------文件上传与上传队列 end------------------
+
+ //------------------- 录音与标签数据加载 start------------------
+ // --- Data Loading ---
+ const loadRecordings = async (
+ page = 1,
+ append = false,
+ searchQuery = ""
+ ) => {
+ globalError.value = null;
+ if (!append) {
+ isLoadingRecordings.value = true;
+ } else {
+ isLoadingMore.value = true;
+ }
+
+ try {
+ const params = new URLSearchParams({
+ page: page.toString(),
+ per_page: perPage.value.toString(),
+ });
+
+ if (searchQuery.trim()) {
+ params.set("q", searchQuery.trim());
+ }
+
+ const response = await fetch(`/tool/speakr/api/recordings?${params}`);
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to load recordings");
+
+ // Update pagination state
+ currentPage.value = data.pagination.page;
+ totalRecordings.value = data.pagination.total;
+ totalPages.value = data.pagination.total_pages;
+ hasNextPage.value = data.pagination.has_next;
+ hasPrevPage.value = data.pagination.has_prev;
+
+ // Update recordings data
+ if (append) {
+ // Append to existing recordings (infinite scroll)
+ recordings.value = [...recordings.value, ...data.recordings];
+ } else {
+ // Replace recordings (fresh load or search)
+ recordings.value = data.recordings;
+
+ // Try to restore last selected recording
+ const lastRecordingId = localStorage.getItem(
+ "lastSelectedRecordingId"
+ );
+ if (lastRecordingId && data.recordings.length > 0) {
+ const recordingToSelect = data.recordings.find(
+ (r) => r.id == lastRecordingId
+ );
+ if (recordingToSelect) {
+ selectRecording(recordingToSelect);
+ }
+ }
+ }
+
+ // Handle incomplete recordings for processing queue
+ const incompleteRecordings = data.recordings.filter((r) =>
+ ["PENDING", "PROCESSING", "SUMMARIZING"].includes(r.status)
+ );
+ if (incompleteRecordings.length > 0 && !isProcessingActive.value) {
+ console.warn(
+ `Found ${incompleteRecordings.length} incomplete recording(s) on load.`
+ );
+ for (const recording of incompleteRecordings) {
+ let queueItem = uploadQueue.value.find(
+ (item) => item.recordingId === recording.id
+ );
+ if (!queueItem) {
+ queueItem = {
+ file: {
+ name: recording.title || `Recording ${recording.id}`,
+ size: recording.file_size,
+ },
+ status: "queued",
+ recordingId: recording.id,
+ clientId: `reload-${recording.id}`,
+ error: null,
+ };
+ uploadQueue.value.unshift(queueItem);
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Load Recordings Error:", error);
+ setGlobalError(`Failed to load recordings: ${error.message}`);
+ if (!append) {
+ recordings.value = [];
+ }
+ } finally {
+ isLoadingRecordings.value = false;
+ isLoadingMore.value = false;
+ }
+ };
+
+ // Load more recordings (infinite scroll)
+ const loadMoreRecordings = async () => {
+ if (!hasNextPage.value || isLoadingMore.value) return;
+ await loadRecordings(currentPage.value + 1, true, searchQuery.value);
+ };
+
+ // Search with debouncing
+ const performSearch = async (query = "") => {
+ currentPage.value = 1;
+ await loadRecordings(1, false, query);
+ };
+
+ // Debounced search function
+ const debouncedSearch = (query) => {
+ if (searchDebounceTimer.value) {
+ clearTimeout(searchDebounceTimer.value);
+ }
+ searchDebounceTimer.value = setTimeout(() => {
+ performSearch(query);
+ }, 300); // 300ms debounce
+ };
+
+ const loadTags = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/tags");
+ if (response.ok) {
+ availableTags.value = await response.json();
+ } else {
+ console.warn("Failed to load tags:", response.status);
+ availableTags.value = [];
+ }
+ } catch (error) {
+ console.warn("Error loading tags:", error);
+ availableTags.value = [];
+ }
+ };
+
+ const addTagToSelection = (tagId) => {
+ if (!selectedTagIds.value.includes(tagId)) {
+ selectedTagIds.value.push(tagId);
+ applyTagDefaults();
+ }
+ };
+
+ const removeTagFromSelection = (tagId) => {
+ const index = selectedTagIds.value.indexOf(tagId);
+ if (index > -1) {
+ selectedTagIds.value.splice(index, 1);
+ applyTagDefaults();
+ }
+ };
+
+ const applyTagDefaults = () => {
+ // Apply defaults from the first selected tag (highest priority)
+ const firstTag = selectedTags.value[0];
+ if (firstTag && useAsrEndpoint.value) {
+ if (firstTag.default_language) {
+ uploadLanguage.value = firstTag.default_language;
+ }
+ if (firstTag.default_min_speakers) {
+ uploadMinSpeakers.value = firstTag.default_min_speakers;
+ }
+ if (firstTag.default_max_speakers) {
+ uploadMaxSpeakers.value = firstTag.default_max_speakers;
+ }
+ }
+ };
+
+ // Legacy function for backward compatibility
+ const onTagSelected = applyTagDefaults;
+
+ // Tag helper functions
+ const getRecordingTags = (recording) => {
+ if (!recording || !recording.tags) return [];
+ return recording.tags || [];
+ };
+
+ const getAvailableTagsForRecording = (recording) => {
+ if (!recording || !availableTags.value) return [];
+ const recordingTagIds = getRecordingTags(recording).map(
+ (tag) => tag.id
+ );
+ return availableTags.value.filter(
+ (tag) => !recordingTagIds.includes(tag.id)
+ );
+ };
+
+ // Computed property for filtered available tags in the modal
+ const filteredAvailableTagsForModal = computed(() => {
+ if (!editingRecording.value) return [];
+ const availableTags = getAvailableTagsForRecording(
+ editingRecording.value
+ );
+ if (!tagSearchFilter.value) return availableTags;
+
+ const filter = tagSearchFilter.value.toLowerCase();
+ return availableTags.filter((tag) =>
+ tag.name.toLowerCase().includes(filter)
+ );
+ });
+ const filterByTag = (tag) => {
+ // Use advanced filter instead of text-based
+ filterTags.value = [tag.id];
+ applyAdvancedFilters();
+ };
+ const clearTagFilter = () => {
+ searchQuery.value = "";
+ clearAllFilters();
+ };
+
+ // Build search query from advanced filters
+ const buildSearchQuery = () => {
+ let query = [];
+
+ // Add text search
+ if (filterTextQuery.value.trim()) {
+ query.push(filterTextQuery.value.trim());
+ }
+
+ // Add tag filters
+ if (filterTags.value.length > 0) {
+ const tagNames = filterTags.value
+ .map((tagId) => {
+ const tag = availableTags.value.find((t) => t.id === tagId);
+ return tag ? `tag:${tag.name.replace(/\s+/g, "_")}` : "";
+ })
+ .filter(Boolean);
+ query.push(...tagNames);
+ }
+
+ // Add date filter
+ if (filterDatePreset.value) {
+ query.push(`date:${filterDatePreset.value}`);
+ } else if (filterDateRange.value.start || filterDateRange.value.end) {
+ // Custom date range - send as separate parameters
+ // Will be handled differently in the backend
+ if (filterDateRange.value.start) {
+ query.push(`date_from:${filterDateRange.value.start}`);
+ }
+ if (filterDateRange.value.end) {
+ query.push(`date_to:${filterDateRange.value.end}`);
+ }
+ }
+
+ return query.join(" ");
+ };
+
+ const applyAdvancedFilters = () => {
+ searchQuery.value = buildSearchQuery();
+ };
+
+ const clearAllFilters = () => {
+ filterTags.value = [];
+ filterDateRange.value = { start: "", end: "" };
+ filterDatePreset.value = "";
+ filterTextQuery.value = "";
+ searchQuery.value = "";
+ };
+
+ const editRecordingTags = (recording) => {
+ editingRecording.value = recording;
+ selectedNewTagId.value = "";
+ showEditTagsModal.value = true;
+ };
+
+ const closeEditTagsModal = () => {
+ showEditTagsModal.value = false;
+ editingRecording.value = null;
+ selectedNewTagId.value = "";
+ tagSearchFilter.value = ""; // Clear the filter when closing
+ };
+
+ const addTagToRecording = async (tagId = null) => {
+ // Use provided tagId or fall back to selectedNewTagId
+ const tagToAddId = tagId || selectedNewTagId.value;
+ if (!tagToAddId || !editingRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/api/recordings/${editingRecording.value.id}/tags`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken,
+ },
+ body: JSON.stringify({ tag_id: tagToAddId }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to add tag");
+ }
+
+ // Update local recording data
+ const tagToAdd = availableTags.value.find(
+ (tag) => tag.id == tagToAddId
+ );
+ if (tagToAdd) {
+ if (!editingRecording.value.tags) {
+ editingRecording.value.tags = [];
+ }
+ editingRecording.value.tags.push(tagToAdd);
+
+ // Also update in recordings list if it's a different object
+ const recordingInList = recordings.value.find(
+ (r) => r.id === editingRecording.value.id
+ );
+ if (recordingInList && recordingInList !== editingRecording.value) {
+ if (!recordingInList.tags) {
+ recordingInList.tags = [];
+ }
+ recordingInList.tags.push(tagToAdd);
+ }
+ }
+
+ selectedNewTagId.value = "";
+ } catch (error) {
+ console.error("Error adding tag to recording:", error);
+ setGlobalError(`Failed to add tag: ${error.message}`);
+ }
+ };
+
+ const removeTagFromRecording = async (tagId) => {
+ if (!editingRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/api/recordings/${editingRecording.value.id}/tags/${tagId}`,
+ {
+ method: "DELETE",
+ headers: {
+ "X-CSRFToken": csrfToken,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to remove tag");
+ }
+
+ // Update local recording data
+ editingRecording.value.tags = editingRecording.value.tags.filter(
+ (tag) => tag.id !== tagId
+ );
+
+ // Also update in recordings list if it's a different object
+ const recordingInList = recordings.value.find(
+ (r) => r.id === editingRecording.value.id
+ );
+ if (
+ recordingInList &&
+ recordingInList !== editingRecording.value &&
+ recordingInList.tags
+ ) {
+ recordingInList.tags = recordingInList.tags.filter(
+ (tag) => tag.id !== tagId
+ );
+ }
+ } catch (error) {
+ console.error("Error removing tag from recording:", error);
+ setGlobalError(`Failed to remove tag: ${error.message}`);
+ }
+ };
+
+ //--------------------录音与标签数据加载 end------------------
+
+ //------------------- 录音控制与实时采集 start------------------
+ // --- Audio Recording ---
+ const startRecordingWithDisclaimer = async (mode = "microphone") => {
+ // Check if disclaimer needs to be shown
+ if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) {
+ pendingRecordingMode.value = mode;
+ showRecordingDisclaimerModal.value = true;
+ } else {
+ // No disclaimer configured, proceed directly
+ await startRecordingActual(mode);
+ }
+ };
+
+ const acceptRecordingDisclaimer = async () => {
+ showRecordingDisclaimerModal.value = false;
+ if (pendingRecordingMode.value) {
+ await startRecordingActual(pendingRecordingMode.value);
+ pendingRecordingMode.value = null;
+ }
+ };
+
+ const cancelRecordingDisclaimer = () => {
+ showRecordingDisclaimerModal.value = false;
+ pendingRecordingMode.value = null;
+ };
+
+ const startRecording = startRecordingWithDisclaimer; // Maintain backward compatibility
+ let systemStream = null;
+ let micStream = null;
+
+ const startRecordingActual = async (mode = "microphone") => {
+ recordingMode.value = mode;
+
+ try {
+ // Load tags if not already loaded
+ if (availableTags.value.length === 0) {
+ await loadTags();
+ }
+
+ // Reset state
+ audioChunks.value = [];
+ audioBlobURL.value = null;
+ recordingNotes.value = "";
+ activeStreams.value = [];
+ // Clear previous tag selection and ASR options for fresh recording
+ selectedTags.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+
+ // Reset draft-related state for fresh recording (Bug fix: prevents new recordings from being merged into old drafts)
+ currentDraftId.value = null;
+ isPaused.value = false;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ asrResult.value = '';
+ asrResultOnline.value = '';
+ asrResultOffline.value = '';
+ recordingTime.value = 0;
+ isStoppingRecording.value = false;
+ pendingCorrectionCount = 0;
+ isEditingTranscription.value = false;
+ startNewRealtimeEpoch({ clearTimestampBarrier: true });
+
+ let combinedStream = null;
+
+ // Get microphone stream if needed
+ if (mode === "microphone" || mode === "both") {
+ if (!canRecordAudio.value) {
+ throw new Error(
+ "Microphone recording is not supported by your browser or permission was denied."
+ );
+ }
+ micStream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+ activeStreams.value.push(micStream);
+ showToast("Microphone access granted", "fa-microphone");
+ }
+
+ // Get system audio stream if needed
+ if (mode === "system" || mode === "both") {
+ if (!canRecordSystemAudio.value) {
+ throw new Error(
+ "System audio recording is not supported by your browser."
+ );
+ }
+ try {
+ systemStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: true,
+ video: true, // Request video to enable system audio sharing prompt
+ });
+
+ // Check if the user actually granted audio permission
+ if (systemStream.getAudioTracks().length === 0) {
+ // Stop the video track if it exists, since we didn't get audio
+ systemStream.getVideoTracks().forEach((track) => track.stop());
+ throw new Error(
+ 'System audio permission was not granted. Please ensure you check the "Share system audio" box.'
+ );
+ }
+
+ activeStreams.value.push(systemStream);
+ showToast("System audio access granted", "fa-desktop");
+ } catch (err) {
+ if (mode === "system") {
+ throw err; // Re-throw the original error to be caught by the outer handler
+ } else {
+ // For 'both' mode, fall back to microphone only
+ showToast(
+ "System audio denied, using microphone only",
+ "fa-exclamation-triangle"
+ );
+ mode = "microphone";
+ systemStream = null; // Make sure systemStream is null so it's not used later
+ }
+ }
+ }
+
+ // Combine streams if we have both
+ if (micStream && systemStream) {
+ try {
+ audioContext.value = new (window.AudioContext ||
+ window.webkitAudioContext)();
+
+ const micSource =
+ audioContext.value.createMediaStreamSource(micStream);
+ const systemSource =
+ audioContext.value.createMediaStreamSource(systemStream);
+ const destination =
+ audioContext.value.createMediaStreamDestination();
+
+ micSource.connect(destination);
+ systemSource.connect(destination);
+
+ // Create a new MediaStream with only the audio track from the destination
+ const mixedAudioTrack = destination.stream.getAudioTracks()[0];
+ if (!mixedAudioTrack) {
+ throw new Error("Failed to create mixed audio track");
+ }
+
+ combinedStream = new MediaStream([mixedAudioTrack]);
+
+ // Verify the stream has audio tracks
+ if (combinedStream.getAudioTracks().length === 0) {
+ throw new Error("Combined stream has no audio tracks");
+ }
+
+ console.log(
+ "Successfully created combined audio stream with",
+ combinedStream.getAudioTracks().length,
+ "audio tracks"
+ );
+ showToast(
+ "Recording both microphone and system audio",
+ "fa-microphone"
+ );
+ } catch (error) {
+ console.error("Failed to combine audio streams:", error);
+ // Fallback to system audio only
+ if (audioContext.value) {
+ audioContext.value
+ .close()
+ .catch((e) =>
+ console.error("Error closing AudioContext:", e)
+ );
+ audioContext.value = null;
+ }
+ combinedStream = systemStream;
+ showToast(
+ "Failed to combine audio, using system audio only",
+ "fa-exclamation-triangle"
+ );
+ }
+ } else if (systemStream) {
+ // For system audio only, create a new stream with just the audio tracks
+ const audioTracks = systemStream.getAudioTracks();
+ if (audioTracks.length > 0) {
+ combinedStream = new MediaStream(audioTracks);
+ console.log(
+ "Created system audio stream with",
+ audioTracks.length,
+ "audio tracks"
+ );
+ showToast("Recording system audio only", "fa-desktop");
+ } else {
+ throw new Error("System stream has no audio tracks");
+ }
+ } else if (micStream) {
+ combinedStream = micStream;
+ showToast("Recording microphone only", "fa-microphone");
+ } else {
+ throw new Error("No audio streams available for recording.");
+ }
+
+ // Setup MediaRecorder with optimized settings for transcription
+ const getOptimizedRecorderOptions = () => {
+ // Define transcription-optimized options in order of preference
+ const optionsList = [
+ // Best option: Opus codec at 32kbps (excellent compression for speech)
+ {
+ mimeType: "audio/webm;codecs=opus",
+ audioBitsPerSecond: 32000,
+ description: "Optimized (32kbps Opus)",
+ },
+ // Good option: Opus at 64kbps (slightly higher quality)
+ {
+ mimeType: "audio/webm;codecs=opus",
+ audioBitsPerSecond: 64000,
+ description: "Good quality (64kbps Opus)",
+ },
+ // Fallback 1: WebM with reduced bitrate
+ {
+ mimeType: "audio/webm",
+ audioBitsPerSecond: 64000,
+ description: "Standard WebM (64kbps)",
+ },
+ // Fallback 2: MP4 with reduced bitrate
+ {
+ mimeType: "audio/mp4",
+ audioBitsPerSecond: 64000,
+ description: "Standard MP4 (64kbps)",
+ },
+ // Fallback 3: Just the codec without bitrate
+ {
+ mimeType: "audio/webm;codecs=opus",
+ description: "Opus codec (default bitrate)",
+ },
+ // Fallback 4: Just WebM without bitrate
+ {
+ mimeType: "audio/webm",
+ description: "WebM (default bitrate)",
+ },
+ ];
+
+ // Test each option to find the first supported one
+ for (const options of optionsList) {
+ if (MediaRecorder.isTypeSupported(options.mimeType)) {
+ console.log(
+ `Testing audio recording option: ${options.description} - ${options.mimeType}`
+ );
+ return options;
+ }
+ }
+
+ // Final fallback: no options (browser default)
+ console.log("Using browser default audio recording settings");
+ return null;
+ };
+
+ // Try to create MediaRecorder with progressive fallbacks
+ let mediaRecorderCreated = false;
+ let recorderOptions = getOptimizedRecorderOptions();
+ let attemptCount = 0;
+
+ while (!mediaRecorderCreated && attemptCount < 5) {
+ try {
+ attemptCount++;
+
+ if (recorderOptions && attemptCount === 1) {
+ // First attempt: try with full options
+ console.log(
+ `Attempt ${attemptCount}: Trying ${recorderOptions.description}`
+ );
+ mediaRecorder.value = new MediaRecorder(
+ combinedStream,
+ recorderOptions
+ );
+ actualBitrate.value =
+ recorderOptions.audioBitsPerSecond || 64000;
+ showToast(
+ `Recording: ${recorderOptions.description}`,
+ "fa-compress-alt",
+ 3000
+ );
+ } else if (
+ recorderOptions &&
+ attemptCount === 2 &&
+ recorderOptions.audioBitsPerSecond
+ ) {
+ // Second attempt: try same mime type without bitrate constraint
+ console.log(
+ `Attempt ${attemptCount}: Trying ${recorderOptions.mimeType} without bitrate`
+ );
+ mediaRecorder.value = new MediaRecorder(combinedStream, {
+ mimeType: recorderOptions.mimeType,
+ });
+ actualBitrate.value = 128000; // Estimate
+ showToast(
+ `Recording: ${recorderOptions.mimeType} (default bitrate)`,
+ "fa-compress-alt",
+ 3000
+ );
+ } else {
+ // Final attempt: browser default
+ console.log(`Attempt ${attemptCount}: Using browser default`);
+ mediaRecorder.value = new MediaRecorder(combinedStream);
+ actualBitrate.value = 128000; // Estimate browser default
+ showToast(
+ "Recording with browser default settings",
+ "fa-microphone",
+ 3000
+ );
+ }
+
+ mediaRecorderCreated = true;
+ console.log(
+ `MediaRecorder created successfully on attempt ${attemptCount}`
+ );
+ } catch (error) {
+ console.warn(
+ `MediaRecorder creation attempt ${attemptCount} failed:`,
+ error
+ );
+ mediaRecorder.value = null;
+
+ if (attemptCount >= 5) {
+ throw new Error(
+ `Failed to create MediaRecorder after ${attemptCount} attempts. Last error: ${error.message}`
+ );
+ }
+ }
+ }
+
+ if (!mediaRecorder.value) {
+ throw new Error(
+ "Failed to create MediaRecorder with any configuration"
+ );
+ }
+
+ console.log(
+ `Recording with estimated bitrate: ${actualBitrate.value} bps`
+ );
+
+ mediaRecorder.value.ondataavailable = (event) =>
+ audioChunks.value.push(event.data);
+ mediaRecorder.value.onstop = () => {
+ const audioBlob = new Blob(audioChunks.value, {
+ type: "audio/webm",
+ });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+
+ // Stop size monitoring
+ stopSizeMonitoring();
+
+ // Stop all active streams
+ activeStreams.value.forEach((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ });
+ activeStreams.value = [];
+
+ if (audioContext.value) {
+ audioContext.value
+ .close()
+ .catch((e) => console.error("Error closing AudioContext:", e));
+ audioContext.value = null;
+ }
+ cancelAnimationFrame(animationFrameId.value);
+ clearInterval(recordingInterval.value);
+ };
+
+ // --- Visualizer Setup ---
+ if (!audioContext.value) {
+ audioContext.value = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ }
+
+ if (mode === "both" && micStream && systemStream) {
+ // Dual visualizer setup
+ micAnalyser.value = audioContext.value.createAnalyser();
+ micAnalyser.value.fftSize = 256;
+ const micSource =
+ audioContext.value.createMediaStreamSource(micStream);
+ micSource.connect(micAnalyser.value);
+
+ systemAnalyser.value = audioContext.value.createAnalyser();
+ systemAnalyser.value.fftSize = 256;
+ const systemSource =
+ audioContext.value.createMediaStreamSource(systemStream);
+ systemSource.connect(systemAnalyser.value);
+ } else {
+ // Single visualizer setup
+ const visualizerStream = micStream || systemStream;
+ if (visualizerStream) {
+ analyser.value = audioContext.value.createAnalyser();
+ analyser.value.fftSize = 256;
+ const source =
+ audioContext.value.createMediaStreamSource(visualizerStream);
+ source.connect(analyser.value);
+ }
+ }
+
+ // Start recording and timer
+ mediaRecorder.value.start();
+ isRecording.value = true;
+ recordingTime.value = 0;
+ recordingInterval.value = setInterval(
+ () => recordingTime.value++,
+ 1000
+ );
+
+ // Start size monitoring
+ startSizeMonitoring();
+
+ // Switch to recording view
+ currentView.value = "recording";
+
+ // Start visualizer(s)
+ drawVisualizers();
+
+ setGlobalError(null);
+ } catch (err) {
+ console.error("Error starting recording:", err);
+ setGlobalError(`Could not start recording: ${err.message}`);
+ isRecording.value = false;
+
+ // Clean up any streams that were created
+ activeStreams.value.forEach((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ });
+ activeStreams.value = [];
+ }
+ };
+
+ const stopRecording = async () => {
+ if (isStoppingRecording.value) {
+ return;
+ }
+
+ const hasRecordingSession =
+ isRecording.value ||
+ isPaused.value ||
+ audioChunks.value.length > 0 ||
+ !!currentDraftId.value ||
+ activeStreams.value.length > 0;
+
+ if (!hasRecordingSession) {
+ return;
+ }
+
+ isStoppingRecording.value = true;
+ try {
+ isRec = false;
+
+ if (mediaRecorder.value && isRecording.value) {
+ is_final.value = true;
+ mediaRecorder.value.stop();
+ isRecording.value = false;
+ stopSizeMonitoring();
+ cancelAnimationFrame(animationFrameId.value);
+ animationFrameId.value = null;
+ // 停止录音计时器
+ if (recordingInterval.value) {
+ clearInterval(recordingInterval.value);
+ recordingInterval.value = null;
+ }
+ }
+
+ if (realtimeAsrSession) {
+ await realtimeAsrSession.finish(3500);
+ }
+
+ // Wait for MediaRecorder to emit the final blob.
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // 如果是草稿模式,上传最后一个片段但不要 finalize
+ if (currentDraftId.value) {
+ try {
+ // 上传最后一个片段 (only if not already paused/uploaded)
+ if (!isPaused.value && audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ const formData = new FormData();
+ formData.append('audio', audioBlob);
+ formData.append('duration', recordingTime.value - pausedDuration.value);
+ formData.append('transcription', asrResult.value + asrResultOffline.value + asrResultOnline.value || '');
+
+ await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
+ method: 'POST',
+ body: formData
+ });
+
+ // 清理 audioChunks 防止重复
+ audioChunks.value = [];
+ }
+
+ // 合并音频片段并获取预览 URL (不 finalize,只是下载合并后的音频用于预览)
+ const audioResp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/download`);
+ if (audioResp.ok) {
+ const audioBlob = await audioResp.blob();
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ }
+
+ // 保持 currentDraftId 状态,以便用户选择上传或放弃
+ isPaused.value = false;
+
+ } catch (err) {
+ console.error('Error saving final segment:', err);
+ setGlobalError(`保存片段失败: ${err.message}`);
+ }
+ } else {
+ // 非草稿模式(直接录音),创建 audioBlobURL
+ if (audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ }
+ }
+
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } finally {
+ isStoppingRecording.value = false;
+ }
+ };
+
+ // 暂停录音
+ const pauseRecording = async () => {
+ if (!isRecording.value || isPaused.value) return;
+
+ try {
+ // Stop realtime ASR before closing MediaRecorder.
+ if (realtimeAsrSession) {
+ realtimeAsrSession.stop();
+ } else {
+ isRec = false;
+ }
+
+ // 停止当前 MediaRecorder
+ if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
+ mediaRecorder.value.stop();
+ }
+
+ // 等待 MediaRecorder 生成最终数据
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // 如果处于编辑模式,先退出编辑模式
+ if (isEditingTranscription.value) {
+ exitEditMode();
+ }
+
+ // 等待所有进行中的LLM矫正完成(最多8秒超时)
+ if (pendingCorrectionCount > 0) {
+ console.log(`等待 ${pendingCorrectionCount} 个LLM矫正完成...`);
+ const waitStart = Date.now();
+ while (pendingCorrectionCount > 0 && Date.now() - waitStart < 8000) {
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+ if (pendingCorrectionCount > 0) {
+ console.warn(`超时:仍有 ${pendingCorrectionCount} 个LLM矫正未完成`);
+ }
+ }
+
+ // 创建草稿(如果是第一次暂停)
+ if (!currentDraftId.value) {
+ const resp = await fetch('/tool/speakr/api/draft/create', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ recording_mode: recordingMode.value,
+ notes: recordingNotes.value
+ })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ currentDraftId.value = data.draft.id;
+ } else {
+ throw new Error(data.error || '创建草稿失败');
+ }
+ }
+
+ // 上传当前片段
+ if (audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ const formData = new FormData();
+ formData.append('audio', audioBlob);
+ formData.append('duration', recordingTime.value - pausedDuration.value);
+ // 发送完整转录文本(历史 + 离线修正 + 当前在线结果)以防止覆盖历史记录
+ const fullTranscription = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
+ formData.append('transcription', fullTranscription);
+
+ await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
+ method: 'POST',
+ body: formData
+ });
+
+ // Clear chunks after upload to prevent duplication
+ audioChunks.value = [];
+ }
+
+ // 更新状态
+ isPaused.value = true;
+ pausedDuration.value = recordingTime.value;
+ pausedTranscription.value = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
+ isRecording.value = false;
+
+ // 停止计时器和动画
+ clearInterval(recordingInterval.value);
+ cancelAnimationFrame(animationFrameId.value);
+ stopSizeMonitoring();
+
+ // 停止所有媒体流
+ activeStreams.value.forEach(stream => {
+ stream.getTracks().forEach(track => track.stop());
+ });
+ activeStreams.value = [];
+ micStream = null;
+ systemStream = null;
+
+ showToast('录音已暂停', 'fa-pause');
+
+ // 刷新草稿列表
+ loadDrafts();
+ } catch (err) {
+ console.error('Error pausing recording:', err);
+ setGlobalError(`暂停录音失败: ${err.message}`);
+ }
+ };
+
+ // 恢复录音
+ const resumeRecording = async () => {
+ if (!isPaused.value || !currentDraftId.value) return;
+
+ try {
+ // 重置音频块
+ audioChunks.value = [];
+
+ // Bug fix: 清空segmentList,防止旧segment与pausedTranscription(历史文本)重复渲染
+ // pausedTranscription已经包含了历史转录,segmentList应该只包含恢复后的新转录
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ pendingCorrectionCount = 0;
+ isEditingTranscription.value = false;
+ startNewRealtimeEpoch({ clearTimestampBarrier: true });
+
+ let combinedStream = null;
+ const mode = recordingMode.value;
+
+ // 重新获取媒体流
+ if (mode === 'microphone' || mode === 'both') {
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ activeStreams.value.push(micStream);
+ }
+
+ if (mode === 'system' || mode === 'both') {
+ try {
+ systemStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: true,
+ video: true
+ });
+ if (systemStream.getAudioTracks().length === 0) {
+ systemStream.getVideoTracks().forEach(track => track.stop());
+ throw new Error('未获取系统音频权限');
+ }
+ activeStreams.value.push(systemStream);
+ } catch (err) {
+ if (mode === 'system') throw err;
+ showToast('系统音频权限被拒绝,仅使用麦克风', 'fa-exclamation-triangle');
+ recordingMode.value = 'microphone';
+ }
+ }
+
+ // 组合音频流
+ if (micStream && systemStream) {
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
+ const micSource = audioContext.value.createMediaStreamSource(micStream);
+ const systemSource = audioContext.value.createMediaStreamSource(systemStream);
+ const destination = audioContext.value.createMediaStreamDestination();
+ micSource.connect(destination);
+ systemSource.connect(destination);
+ combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]);
+ } else if (systemStream) {
+ combinedStream = new MediaStream(systemStream.getAudioTracks());
+ } else if (micStream) {
+ combinedStream = micStream;
+ } else {
+ throw new Error('无可用音频流');
+ }
+
+ // 创建新的 MediaRecorder
+ mediaRecorder.value = new MediaRecorder(combinedStream, { mimeType: 'audio/webm;codecs=opus' });
+ mediaRecorder.value.ondataavailable = (event) => audioChunks.value.push(event.data);
+ mediaRecorder.value.onstop = () => {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ };
+
+ // 重新设置可视化
+ if (!audioContext.value) {
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ if (mode === 'both' && micStream && systemStream) {
+ // Dual visualizer setup
+ micAnalyser.value = audioContext.value.createAnalyser();
+ micAnalyser.value.fftSize = 256;
+ const micSource = audioContext.value.createMediaStreamSource(micStream);
+ micSource.connect(micAnalyser.value);
+
+ systemAnalyser.value = audioContext.value.createAnalyser();
+ systemAnalyser.value.fftSize = 256;
+ const systemSource = audioContext.value.createMediaStreamSource(systemStream);
+ systemSource.connect(systemAnalyser.value);
+ } else {
+ const visualizerStream = micStream || systemStream;
+ if (visualizerStream) {
+ analyser.value = audioContext.value.createAnalyser();
+ analyser.value.fftSize = 256;
+ const source = audioContext.value.createMediaStreamSource(visualizerStream);
+ source.connect(analyser.value);
+ }
+ }
+
+ // 开始录音
+ mediaRecorder.value.start();
+ isPaused.value = false;
+ isRecording.value = true;
+
+ // 恢复计时器(从暂停时间继续)
+ recordingInterval.value = setInterval(() => recordingTime.value++, 1000);
+
+ // 恢复可视化
+ drawVisualizers();
+ startSizeMonitoring();
+
+ // Resume realtime ASR WebSocket transcription.
+ console.log("Resuming WebSocket connections...");
+ asrResultOnline.value = '';
+ if (realtimeAsrSession) {
+ realtimeAsrSession.restart();
+ }
+
+ showToast('录音已恢复', 'fa-play');
+ } catch (err) {
+ console.error('Error resuming recording:', err);
+ setGlobalError(`恢复录音失败: ${err.message}`);
+ }
+ };
+
+ // 加载草稿列表
+ const loadDrafts = async () => {
+ try {
+ const resp = await fetch('/tool/speakr/api/draft/list');
+ const data = await resp.json();
+ draftRecordings.value = Array.isArray(data) ? data : [];
+ } catch (err) {
+ console.error('Error loading drafts:', err);
+ }
+ };
+
+ // 继续草稿录音
+ const continueDraftRecording = async (draftId) => {
+ try {
+ // 获取草稿详情
+ const resp = await fetch(`/tool/speakr/api/draft/${draftId}`);
+ const draft = await resp.json();
+
+ if (draft.error) {
+ throw new Error(draft.error);
+ }
+
+ // 恢复状态
+ currentDraftId.value = draftId;
+ recordingMode.value = draft.recording_mode || 'microphone';
+ recordingTime.value = Math.round(draft.total_duration || 0);
+ pausedDuration.value = recordingTime.value;
+ recordingNotes.value = draft.notes || '';
+ // Load historical transcription into asrResult
+ console.log('Loading draft transcription:', draft.combined_transcription ? draft.combined_transcription.length : 0, 'chars');
+ asrResult.value = draft.combined_transcription || '';
+ asrResultOnline.value = '';
+ asrResultOffline.value = '';
+ pausedTranscription.value = draft.combined_transcription || '';
+ segmentRenderStartIndex = 0;
+
+ // 设置暂停状态
+ isPaused.value = true;
+ isRecording.value = false;
+
+ // 切换到录音视图
+ currentView.value = 'recording';
+
+ showToast('草稿已加载,点击恢复继续录音', 'fa-folder-open');
+ } catch (err) {
+ console.error('Error continuing draft:', err);
+ setGlobalError(`加载草稿失败: ${err.message}`);
+ }
+ };
+
+ // 删除草稿
+ const deleteDraft = async (draftId) => {
+ if (!confirm('确定要删除这个草稿吗?此操作不可撤销。')) return false;
+
+ try {
+ const resp = await fetch(`/tool/speakr/api/draft/${draftId}`, {
+ method: 'DELETE'
+ });
+ const data = await resp.json();
+
+ if (data.success) {
+ showToast('草稿已删除', 'fa-trash');
+ loadDrafts();
+
+ // 如果删除的是当前草稿,重置状态
+ if (currentDraftId.value === draftId) {
+ currentDraftId.value = null;
+ isPaused.value = false;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentRenderStartIndex = 0;
+ }
+ return true;
+ } else {
+ throw new Error(data.error);
+ }
+ } catch (err) {
+ console.error('Error deleting draft:', err);
+ setGlobalError(`删除草稿失败: ${err.message}`);
+ return false;
+ }
+ };
+
+ // 重命名草稿
+ const startDraftRename = (draft) => {
+ editingDraftId.value = draft.id;
+ editingDraftName.value = draft.name;
+ // 等待 DOM 更新后聚焦输入框
+ nextTick(() => {
+ if (draftNameInput.value && draftNameInput.value[0]) {
+ draftNameInput.value[0].focus();
+ } else if (draftNameInput.value) {
+ draftNameInput.value.focus();
+ }
+ });
+ };
+
+ const saveDraftRename = async (draft) => {
+ if (!editingDraftId.value) return;
+
+ const newName = editingDraftName.value.trim();
+ editingDraftId.value = null; // 退出编辑模式
+
+ if (!newName || newName === draft.name) return;
+
+ try {
+ const resp = await fetch(`/tool/speakr/api/draft/${draft.id}/rename`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newName })
+ });
+ const data = await resp.json();
+
+ if (data.success) {
+ draft.name = newName;
+ showToast('重命名成功', 'fa-check');
+ } else {
+ throw new Error(data.error);
+ }
+ } catch (err) {
+ console.error('Error renaming draft:', err);
+ showToast(`重命名失败: ${err.message}`, 'fa-exclamation-circle');
+ }
+ };
+
+ const uploadRecordedAudio = async () => {
+ // 如果有草稿,先获取合并后的音频文件,然后走统一上传流程
+ if (currentDraftId.value) {
+ try {
+ showToast('Finalizing draft...', 'fa-spinner fa-spin', 0);
+
+ const savedNotes = recordingNotes.value;
+ const savedTags = [...selectedTags.value];
+ const savedAsrOptions = {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ };
+
+ const resp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/finalize`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ notes: savedNotes,
+ tag_ids: savedTags.map((tag) => tag.id || tag),
+ ...savedAsrOptions,
+ }),
+ });
+
+ const data = await resp.json();
+ if (!resp.ok || !data.success || !data.recording) {
+ throw new Error(data.error || 'Finalize draft failed');
+ }
+
+ const recording = data.recording;
+ const existingIndex = recordings.value.findIndex((r) => r.id === recording.id);
+ if (existingIndex === -1) {
+ recordings.value.unshift(recording);
+ totalRecordings.value += 1;
+ } else {
+ recordings.value[existingIndex] = recording;
+ }
+
+ const queueItem = {
+ file: {
+ name: recording.original_filename || recording.title || `draft-${recording.id}.webm`,
+ size: recording.file_size || 0,
+ },
+ notes: savedNotes,
+ tags: savedTags,
+ asrOptions: savedAsrOptions,
+ status: 'queued',
+ recordingId: recording.id,
+ clientId: `reload-draft-${recording.id}`,
+ error: null,
+ willAutoSummarize: true,
+ };
+ uploadQueue.value.push(queueItem);
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ currentDraftId.value = null;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentRenderStartIndex = 0;
+ loadDrafts();
+
+ if (audioBlobURL.value) {
+ URL.revokeObjectURL(audioBlobURL.value);
+ }
+ audioBlobURL.value = null;
+ audioChunks.value = [];
+ isRecording.value = false;
+ recordingNotes.value = '';
+ selectedTagIds.value = [];
+ asrLanguage.value = '';
+ asrMinSpeakers.value = '';
+ asrMaxSpeakers.value = '';
+ recordingTime.value = 0;
+
+ currentView.value = 'upload';
+ selectedRecording.value = null;
+
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+
+ showToast('Draft submitted for processing', 'fa-check-circle');
+ } catch (err) {
+ console.error('Error finalizing draft:', err);
+ setGlobalError(`Upload failed: ${err.message}`);
+ }
+ return;
+ }
+
+ if (!audioBlobURL.value) {
+ setGlobalError("No recorded audio to upload.");
+ return;
+ }
+
+ const previousQueueLength = uploadQueue.value.length;
+
+ const recordedFile = new File(
+ audioChunks.value,
+ createLocalizedRecordingFilename(),
+ { type: "audio/webm" }
+ );
+
+
+ addFilesToQueue([
+ {
+ file: recordedFile,
+ notes: recordingNotes.value,
+ tags: selectedTags.value,
+ asrOptions: {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ },
+ },
+ ]);
+
+
+ if (uploadQueue.value.length === previousQueueLength) {
+ return;
+ }
+
+ const queueItem = uploadQueue.value[uploadQueue.value.length - 1];
+
+ showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0);
+
+ const waitForServerResponse = () => {
+ return new Promise((resolve, reject) => {
+ const checkInterval = setInterval(() => {
+
+ if (!queueItem || queueItem.status === 'failed') {
+ clearInterval(checkInterval);
+ reject(new Error(queueItem?.error || "Upload failed"));
+ return;
+ }
+
+ const successStates = ['pending', 'processing', 'transcribing', 'summarizing', 'completed'];
+ if (successStates.includes(queueItem.status)) {
+ clearInterval(checkInterval);
+ resolve(true);
+ }
+
+ }, 200);
+ });
+ };
+ try {
+
+ await waitForServerResponse();
+
+ discardRecording();
+
+ if (isRec === true) {
+ if (typeof stopRecording === 'function') stopRecording();
+ }
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+
+ showToast("上传成功,正在处理中...", "fa-check-circle");
+
+ } catch (error) {
+ console.error("Server upload failed:", error);
+ showToast(`上传失败: ${error.message || '请检查网络'}`, "fa-exclamation-triangle", 4000);
+ }
+ };
+const downloadRecordedAudio = () => {
+ if (!audioBlobURL.value) {
+ showToast("No recording available to download", "fa-exclamation-circle");
+ return;
+ }
+
+ const filename = createLocalizedRecordingFilename();
+
+ const a = document.createElement('a');
+ a.style.display = 'none';
+ a.href = audioBlobURL.value;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+
+ // 清理
+ setTimeout(() => {
+ document.body.removeChild(a);
+ }, 100);
+
+ showToast("录音已开始下载", "fa-download");
+ };
+ const discardRecording = async () => {
+ if (currentDraftId.value) {
+ const deleted = await deleteDraft(currentDraftId.value);
+ if (!deleted) return;
+ }
+
+ if (audioBlobURL.value) {
+ URL.revokeObjectURL(audioBlobURL.value);
+ }
+ audioBlobURL.value = null;
+ audioChunks.value = [];
+ isRecording.value = false;
+ recordingTime.value = 0;
+ if (recordingInterval.value) clearInterval(recordingInterval.value);
+ recordingNotes.value = "";
+ // Clear tags and ASR options for fresh start
+ selectedTagIds.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+ };
+
+ const drawSingleVisualizer = (analyserNode, canvasElement) => {
+ if (!analyserNode || !canvasElement) return;
+
+ const bufferLength = analyserNode.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+ analyserNode.getByteFrequencyData(dataArray);
+
+ const canvasCtx = canvasElement.getContext("2d");
+ const WIDTH = canvasElement.width;
+ const HEIGHT = canvasElement.height;
+
+ canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
+
+ const barWidth = (WIDTH / bufferLength) * 1.5;
+ let barHeight;
+ let x = 0;
+
+ // Use theme-specific colors that work with all color schemes
+ const buttonColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--bg-button")
+ .trim();
+ const buttonHoverColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--bg-button-hover")
+ .trim();
+
+ // Create gradient that works in both light and dark modes
+ const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
+ if (isDarkMode.value) {
+ // Dark mode: use button colors with transparency
+ gradient.addColorStop(0, buttonColor);
+ gradient.addColorStop(0.6, buttonHoverColor);
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0.2)");
+ } else {
+ // Light mode: use more saturated colors for visibility
+ gradient.addColorStop(0, buttonColor);
+ gradient.addColorStop(0.5, buttonHoverColor);
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0.1)");
+ }
+
+ for (let i = 0; i < bufferLength; i++) {
+ barHeight = dataArray[i] / 2.5;
+ canvasCtx.fillStyle = gradient;
+ canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
+ x += barWidth + 2;
+ }
+ };
+
+ const drawVisualizers = () => {
+ if (!isRecording.value) {
+ if (animationFrameId.value) {
+ cancelAnimationFrame(animationFrameId.value);
+ animationFrameId.value = null;
+ }
+ return;
+ }
+
+ animationFrameId.value = requestAnimationFrame(drawVisualizers);
+
+ if (recordingMode.value === "both") {
+ drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
+ drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
+ } else {
+ drawSingleVisualizer(analyser.value, visualizer.value);
+ }
+ };
+
+ //--------------------录音控制与实时采集 end------------------
+
+ //------------------- 录音保存与详情管理 start------------------
+ // --- Recording Management ---
+ const saveMetadata = async (recordingDataToSave) => {
+ globalError.value = null;
+ if (!recordingDataToSave || !recordingDataToSave.id) return null;
+ console.log("Saving metadata for:", recordingDataToSave.id);
+ try {
+ const payload = {
+ id: recordingDataToSave.id,
+ title: recordingDataToSave.title,
+ participants: recordingDataToSave.participants,
+ notes: recordingDataToSave.notes,
+ summary: recordingDataToSave.summary,
+ meeting_date: recordingDataToSave.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to save metadata");
+
+ console.log("Save successful:", data.recording.id);
+ const index = recordings.value.findIndex(
+ (r) => r.id === data.recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].title = payload.title;
+ recordings.value[index].participants = payload.participants;
+ recordings.value[index].notes = payload.notes;
+ recordings.value[index].notes_html = data.recording.notes_html;
+ recordings.value[index].summary = payload.summary;
+ recordings.value[index].summary_html = data.recording.summary_html;
+ recordings.value[index].meeting_date = payload.meeting_date;
+ }
+ if (selectedRecording.value?.id === data.recording.id) {
+ selectedRecording.value.title = payload.title;
+ selectedRecording.value.participants = payload.participants;
+ selectedRecording.value.notes = payload.notes;
+ selectedRecording.value.notes_html = data.recording.notes_html;
+ selectedRecording.value.summary = payload.summary;
+ selectedRecording.value.summary_html = data.recording.summary_html;
+ selectedRecording.value.meeting_date = payload.meeting_date;
+ }
+ return data.recording;
+ } catch (error) {
+ console.error("Save Metadata Error:", error);
+ setGlobalError(`Save failed: ${error.message}`);
+ return null;
+ }
+ };
+
+ const editRecording = (recording) => {
+ editingRecording.value = JSON.parse(JSON.stringify(recording));
+ showEditModal.value = true;
+ };
+
+ const cancelEdit = () => {
+ showEditModal.value = false;
+ editingRecording.value = null;
+ };
+
+ const saveEdit = async () => {
+ const success = await saveMetadata(editingRecording.value);
+ if (success) {
+ cancelEdit();
+ }
+ };
+
+ const confirmDelete = (recording) => {
+ recordingToDelete.value = recording;
+ showDeleteModal.value = true;
+ };
+
+ const cancelDelete = () => {
+ showDeleteModal.value = false;
+ recordingToDelete.value = null;
+ };
+
+ const deleteRecording = async () => {
+ globalError.value = null;
+ if (!recordingToDelete.value) return;
+ const idToDelete = recordingToDelete.value.id;
+ const titleToDelete = recordingToDelete.value.title;
+ try {
+ const response = await fetch(`/tool/speakr/recording/${idToDelete}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to delete recording");
+
+ recordings.value = recordings.value.filter(
+ (r) => r.id !== idToDelete
+ );
+ totalRecordings.value--; // Update total count
+
+ const queueIndex = uploadQueue.value.findIndex(
+ (item) => item.recordingId === idToDelete
+ );
+ if (queueIndex !== -1) {
+ const deletedItem = uploadQueue.value.splice(queueIndex, 1)[0];
+ console.log(`Removed item ${deletedItem.clientId} from queue.`);
+ if (
+ currentlyProcessingFile.value?.clientId === deletedItem.clientId
+ ) {
+ console.log(
+ `Deleting currently processing file: ${titleToDelete}. Stopping poll and moving to next.`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ }
+
+ if (selectedRecording.value?.id === idToDelete)
+ selectedRecording.value = null;
+ cancelDelete();
+ console.log(
+ `Successfully deleted recording ${idToDelete} (${titleToDelete})`
+ );
+ } catch (error) {
+ console.error("Delete Error:", error);
+ setGlobalError(
+ `Failed to delete recording "${titleToDelete}": ${error.message}`
+ );
+ cancelDelete();
+ }
+ };
+
+ //--------------------录音保存与详情管理 end------------------
+
+ //------------------- 详情页内联编辑 start------------------
+ // --- Inline Editing ---
+ const toggleEditParticipants = () => {
+ editingParticipants.value = !editingParticipants.value;
+ if (!editingParticipants.value) {
+ saveInlineEdit("participants");
+ }
+ };
+
+ const toggleEditMeetingDate = () => {
+ editingMeetingDate.value = !editingMeetingDate.value;
+ if (!editingMeetingDate.value) {
+ saveInlineEdit("meeting_date");
+ }
+ };
+
+ const toggleEditSummary = () => {
+ editingSummary.value = !editingSummary.value;
+ if (editingSummary.value) {
+ // Store current content in temp variable
+ tempSummaryContent.value = selectedRecording.value.summary || "";
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ };
+
+ const cancelEditSummary = () => {
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ editingSummary.value = false;
+ // Restore original content
+ if (selectedRecording.value) {
+ selectedRecording.value.summary = tempSummaryContent.value;
+ }
+ };
+
+ const saveEditSummary = async () => {
+ if (summaryMarkdownEditorInstance.value) {
+ selectedRecording.value.summary =
+ summaryMarkdownEditorInstance.value.value();
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ editingSummary.value = false;
+ await saveInlineEdit("summary");
+ };
+
+ const toggleEditNotes = () => {
+ editingNotes.value = !editingNotes.value;
+ if (editingNotes.value) {
+ // Store current content in temp variable
+ tempNotesContent.value = selectedRecording.value.notes || "";
+ // Initialize markdown editor when entering edit mode
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ };
+
+ const cancelEditNotes = () => {
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ editingNotes.value = false;
+ // Restore original content
+ if (selectedRecording.value) {
+ selectedRecording.value.notes = tempNotesContent.value;
+ }
+ };
+
+ const saveEditNotes = async () => {
+ if (markdownEditorInstance.value) {
+ // Get the markdown content from the editor
+ selectedRecording.value.notes = markdownEditorInstance.value.value();
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ editingNotes.value = false;
+ await saveInlineEdit("notes");
+ };
+
+ const clickToEditNotes = () => {
+ // Allow clicking on empty notes area to start editing
+ if (
+ !editingNotes.value &&
+ (!selectedRecording.value?.notes ||
+ selectedRecording.value.notes.trim() === "")
+ ) {
+ toggleEditNotes();
+ }
+ };
+
+ const clickToEditSummary = () => {
+ // Allow clicking on empty summary area to start editing
+ if (
+ !editingSummary.value &&
+ (!selectedRecording.value?.summary ||
+ selectedRecording.value.summary.trim() === "")
+ ) {
+ toggleEditSummary();
+ }
+ };
+
+ const autoSaveNotes = async () => {
+ if (markdownEditorInstance.value && editingNotes.value) {
+ // Just save the content to the model, don't exit edit mode
+ selectedRecording.value.notes = markdownEditorInstance.value.value();
+ // Silently save to backend without changing UI state
+ try {
+ const payload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (response.ok && data.recording) {
+ // Update the HTML rendered versions if they exist
+ if (data.recording.notes_html) {
+ selectedRecording.value.notes_html = data.recording.notes_html;
+ }
+ } else {
+ console.error("Failed to auto-save notes");
+ }
+ } catch (error) {
+ console.error("Error auto-saving notes:", error);
+ }
+ }
+ };
+
+ const autoSaveSummary = async () => {
+ if (summaryMarkdownEditorInstance.value && editingSummary.value) {
+ // Just save the content to the model, don't exit edit mode
+ selectedRecording.value.summary =
+ summaryMarkdownEditorInstance.value.value();
+ // Silently save to backend without changing UI state
+ try {
+ const payload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (response.ok && data.recording) {
+ // Update the HTML rendered versions if they exist
+ if (data.recording.summary_html) {
+ selectedRecording.value.summary_html =
+ data.recording.summary_html;
+ }
+ } else {
+ console.error("Failed to auto-save summary");
+ }
+ } catch (error) {
+ console.error("Error auto-saving summary:", error);
+ }
+ }
+ };
+
+ const initializeMarkdownEditor = () => {
+ if (!notesMarkdownEditor.value) return;
+
+ try {
+ markdownEditorInstance.value = new EasyMDE({
+ element: notesMarkdownEditor.value,
+ spellChecker: false,
+ autofocus: true,
+ placeholder: "以Markdown格式输入笔记...",
+ initialValue: selectedRecording.value?.notes || "",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "image",
+ "|",
+ "preview",
+ "side-by-side",
+ "fullscreen",
+ "|",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ });
+
+ // Add auto-save functionality
+ markdownEditorInstance.value.codemirror.on("change", () => {
+ if (autoSaveTimer.value) {
+ clearTimeout(autoSaveTimer.value);
+ }
+ autoSaveTimer.value = setTimeout(() => {
+ autoSaveNotes();
+ }, autoSaveDelay);
+ });
+ } catch (error) {
+ console.error("Failed to initialize markdown editor:", error);
+ // Fallback to regular textarea editing
+ editingNotes.value = true;
+ }
+ };
+ const getBaseInfo = async () => {
+ try {
+ const response = await fetch(`getUserConfig`, {
+ method: "GET",
+ });
+ const data = await response.json();
+ if (data.LEADER_SPEECH_BUTTON) {
+ baseInfo.value = {
+ name: data.LEADER_SPEECH_BUTTON,
+ fontSize: data.LEADER_SPEECH_TEXT_SIZE,
+ };
+ }
+ configwords = data.END_PHRASE_PATTERNS || "";
+ } catch (error) {
+ console.error("获取基础配置出错:", error);
+ configwords = "";
+ }
+ };
+ const initializeRecordingMarkdownEditor = () => {
+ if (!recordingNotesEditor.value) {
+ console.log("Recording notes editor ref not found");
+ return;
+ }
+
+ // Check if EasyMDE is available
+ if (typeof EasyMDE === "undefined") {
+ console.error("EasyMDE is not loaded");
+ return;
+ }
+
+ // Clean up existing instance if any
+ if (recordingMarkdownEditorInstance.value) {
+ recordingMarkdownEditorInstance.value.toTextArea();
+ recordingMarkdownEditorInstance.value = null;
+ }
+
+ try {
+ console.log("Initializing recording markdown editor");
+ recordingMarkdownEditorInstance.value = new EasyMDE({
+ element: recordingNotesEditor.value,
+ spellChecker: false,
+ autofocus: false,
+ placeholder: "Type your notes in Markdown format...",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "|",
+ "preview",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ initialValue: recordingNotes.value || "",
+ maxHeight: "300px", // Add height constraint to prevent unlimited growth
+ minHeight: "150px", // Minimum height for usability
+ });
+
+ // Sync changes back to the reactive variable
+ recordingMarkdownEditorInstance.value.codemirror.on("change", () => {
+ recordingNotes.value =
+ recordingMarkdownEditorInstance.value.value();
+ });
+
+ console.log("Recording markdown editor initialized successfully");
+ } catch (error) {
+ console.error(
+ "Failed to initialize recording markdown editor:",
+ error
+ );
+ // Keep as regular textarea if EasyMDE fails
+ }
+ };
+ // 识别启动、停止、清空操作
+
+ const toggleTranscriptionViewMode = () => {
+ transcriptionViewMode.value =
+ transcriptionViewMode.value === "simple" ? "bubble" : "simple";
+ localStorage.setItem(
+ "transcriptionViewMode",
+ transcriptionViewMode.value
+ );
+ };
+
+ const toggleChatMaximize = () => {
+ if (isChatMaximized.value) {
+ // If maximized, restore to normal state
+ isChatMaximized.value = false;
+ } else {
+ // If not maximized, maximize and ensure chat is open
+ isChatMaximized.value = true;
+ if (!showChat.value) {
+ showChat.value = true;
+ }
+ }
+ };
+
+ const saveInlineEdit = async (field) => {
+ if (!selectedRecording.value) return;
+
+ const fullPayload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+
+ try {
+ const updatedRecording = await saveMetadata(fullPayload);
+ if (updatedRecording) {
+ if (field === "notes") {
+ selectedRecording.value.notes_html = updatedRecording.notes_html;
+ } else if (field === "summary") {
+ selectedRecording.value.summary_html =
+ updatedRecording.summary_html;
+ }
+
+ switch (field) {
+ case "participants":
+ editingParticipants.value = false;
+ break;
+ case "meeting_date":
+ editingMeetingDate.value = false;
+ break;
+ case "summary":
+ editingSummary.value = false;
+ break;
+ case "notes":
+ editingNotes.value = false;
+ break;
+ }
+ showToast(
+ `${
+ field.charAt(0).toUpperCase() + field.slice(1).replace("_", " ")
+ } updated successfully`
+ );
+ }
+ } catch (error) {
+ console.error(`Save ${field} Error:`, error);
+ setGlobalError(`Failed to save ${field}: ${error.message}`);
+ }
+ };
+
+ //--------------------详情页内联编辑 end------------------
+
+ //------------------- 聊天问答功能 start------------------
+ // --- Chat Functionality ---
+ // Helper function to check if chat is scrolled to bottom (within bottom 5%)
+ const isChatScrolledToBottom = () => {
+ if (!chatMessagesRef.value) return true;
+ const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
+ const scrollableHeight = scrollHeight - clientHeight;
+ if (scrollableHeight <= 0) return true; // No scrolling possible
+ const scrollPercentage = scrollTop / scrollableHeight;
+ return scrollPercentage >= 0.95; // Within bottom 5%
+ };
+
+ // Helper function to scroll chat to bottom with smooth behavior
+ const scrollChatToBottom = () => {
+ if (chatMessagesRef.value) {
+ requestAnimationFrame(() => {
+ if (chatMessagesRef.value) {
+ chatMessagesRef.value.scrollTop =
+ chatMessagesRef.value.scrollHeight;
+ }
+ });
+ }
+ };
+
+ const sendChatMessage = async () => {
+ if (
+ !chatInput.value.trim() ||
+ isChatLoading.value ||
+ !selectedRecording.value ||
+ selectedRecording.value.status !== "COMPLETED"
+ ) {
+ return;
+ }
+
+ const message = chatInput.value.trim();
+
+ if (!Array.isArray(chatMessages.value)) {
+ chatMessages.value = [];
+ }
+
+ chatMessages.value.push({ role: "user", content: message });
+ chatInput.value = "";
+ isChatLoading.value = true;
+
+ await nextTick();
+ // Always scroll to bottom when user sends a new message
+ scrollChatToBottom();
+
+ let assistantMessage = null;
+
+ try {
+ const messageHistory = chatMessages.value
+ .slice(0, -1)
+ .map((msg) => ({ role: msg.role, content: msg.content }));
+
+ const response = await fetch("/tool/speakr/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ recording_id: selectedRecording.value.id,
+ message: message,
+ message_history: messageHistory,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to get chat response");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ const processStream = async () => {
+ let isFirstChunk = true;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop();
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ const jsonStr = line.substring(6);
+ if (jsonStr) {
+ try {
+ const data = JSON.parse(jsonStr);
+ if (data.thinking) {
+ // Check scroll position BEFORE updating content
+ const shouldScroll = isChatScrolledToBottom();
+
+ if (isFirstChunk) {
+ isChatLoading.value = false;
+ assistantMessage = reactive({
+ role: "assistant",
+ content: "",
+ html: "",
+ thinking: data.thinking,
+ thinkingExpanded: false,
+ });
+ chatMessages.value.push(assistantMessage);
+ isFirstChunk = false;
+ } else if (assistantMessage) {
+ // Append to existing thinking content
+ if (assistantMessage.thinking) {
+ assistantMessage.thinking += "\n\n" + data.thinking;
+ } else {
+ assistantMessage.thinking = data.thinking;
+ }
+ }
+
+ // Scroll if we were at bottom before the update
+ if (shouldScroll) {
+ await nextTick();
+ scrollChatToBottom();
+ }
+ }
+ if (data.delta) {
+ // Check scroll position BEFORE updating content
+ const shouldScroll = isChatScrolledToBottom();
+
+ if (isFirstChunk) {
+ isChatLoading.value = false;
+ assistantMessage = reactive({
+ role: "assistant",
+ content: "",
+ html: "",
+ thinking: "",
+ thinkingExpanded: false,
+ });
+ chatMessages.value.push(assistantMessage);
+ isFirstChunk = false;
+ }
+
+ assistantMessage.content += data.delta;
+ assistantMessage.html = marked.parse(
+ assistantMessage.content
+ );
+
+ // Scroll if we were at bottom before the update
+ if (shouldScroll) {
+ await nextTick();
+ scrollChatToBottom();
+ }
+ }
+ if (data.end_of_stream) {
+ return;
+ }
+ if (data.error) {
+ throw new Error(data.error);
+ }
+ } catch (e) {
+ console.error("Error parsing stream data:", e);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ await processStream();
+ } catch (error) {
+ console.error("Chat Error:", error);
+ if (assistantMessage) {
+ assistantMessage.content = `Error: ${error.message}`;
+ assistantMessage.html = `Error: ${error.message} `;
+ } else {
+ chatMessages.value.push({
+ role: "assistant",
+ content: `Error: ${error.message}`,
+ html: `Error: ${error.message} `,
+ });
+ }
+ } finally {
+ isChatLoading.value = false;
+ await nextTick();
+ // Final scroll only if user is at bottom
+ if (isChatScrolledToBottom()) {
+ scrollChatToBottom();
+ }
+ }
+ };
+
+ //--------------------聊天问答功能 end------------------
+
+ //------------------- 分栏拖拽与尺寸持久化 start------------------
+ // --- Column Resizing ---
+ const startColumnResize = (event) => {
+ isResizing.value = true;
+ const startX = event.clientX;
+ const startLeftWidth = leftColumnWidth.value;
+
+ const handleMouseMove = (e) => {
+ if (!isResizing.value) return;
+
+ const container = document.getElementById("mainContentColumns");
+ if (!container) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const deltaX = e.clientX - startX;
+ const containerWidth = containerRect.width;
+ const deltaPercent = (deltaX / containerWidth) * 100;
+
+ let newLeftWidth = startLeftWidth + deltaPercent;
+ newLeftWidth = Math.max(20, Math.min(80, newLeftWidth)); // Constrain between 20% and 80%
+
+ leftColumnWidth.value = newLeftWidth;
+ rightColumnWidth.value = 100 - newLeftWidth;
+ };
+
+ const handleMouseUp = () => {
+ isResizing.value = false;
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+
+ // Save to localStorage
+ localStorage.setItem("transcriptColumnWidth", leftColumnWidth.value);
+ localStorage.setItem("summaryColumnWidth", rightColumnWidth.value);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ event.preventDefault();
+ };
+
+ //--------------------分栏拖拽与尺寸持久化 end------------------
+
+ //------------------- 聊天输入交互 start------------------
+ // --- Chat Input Handling ---
+ const handleChatKeydown = (event) => {
+ if (event.key === "Enter") {
+ if (event.ctrlKey || event.shiftKey) {
+ // Ctrl+Enter or Shift+Enter: add new line (default behavior)
+ return;
+ } else {
+ // Enter: send message
+ event.preventDefault();
+ sendChatMessage();
+ }
+ }
+ };
+
+ //--------------------聊天输入交互 end------------------
+
+ //------------------- 音频播放器辅助逻辑 start------------------
+ // --- Audio Player ---
+ const seekAudio = (time, context = "main") => {
+ let audioPlayer = null;
+ if (context === "modal") {
+ // The audio player in the modal has the class directly on the audio element
+ audioPlayer = document.querySelector(
+ "audio.speaker-modal-transcript"
+ );
+ } else {
+ audioPlayer = document.querySelector(".main-content-area audio");
+ }
+
+ if (audioPlayer) {
+ const wasPlaying = !audioPlayer.paused;
+ audioPlayer.currentTime = time;
+ if (wasPlaying) {
+ audioPlayer.play();
+ }
+ } else {
+ console.warn(`Audio player not found for context: ${context}`);
+ // Fallback to old method if new one fails
+ const oldPlayer = document.querySelector("audio");
+ if (oldPlayer) {
+ const wasPlaying = !oldPlayer.paused;
+ oldPlayer.currentTime = time;
+ if (wasPlaying) {
+ oldPlayer.play();
+ }
+ }
+ }
+ };
+
+ const seekAudioFromEvent = (event) => {
+ const segmentElement = event.target.closest("[data-start-time]");
+ if (!segmentElement) return;
+
+ const time = parseFloat(segmentElement.dataset.startTime);
+ if (isNaN(time)) return;
+
+ // Determine context by checking if we're inside the speaker modal
+ const isInSpeakerModal =
+ event.target.closest(".speaker-modal-transcript") !== null;
+ const context = isInSpeakerModal ? "modal" : "main";
+
+ seekAudio(time, context);
+ };
+
+ const onPlayerVolumeChange = (event) => {
+ const newVolume = event.target.volume;
+ playerVolume.value = newVolume;
+ localStorage.setItem("playerVolume", newVolume);
+ };
+
+ //--------------------音频播放器辅助逻辑 end------------------
+
+ //------------------- 国际化辅助方法 start------------------
+ // --- i18n Helper Functions ---
+ // Use the same safeT function that's globally available
+ const t = safeT;
+
+ const tc = (key, count, params = {}) => {
+ return window.i18n ? window.i18n.tc(key, count, params) : key;
+ };
+
+ const changeLanguage = async (langCode) => {
+ if (window.i18n) {
+ await window.i18n.setLocale(langCode);
+ currentLanguage.value = langCode;
+ const lang = availableLanguages.value.find(
+ (l) => l.code === langCode
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+ showLanguageMenu.value = false;
+ isUserMenuOpen.value = false;
+
+ // Save preference to backend
+ try {
+ await fetch("/tool/speakr/api/user/preferences", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrfToken.value,
+ },
+ body: JSON.stringify({ language: langCode }),
+ });
+ } catch (error) {
+ console.error("Failed to save language preference:", error);
+ }
+ }
+ };
+
+ //--------------------国际化辅助方法 end------------------
+
+ //------------------- Toast 提示与复制下载反馈 start------------------
+ // --- Toast Notifications ---
+ const showToast = (
+ message,
+ icon = "fa-check-circle",
+ duration = 2000
+ ) => {
+ const toastContainer = document.getElementById("toastContainer");
+
+ const toast = document.createElement("div");
+ toast.className = "toast";
+ toast.innerHTML = ` ${message}`;
+
+ toastContainer.appendChild(toast);
+
+ setTimeout(() => {
+ toast.classList.add("show");
+ }, 10);
+
+ setTimeout(() => {
+ toast.classList.remove("show");
+ setTimeout(() => {
+ toastContainer.removeChild(toast);
+ }, 300);
+ }, duration);
+ };
+
+ const animateCopyButton = (button) => {
+ button.classList.add("copy-success");
+
+ const originalContent = button.innerHTML;
+ button.innerHTML = ' ';
+
+ setTimeout(() => {
+ button.classList.remove("copy-success");
+ button.innerHTML = originalContent;
+ }, 1500);
+ };
+
+ const copyMessage = (text, event) => {
+ const button = event.currentTarget;
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ showToast("Copied to clipboard!");
+ animateCopyButton(button);
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(text, button);
+ });
+ } else {
+ fallbackCopyTextToClipboard(text, button);
+ }
+ };
+
+ const fallbackCopyTextToClipboard = (text, button = null) => {
+ try {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+
+ textArea.focus();
+ textArea.select();
+ const successful = document.execCommand("copy");
+
+ document.body.removeChild(textArea);
+
+ if (successful) {
+ showToast("Copied to clipboard!");
+ if (button) animateCopyButton(button);
+ } else {
+ showToast(
+ "Copy failed. Your browser may not support this feature.",
+ "fa-exclamation-circle"
+ );
+ }
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ showToast("Unable to copy: " + err.message, "fa-exclamation-circle");
+ }
+ };
+
+ const copyTranscription = (event) => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.transcription
+ ) {
+ showToast(
+ "No transcription available to copy.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ const button = event.currentTarget;
+ let textToCopy = "";
+
+ try {
+ const transcriptionData = JSON.parse(
+ selectedRecording.value.transcription
+ );
+ if (Array.isArray(transcriptionData)) {
+ const wasDiarized = transcriptionData.some(
+ (segment) => segment.speaker
+ );
+ if (wasDiarized) {
+ textToCopy = transcriptionData
+ .map((segment) => {
+ const speakerName = segment.speaker;
+ return `[${speakerName}]: ${segment.sentence}`;
+ })
+ .join("\n");
+ } else {
+ textToCopy = transcriptionData
+ .map((segment) => segment.sentence)
+ .join("\n");
+ }
+ } else {
+ textToCopy = selectedRecording.value.transcription;
+ }
+ } catch (e) {
+ textToCopy = selectedRecording.value.transcription;
+ }
+
+ animateCopyButton(button);
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("转录内容已复制到剪贴板!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const copySummary = (event) => {
+ if (!selectedRecording.value || !selectedRecording.value.summary) {
+ showToast("No summary available to copy.", "fa-exclamation-circle");
+ return;
+ }
+ const button = event.currentTarget;
+ const textToCopy = selectedRecording.value.summary;
+ animateCopyButton(button);
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("摘要已复制到剪贴板!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const copyNotes = (event) => {
+ if (!selectedRecording.value || !selectedRecording.value.notes) {
+ showToast("No notes available to copy.", "fa-exclamation-circle");
+ return;
+ }
+ const button = event.currentTarget;
+ const textToCopy = selectedRecording.value.notes;
+ animateCopyButton(button);
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("Notes copied to clipboard!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const downloadSummary = async () => {
+ if (!selectedRecording.value || !selectedRecording.value.summary) {
+ showToast(
+ "No summary available to download.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/summary`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download summary",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "摘要.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Summary downloaded successfully!");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download summary", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadTranscript = async () => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.transcription
+ ) {
+ showToast(
+ "No transcription available to download.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ try {
+ // First, fetch available templates
+ const templatesResponse = await fetch(
+ "/tool/speakr/api/transcript-templates"
+ );
+ let templates = [];
+ if (templatesResponse.ok) {
+ templates = await templatesResponse.json();
+ }
+
+ // If there are templates, show a selection dialog
+ let templateId = null;
+ if (templates.length > 0) {
+ // Create a simple modal for template selection
+ const modal = document.createElement("div");
+ modal.className =
+ "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50";
+ modal.innerHTML = `
+
+
Select Template
+
+ ${templates
+ .map(
+ (t) => `
+
+ ${
+ t.name
+ }
+ ${
+ t.description
+ ? `${t.description}
`
+ : ""
+ }
+ ${
+ t.is_default
+ ? ' Default
'
+ : ""
+ }
+
+ `
+ )
+ .join("")}
+
+
+ 取消
+ Download without template
+
+
+ `;
+ document.body.appendChild(modal);
+
+ // Wait for user selection
+ await new Promise((resolve) => {
+ modal.querySelectorAll(".template-option").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ templateId = btn.dataset.templateId;
+ modal.remove();
+ resolve();
+ });
+ });
+
+ modal
+ .querySelector(".cancel-btn")
+ .addEventListener("click", () => {
+ templateId = "cancelled"; // Mark as cancelled
+ modal.remove();
+ resolve();
+ });
+
+ modal
+ .querySelector(".download-without-template-btn")
+ .addEventListener("click", () => {
+ templateId = "none"; // Use 'none' to indicate no template
+ modal.remove();
+ resolve();
+ });
+
+ modal.addEventListener("click", (e) => {
+ if (e.target === modal) {
+ templateId = "cancelled"; // Mark as cancelled when clicking outside
+ modal.remove();
+ resolve();
+ }
+ });
+ });
+
+ if (
+ templateId === null ||
+ templateId === undefined ||
+ templateId === "cancelled"
+ ) {
+ // User cancelled
+ return;
+ }
+ }
+
+ // Download the transcript with the selected template
+ const url = templateId && templateId !== "none"
+ ? `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}`
+ : `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=none`;
+
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error("Failed to download transcript");
+ }
+
+ const blob = await response.blob();
+ const contentDisposition = response.headers.get(
+ "content-disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "转录稿.docx"
+ );
+
+ // Create download link
+ const downloadUrl = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = downloadUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(downloadUrl);
+
+ showToast("Transcript downloaded successfully!");
+ } catch (error) {
+ console.error("Error downloading transcript:", error);
+ showToast("Failed to download transcript", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadNotes = async () => {
+ if (!selectedRecording.value || !selectedRecording.value.notes) {
+ showToast("No notes available to download.", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/notes`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download notes",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "笔记.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Notes downloaded successfully!", "fa-check-circle");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download notes", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadEventICS = async (event) => {
+ if (!event || !event.id) {
+ showToast("Invalid event data", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/event/${event.id}/ics`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download event",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `${event.title || "event"}.ics`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast(
+ `Event "${event.title}" downloaded. Open the file to add to your calendar.`,
+ "fa-calendar-check",
+ 3000
+ );
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download event", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadICS = async () => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.events ||
+ selectedRecording.value.events.length === 0
+ ) {
+ showToast("No events to export", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${selectedRecording.value.id}/events/ics`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to export events",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `events-${
+ selectedRecording.value.title || selectedRecording.value.id
+ }.ics`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast(
+ `Exported ${selectedRecording.value.events.length} events`,
+ "fa-calendar-check"
+ );
+ } catch (error) {
+ console.error("Download all events ICS error:", error);
+ showToast("Failed to export events", "fa-exclamation-circle");
+ }
+ };
+
+ const formatEventDateTime = (dateTimeStr) => {
+ if (!dateTimeStr) return "";
+ try {
+ const date = new Date(dateTimeStr);
+ const options = {
+ weekday: "short",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ };
+ return date.toLocaleString(undefined, options);
+ } catch (e) {
+ return dateTimeStr;
+ }
+ };
+
+ const downloadChat = async () => {
+ if (!selectedRecording.value || chatMessages.value.length === 0) {
+ showToast("No chat messages to download.", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/chat`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken.value,
+ },
+ body: JSON.stringify({
+ messages: chatMessages.value,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download chat",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "对话记录.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Notes downloaded successfully!");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download notes", "fa-exclamation-circle");
+ }
+ };
+
+ const clearChat = () => {
+ if (chatMessages.value.length > 0) {
+ chatMessages.value = [];
+ showToast("Chat cleared", "fa-broom");
+ }
+ };
+
+ const openShareModal = async (recording) => {
+ recordingToShare.value = recording;
+ shareOptions.share_summary = true;
+ shareOptions.share_notes = true;
+ generatedShareLink.value = "";
+ existingShareDetected.value = false;
+ showShareModal.value = true;
+
+ // Check for existing share
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${recording.id}/share`,
+ {
+ method: "GET",
+ }
+ );
+ const data = await response.json();
+ if (response.ok && data.exists) {
+ generatedShareLink.value = data.share_url;
+ existingShareDetected.value = true;
+ shareOptions.share_summary = data.share.share_summary;
+ shareOptions.share_notes = data.share.share_notes;
+ }
+ } catch (error) {
+ console.error("Error checking for existing share:", error);
+ }
+ };
+
+ const closeShareModal = () => {
+ showShareModal.value = false;
+ recordingToShare.value = null;
+ existingShareDetected.value = false;
+ };
+
+ const createShare = async (forceNew = false) => {
+ if (!recordingToShare.value) return;
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${recordingToShare.value.id}/share`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...shareOptions,
+ force_new: forceNew,
+ }),
+ }
+ );
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to create share link");
+
+ generatedShareLink.value = data.share_url;
+ existingShareDetected.value = data.existing && !forceNew;
+
+ if (data.existing && !forceNew) {
+ // Show that we're using an existing share
+ showToast("Using existing share link", "fa-link");
+ } else {
+ showToast("Share link created successfully!", "fa-check-circle");
+ }
+ } catch (error) {
+ setGlobalError(`Failed to create share link: ${error.message}`);
+ }
+ };
+
+ const copyShareLink = () => {
+ if (!generatedShareLink.value) return;
+ navigator.clipboard.writeText(generatedShareLink.value).then(() => {
+ showToast("Share link copied to clipboard!");
+ });
+ };
+
+ const openSharesList = async () => {
+ isLoadingShares.value = true;
+ showSharesListModal.value = true;
+ try {
+ const response = await fetch("/tool/speakr/api/shares");
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to load shared items");
+ userShares.value = data;
+ } catch (error) {
+ setGlobalError(`Failed to load shared items: ${error.message}`);
+ } finally {
+ isLoadingShares.value = false;
+ }
+ };
+
+ const closeSharesList = () => {
+ showSharesListModal.value = false;
+ userShares.value = [];
+ };
+
+ const updateShare = async (share) => {
+ try {
+ const response = await fetch(`/tool/speakr/api/share/${share.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ share_summary: share.share_summary,
+ share_notes: share.share_notes,
+ }),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update share");
+ showToast("Share permissions updated.", "fa-check-circle");
+ } catch (error) {
+ setGlobalError(`Failed to update share: ${error.message}`);
+ }
+ };
+
+ const confirmDeleteShare = (share) => {
+ shareToDelete.value = share;
+ showShareDeleteModal.value = true;
+ };
+
+ const cancelDeleteShare = () => {
+ shareToDelete.value = null;
+ showShareDeleteModal.value = false;
+ };
+
+ const deleteShare = async () => {
+ if (!shareToDelete.value) return;
+ const shareId = shareToDelete.value.id;
+ try {
+ const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to delete share");
+ userShares.value = userShares.value.filter((s) => s.id !== shareId);
+ showToast("Share link deleted successfully.", "fa-check-circle");
+ showShareDeleteModal.value = false;
+ shareToDelete.value = null;
+ } catch (error) {
+ setGlobalError(`Failed to delete share: ${error.message}`);
+ }
+ };
+
+ //--------------------Toast 提示与复制下载反馈 end------------------
+
+ //------------------- 响应式监听与界面联动 start------------------
+ // --- Watchers ---
+ watch(
+ uploadQueue,
+ (newQueue, oldQueue) => {
+ if (
+ newQueue.length === 0 &&
+ oldQueue.length > 0 &&
+ !isProcessingActive.value
+ ) {
+ console.log("Upload queue processing finished.");
+ setTimeout(() => (progressPopupMinimized.value = true), 500);
+ setTimeout(() => {
+ if (
+ completedInQueue.value === totalInQueue.value &&
+ !isProcessingActive.value
+ ) {
+ progressPopupClosed.value = true;
+ }
+ }, 2000);
+ }
+ },
+ { deep: true }
+ );
+
+ // Watch for changes to speakerMap to handle "This is Me" functionality
+ watch(
+ speakerMap,
+ (newSpeakerMap) => {
+ if (!newSpeakerMap) return;
+
+ Object.keys(newSpeakerMap).forEach((speakerId) => {
+ const speakerData = newSpeakerMap[speakerId];
+ if (
+ speakerData.isMe &&
+ currentUserName.value &&
+ !speakerData.name
+ ) {
+ // Automatically fill in the user's name when "This is Me" is checked
+ speakerData.name = currentUserName.value;
+ } else if (
+ !speakerData.isMe &&
+ speakerData.name === currentUserName.value
+ ) {
+ // Clear the name if "This is Me" is unchecked and the name matches the current user
+ speakerData.name = "";
+ }
+ });
+ },
+ { deep: true }
+ );
+
+ // Watch for tab changes to save content properly
+ watch(selectedTab, (newTab, oldTab) => {
+ // Close maximized chat when switching tabs
+ if (isChatMaximized.value) {
+ isChatMaximized.value = false;
+ }
+
+ // Save content when switching away from notes tab but keep editor open
+ if (
+ oldTab === "notes" &&
+ editingNotes.value &&
+ markdownEditorInstance.value
+ ) {
+ // Save the current content from the editor
+ const currentContent = markdownEditorInstance.value.value();
+ selectedRecording.value.notes = currentContent;
+ // Call auto-save instead of saveInlineEdit to keep editor open
+ autoSaveNotes();
+ // Store that we need to recreate the editor when coming back
+ tempNotesContent.value = currentContent;
+ }
+ // Save content when switching away from summary tab but keep editor open
+ if (
+ oldTab === "summary" &&
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value
+ ) {
+ // Save the current content from the editor
+ const currentContent = summaryMarkdownEditorInstance.value.value();
+ selectedRecording.value.summary = currentContent;
+ // Call auto-save instead of saveInlineEdit to keep editor open
+ autoSaveSummary();
+ // Store that we need to recreate the editor when coming back
+ tempSummaryContent.value = currentContent;
+ }
+
+ // Re-initialize editors when switching back to tabs
+ if (newTab === "notes" && editingNotes.value) {
+ // Destroy old instance if exists
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ // Re-initialize the editor in next tick
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ if (newTab === "summary" && editingSummary.value) {
+ // Destroy old instance if exists
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ // Re-initialize the editor in next tick
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ });
+
+ // Watch for mobile tab changes similarly
+ watch(mobileTab, (newTab, oldTab) => {
+ // Save content when switching away from notes tab but keep editor open
+ if (
+ oldTab === "notes" &&
+ editingNotes.value &&
+ markdownEditorInstance.value
+ ) {
+ const currentContent = markdownEditorInstance.value.value();
+ selectedRecording.value.notes = currentContent;
+ autoSaveNotes(); // Use auto-save instead
+ tempNotesContent.value = currentContent;
+ }
+ // Save content when switching away from summary tab but keep editor open
+ if (
+ oldTab === "summary" &&
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value
+ ) {
+ const currentContent = summaryMarkdownEditorInstance.value.value();
+ selectedRecording.value.summary = currentContent;
+ autoSaveSummary(); // Use auto-save instead
+ tempSummaryContent.value = currentContent;
+ }
+
+ // Re-initialize editors when switching back to tabs on mobile
+ if (newTab === "notes" && editingNotes.value) {
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ if (newTab === "summary" && editingSummary.value) {
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ });
+
+ watch(selectedRecording, (newVal, oldVal) => {
+ if (newVal?.id !== oldVal?.id) {
+ // Save any pending edits before switching recordings
+ if (
+ editingNotes.value &&
+ markdownEditorInstance.value &&
+ oldVal?.id
+ ) {
+ selectedRecording.value = oldVal; // Temporarily restore old recording
+ saveEditNotes(); // This will save and cleanup
+ selectedRecording.value = newVal; // Switch back to new recording
+ }
+ if (
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value &&
+ oldVal?.id
+ ) {
+ selectedRecording.value = oldVal; // Temporarily restore old recording
+ saveEditSummary(); // This will save and cleanup
+ selectedRecording.value = newVal; // Switch back to new recording
+ }
+
+ chatMessages.value = [];
+ showChat.value = false;
+ selectedTab.value = "summary";
+
+ editingParticipants.value = false;
+ editingMeetingDate.value = false;
+ editingSummary.value = false;
+ editingNotes.value = false;
+
+ // Fix WebM duration issue by forcing metadata load
+ if (newVal?.id) {
+ nextTick(() => {
+ const audioElements = document.querySelectorAll("audio");
+ audioElements.forEach((audio) => {
+ if (
+ audio.src &&
+ audio.src.includes(`tool/speakr/audio/${newVal.id}`)
+ ) {
+ // For WebM files, we need to seek to end to get duration
+ const fixDuration = () => {
+ if (!isFinite(audio.duration) || audio.duration === 0) {
+ audio.currentTime = 1e101; // Seek to "infinity" to load duration
+ audio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ audio.currentTime = 0;
+ audio.removeEventListener("timeupdate", resetTime);
+ },
+ { once: true }
+ );
+ }
+ };
+
+ // Try to fix duration when metadata loads
+ audio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+
+ // Also try immediately in case metadata is already loaded
+ if (audio.readyState >= 1) {
+ fixDuration();
+ }
+ }
+ });
+ });
+ }
+ }
+ });
+ // 会议记录页面
+ //------------------- Realtime ASR session bridge start------------------
+ const createRealtimeAsrSession = () => {
+ if (!window.Speakr || !window.Speakr.createRealtimeAsrSession) {
+ throw new Error("Realtime ASR session module is not loaded");
+ }
+
+ return window.Speakr.createRealtimeAsrSession({
+ Recorder,
+ WebSocketConnectMethod,
+ getMicStream: () => micStream,
+ getSystemStream: () => systemStream,
+ getPausedTranscription: () => pausedTranscription.value || "",
+ setMainText: (text) => {
+ asrResult.value = text;
+ },
+ setPreviewText: (text) => {
+ asrResultOnline.value = text;
+ },
+ setOfflineText: (text) => {
+ asrResultOffline.value = text;
+ },
+ getLastTimer: () => lastTimer.value,
+ setLastTimer: (value) => {
+ lastTimer.value = value;
+ },
+ getConfigWords: () => configwords,
+ shouldShowSpeaker: () => showSpeaker.value,
+ getSegments: () => segmentList,
+ setSegments: (segments) => {
+ segmentList = segments;
+ },
+ getSegmentRenderStartIndex: () => segmentRenderStartIndex,
+ getRealtimeEpoch: () => liveTranscriptionEpoch,
+ getTimestampBarrier: () => realtimeTimestampBarrierMs,
+ setTimestampBarrier: (value) => {
+ realtimeTimestampBarrierMs = value;
+ },
+ getEditCarryover: () => realtimeEditCarryover,
+ setEditCarryover: (value) => {
+ realtimeEditCarryover = value;
+ },
+ setPendingCorrectionCount: (count) => {
+ pendingCorrectionCount = count;
+ },
+ onActiveChange: (active) => {
+ isRec = active;
+ },
+ });
+ };
+
+ const bindRealtimeAsrRefs = () => {
+ clearRealtimePipelineRef = realtimeAsrSession
+ ? realtimeAsrSession.clearRealtimePipeline
+ : null;
+ getLatestRealtimeTimestampRef = realtimeAsrSession
+ ? realtimeAsrSession.getLatestRealtimeTimestamp
+ : null;
+ buildRealtimeEditCarryoverRef = realtimeAsrSession
+ ? realtimeAsrSession.buildRealtimeEditCarryover
+ : null;
+ renderFullTextRef = realtimeAsrSession
+ ? realtimeAsrSession.renderFullText
+ : null;
+ };
+
+ const cleanupRealtimeAsrSession = () => {
+ if (realtimeAsrSession) {
+ realtimeAsrSession.cleanup();
+ realtimeAsrSession = null;
+ }
+ bindRealtimeAsrRefs();
+ isRec = false;
+ };
+
+ const fixRecordedAudioDuration = () => {
+ if (!audioBlobURL.value) return;
+
+ const recordingAudio = document.querySelector('audio[src*="blob:"]');
+ if (!recordingAudio) return;
+
+ const fixDuration = () => {
+ if (!isFinite(recordingAudio.duration) || recordingAudio.duration === 0) {
+ const originalTime = recordingAudio.currentTime;
+ recordingAudio.currentTime = 1e101;
+ recordingAudio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ recordingAudio.currentTime = originalTime;
+ recordingAudio.removeEventListener("timeupdate", resetTime);
+ },
+ { once: true }
+ );
+ }
+ };
+
+ recordingAudio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("canplay", fixDuration, {
+ once: true,
+ });
+ if (recordingAudio.readyState >= 1) {
+ setTimeout(fixDuration, 100);
+ }
+ };
+
+ watch(currentView, (newView) => {
+ if (newView === "recording") {
+ nextTick(() => {
+ initializeRecordingMarkdownEditor();
+ getBaseInfo();
+ is_final.value = false;
+ asrResultOnline.value = "";
+
+ cleanupRealtimeAsrSession();
+ try {
+ realtimeAsrSession = createRealtimeAsrSession();
+ bindRealtimeAsrRefs();
+ if (!isPaused.value) {
+ realtimeAsrSession.start();
+ }
+ } catch (error) {
+ console.error("Failed to initialize realtime ASR session:", error);
+ setGlobalError(error.message);
+ }
+
+ fixRecordedAudioDuration();
+ });
+ } else {
+ liveTranscriptionEpoch += 1;
+ realtimeTimestampBarrierMs = -1;
+ cleanupRealtimeAsrSession();
+ pendingCorrectionCount = 0;
+ analysisResult.value = "";
+ asrResult.value = "";
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ isEditingTranscription.value = false;
+ is_final.value = false;
+ if (recordingMarkdownEditorInstance.value) {
+ recordingMarkdownEditorInstance.value.toTextArea();
+ recordingMarkdownEditorInstance.value = null;
+ }
+ }
+ });
+ //--------------------Realtime ASR session bridge end------------------
+
+ // 监听内容变化
+ watch(asrResult, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+ if (asrTextarea.value) {
+ // console.log(asrTextarea.value.scrollHeight,'asrTextarea.value.scrollHeight');
+
+ asrTextarea.value.scrollTop = asrTextarea.value.scrollHeight;
+ }
+ if (asrResultTextarea.value) {
+ asrResultTextarea.value.scrollTop =
+ asrResultTextarea.value.scrollHeight;
+ }
+ });
+ // 监听内容变化
+ watch(asrResultOnline, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+
+ if (asrResultTextareaOnline.value) {
+ asrResultTextareaOnline.value.scrollTop =
+ asrResultTextareaOnline.value.scrollHeight;
+ }
+ });
+ watch(asrResultOffline, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+
+ if (asrResultTextareaOnline.value) {
+ asrResultTextareaOnline.value.scrollTop =
+ asrResultTextareaOnline.value.scrollHeight;
+ }
+ });
+ let realtimeAutoScrollFrame = null;
+ const scrollRealtimeTextareaToBottom = (textarea) => {
+ if (!textarea) return;
+ textarea.scrollTop = textarea.scrollHeight;
+ };
+ const scrollVisibleRealtimeTextareas = () => {
+ [
+ asrTextarea.value,
+ asrResultTextarea.value,
+ asrResultTextareaOnline.value,
+ ].forEach(scrollRealtimeTextareaToBottom);
+ };
+ const scheduleRealtimeAutoScroll = async (force = false) => {
+ if (isEditingTranscription.value && !force) {
+ return;
+ }
+
+ await nextTick();
+ scrollVisibleRealtimeTextareas();
+
+ if (
+ typeof window === "undefined" ||
+ typeof window.requestAnimationFrame !== "function"
+ ) {
+ return;
+ }
+
+ if (realtimeAutoScrollFrame !== null) {
+ window.cancelAnimationFrame(realtimeAutoScrollFrame);
+ realtimeAutoScrollFrame = null;
+ }
+
+ realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
+ scrollVisibleRealtimeTextareas();
+ realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
+ scrollVisibleRealtimeTextareas();
+ realtimeAutoScrollFrame = null;
+ });
+ });
+ };
+ watch(fullRealtimeTranscription, () => {
+ scheduleRealtimeAutoScroll();
+ });
+ watch(isEditingTranscription, () => {
+ scheduleRealtimeAutoScroll(true);
+ });
+ watch(isyjDialogVisible, (visible) => {
+ if (visible) {
+ scheduleRealtimeAutoScroll(true);
+ }
+ });
+ watch(analysisResult, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+ if (sumupResultTextarea.value) {
+ sumupResultTextarea.value.scrollTop =
+ sumupResultTextarea.value.scrollHeight;
+ }
+ });
+ watch(audioBlobURL, (newURL) => {
+ if (newURL) {
+ nextTick(() => {
+ const recordingAudio = document.querySelector(
+ 'audio[src*="blob:"]'
+ );
+
+ if (recordingAudio) {
+ const fixDuration = () => {
+ if (
+ !isFinite(recordingAudio.duration) ||
+ recordingAudio.duration === 0
+ ) {
+ const originalTime = recordingAudio.currentTime;
+ // recordingAudio.currentTime = 1e101;
+ recordingAudio.currentTime = originalTime + 0.01
+
+ recordingAudio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ recordingAudio.currentTime = originalTime;
+ recordingAudio.removeEventListener(
+ "timeupdate",
+ resetTime
+ );
+ },
+ { once: true }
+ );
+ }
+ };
+
+ // Multiple events to ensure we catch the duration
+ recordingAudio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("loadeddata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("canplay", fixDuration, {
+ once: true,
+ });
+
+ // Also try after a delay
+ if (recordingAudio.readyState >= 1) {
+ setTimeout(fixDuration, 100);
+ }
+ }
+ });
+ }
+ });
+
+ watch(playerVolume, (newVolume) => {
+ const audioElements = document.querySelectorAll("audio");
+ audioElements.forEach((audio) => {
+ if (audio.volume !== newVolume) {
+ audio.volume = newVolume;
+ }
+ });
+ });
+
+ // Watch for search query changes
+ watch(searchQuery, (newQuery) => {
+ debouncedSearch(newQuery);
+ });
+
+ // Auto-apply filters when they change (except text query which is debounced)
+ watch(
+ filterTags,
+ () => {
+ applyAdvancedFilters();
+ },
+ { deep: true }
+ );
+
+ watch(filterDatePreset, () => {
+ applyAdvancedFilters();
+ });
+
+ watch(
+ filterDateRange,
+ () => {
+ applyAdvancedFilters();
+ },
+ { deep: true }
+ );
+
+ // Debounce text query changes
+ watch(filterTextQuery, (newValue) => {
+ clearTimeout(searchDebounceTimer.value);
+ searchDebounceTimer.value = setTimeout(() => {
+ applyAdvancedFilters();
+ }, 300);
+ });
+
+ //--------------------响应式监听与界面联动 end------------------
+
+ //------------------- 运行时配置加载 start------------------
+ // --- Configuration Loading ---
+ const loadConfiguration = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/config");
+ if (response.ok) {
+ const config = await response.json();
+ maxFileSizeMB.value = config.max_file_size_mb || 250;
+ chunkingEnabled.value =
+ config.chunking_enabled !== undefined
+ ? config.chunking_enabled
+ : true;
+ chunkingMode.value = config.chunking_mode || "size";
+ chunkingLimit.value = config.chunking_limit || 20;
+ chunkingLimitDisplay.value =
+ config.chunking_limit_display || "20MB";
+ recordingDisclaimer.value = config.recording_disclaimer || "";
+ console.log(
+ `Loaded configuration: max size ${
+ maxFileSizeMB.value
+ }MB, chunking ${
+ chunkingEnabled.value ? "enabled" : "disabled"
+ } (${chunkingLimitDisplay.value})`
+ );
+ } else {
+ console.warn("Failed to load configuration, using default values");
+ }
+ } catch (error) {
+ console.error("Error loading configuration:", error);
+ // Keep default values on error
+ }
+ };
+
+ const initializeSummaryMarkdownEditor = () => {
+ if (!summaryMarkdownEditor.value) return;
+
+ try {
+ summaryMarkdownEditorInstance.value = new EasyMDE({
+ element: summaryMarkdownEditor.value,
+ spellChecker: false,
+ autofocus: true,
+ placeholder: "Enter summary in Markdown format...",
+ initialValue: selectedRecording.value?.summary || "",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "image",
+ "|",
+ "preview",
+ "side-by-side",
+ "fullscreen",
+ "|",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ });
+
+ // Add auto-save functionality
+ summaryMarkdownEditorInstance.value.codemirror.on("change", () => {
+ if (autoSaveTimer.value) {
+ clearTimeout(autoSaveTimer.value);
+ }
+ autoSaveTimer.value = setTimeout(() => {
+ autoSaveSummary();
+ }, autoSaveDelay);
+ });
+ } catch (error) {
+ console.error("Failed to initialize summary markdown editor:", error);
+ editingSummary.value = true;
+ }
+ };
+
+ const pollInboxRecordings = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/inbox_recordings");
+ if (!response.ok) {
+ // Silently fail, as this is a background task
+ return;
+ }
+ const inboxRecordings = await response.json();
+
+ if (inboxRecordings && inboxRecordings.length > 0) {
+ inboxRecordings.forEach((recording) => {
+ // Add to main recordings list if it's not there already
+ const existingRecording = recordings.value.find(
+ (r) => r.id === recording.id
+ );
+ if (!existingRecording) {
+ recordings.value.unshift(recording);
+ totalRecordings.value++; // Update total count
+ }
+
+ // Check if this recording is already in the upload queue or currently being processed
+ const existingItem = uploadQueue.value.find(
+ (item) => item.recordingId === recording.id
+ );
+ const isCurrentlyProcessing =
+ currentlyProcessingFile.value &&
+ currentlyProcessingFile.value.recordingId === recording.id;
+
+ // Don't add if it's already in queue or being processed
+ if (existingItem || isCurrentlyProcessing) {
+ // Update status if the existing item status has changed
+ if (existingItem && existingItem.status !== recording.status) {
+ console.log(
+ `Updating status for existing recording ${recording.original_filename}: ${existingItem.status} -> ${recording.status}`
+ );
+ }
+ return;
+ }
+
+ // Only add recordings that are still processing (not completed)
+ if (recording.status === "COMPLETED") {
+ console.log(
+ `Skipping completed inbox recording: ${recording.original_filename}`
+ );
+ return;
+ }
+
+ console.log(
+ `Found new inbox recording: ${recording.original_filename} (status: ${recording.status})`
+ );
+
+ // Don't add recordings that are already being handled by main processing
+ if (
+ recording.status === "SUMMARIZING" &&
+ isProcessingActive.value
+ ) {
+ console.log(
+ `Skipping ${recording.original_filename} - already in summarization phase of main processing`
+ );
+ return;
+ }
+
+ // Add it to the queue to be displayed in the progress modal
+ const inboxItem = {
+ file: {
+ name:
+ recording.original_filename || `Recording ${recording.id}`,
+ size: recording.file_size || 0,
+ },
+ status: "pending", // It's already being processed on backend
+ recordingId: recording.id,
+ clientId: `inbox-${recording.id}-${Date.now()}`,
+ error: null,
+ isReprocessing: true, // Use reprocessing logic to poll for status
+ reprocessType: "transcription",
+ };
+ uploadQueue.value.unshift(inboxItem);
+
+ // If nothing is currently being processed, make this the active item for the main progress bar
+ if (!isProcessingActive.value && !currentlyProcessingFile.value) {
+ currentlyProcessingFile.value = inboxItem;
+ }
+
+ // Show progress modal if it's hidden
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ // Start polling its status
+ startReprocessingPoll(recording.id);
+ });
+ }
+ } catch (error) {
+ console.error("Error polling for inbox recordings:", error);
+ }
+ };
+
+ //--------------------运行时配置加载 end------------------
+
+ //------------------- 录音格式兼容检测 start------------------
+ // --- Audio Format Detection ---
+ const detectSupportedAudioFormats = () => {
+ const formats = [
+ "audio/webm;codecs=opus",
+ "audio/webm;codecs=vp9",
+ "audio/webm",
+ "audio/mp4;codecs=mp4a.40.2",
+ "audio/mp4",
+ "audio/ogg;codecs=opus",
+ "audio/wav",
+ ];
+
+ const supportedFormats = formats.filter((format) =>
+ MediaRecorder.isTypeSupported(format)
+ );
+ console.log("Supported audio recording formats:", supportedFormats);
+
+ if (supportedFormats.length === 0) {
+ console.warn(
+ "No optimized audio formats supported, will use browser default"
+ );
+ }
+
+ return supportedFormats;
+ };
+
+ //--------------------录音格式兼容检测 end------------------
+
+ //------------------- 系统音频能力检测 start------------------
+ // --- System Audio Detection ---
+ const detectSystemAudioCapabilities = async () => {
+ systemAudioSupported.value = false;
+ canRecordSystemAudio.value = false;
+ systemAudioError.value = "";
+
+ // Check if getDisplayMedia is available
+ if (
+ !navigator.mediaDevices ||
+ !navigator.mediaDevices.getDisplayMedia
+ ) {
+ systemAudioError.value = "getDisplayMedia API not supported";
+ return;
+ }
+
+ try {
+ // Test if we can request system audio (this will prompt user)
+ // We'll do this only when user actually tries to record
+ systemAudioSupported.value = true;
+ canRecordSystemAudio.value = true;
+ systemAudioError.value = "";
+ } catch (error) {
+ systemAudioError.value = error.message;
+ console.warn("System audio detection failed:", error);
+ }
+ };
+
+ const detectBrowser = () => {
+ const userAgent = navigator.userAgent;
+ if (userAgent.includes("Firefox")) {
+ browser.value = "firefox";
+ } else if (userAgent.includes("Chrome")) {
+ browser.value = "chrome";
+ } else if (userAgent.includes("Safari")) {
+ browser.value = "safari";
+ } else if (
+ userAgent.includes("MSIE") ||
+ userAgent.includes("Trident/")
+ ) {
+ browser.value = "ie";
+ } else {
+ browser.value = "unknown";
+ }
+ };
+
+ //--------------------系统音频能力检测 end------------------
+
+ //------------------- 生命周期初始化 start------------------
+ // --- Lifecycle ---
+ onMounted(async () => {
+ const loader = document.getElementById("loader");
+ const app = document.getElementById("app");
+ if (loader) {
+ loader.style.opacity = "0";
+ setTimeout(() => {
+ loader.style.display = "none";
+ }, 500);
+ }
+ if (app) {
+ app.style.opacity = "1";
+ }
+ const appDiv = document.getElementById("app");
+ if (appDiv) {
+ const asrFlag = appDiv.dataset.useAsrEndpoint;
+ useAsrEndpoint.value = asrFlag === "True" || asrFlag === "true";
+ currentUserName.value = appDiv.dataset.currentUserName || "";
+ }
+
+ // Load configuration first
+ await loadConfiguration();
+
+ // Detect system audio capabilities
+ await detectSystemAudioCapabilities();
+
+ detectBrowser();
+
+ // Check if language was changed in account settings
+ if (localStorage.getItem("ui_language_changed") === "true") {
+ localStorage.removeItem("ui_language_changed");
+ // Force reload to apply new language
+ window.location.reload();
+ return;
+ }
+
+ // i18n is already initialized before Vue app creation
+ if (window.i18n) {
+ currentLanguage.value = window.i18n.getLocale();
+ availableLanguages.value = window.i18n.getAvailableLocales();
+ const lang = availableLanguages.value.find(
+ (l) => l.code === currentLanguage.value
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+
+ // Listen for locale changes
+ window.addEventListener("localeChanged", (event) => {
+ currentLanguage.value = event.detail.locale;
+ const lang = availableLanguages.value.find(
+ (l) => l.code === currentLanguage.value
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+ });
+ }
+
+ loadRecordings();
+ loadTags();
+ loadDrafts(); // 加载草稿列表
+ initializeDarkMode();
+ initializeColorScheme();
+
+ const savedVolume = localStorage.getItem("playerVolume");
+ if (savedVolume !== null) {
+ playerVolume.value = parseFloat(savedVolume);
+ }
+
+ const savedTranscriptionViewMode = localStorage.getItem(
+ "transcriptionViewMode"
+ );
+ if (savedTranscriptionViewMode) {
+ transcriptionViewMode.value = savedTranscriptionViewMode;
+ }
+
+ // Load saved column widths
+ const savedLeftWidth = localStorage.getItem("transcriptColumnWidth");
+ const savedRightWidth = localStorage.getItem("summaryColumnWidth");
+ if (savedLeftWidth && savedRightWidth) {
+ leftColumnWidth.value = parseFloat(savedLeftWidth);
+ rightColumnWidth.value = parseFloat(savedRightWidth);
+ }
+
+ const updateMobileStatus = () => {
+ windowWidth.value = window.innerWidth;
+ };
+
+ window.addEventListener("resize", updateMobileStatus);
+ updateMobileStatus();
+
+ // Start polling for inbox recordings
+ setInterval(pollInboxRecordings, 10000);
+
+ const handleEsc = (e) => {
+ if (e.key === "Escape") {
+ if (showColorSchemeModal.value) {
+ closeColorSchemeModal();
+ }
+ if (showEditModal.value) {
+ cancelEdit();
+ }
+ if (showDeleteModal.value) {
+ cancelDelete();
+ }
+ if (showSortOptions.value) {
+ showSortOptions.value = false;
+ }
+ }
+ };
+ document.addEventListener("keydown", handleEsc);
+
+ // Click away handler for dropdowns
+ const handleClickAway = (e) => {
+ // Close user menu if clicking outside
+ if (isUserMenuOpen.value) {
+ // Check if we clicked within the user menu area (button or dropdown)
+ const userMenuButton = e.target.closest(
+ 'button[class*="flex items-center gap"]'
+ );
+ const userMenuDropdown = e.target.closest(
+ 'div[class*="absolute right-0"]'
+ );
+
+ // Check if the click was on the user menu button specifically
+ const isUserMenuButtonClick =
+ userMenuButton &&
+ userMenuButton.querySelector("i.fa-user-circle");
+
+ // If we didn't click on the user menu button or dropdown, close it
+ if (!isUserMenuButtonClick && !userMenuDropdown) {
+ isUserMenuOpen.value = false;
+ }
+ }
+
+ // Close speaker suggestions if clicking outside in the modal
+ if (showSpeakerModal.value) {
+ // If not clicking on an input field or suggestion dropdown
+ const clickedOnInput = e.target.closest('input[type="text"]');
+ const clickedOnSuggestion = e.target.closest(
+ '[class*="absolute z-10"]'
+ );
+
+ if (!clickedOnInput && !clickedOnSuggestion) {
+ // Close all suggestion dropdowns
+ Object.keys(speakerSuggestions.value).forEach((speakerId) => {
+ speakerSuggestions.value[speakerId] = [];
+ });
+ }
+ }
+ };
+ document.addEventListener("click", handleClickAway);
+ });
+
+ //--------------------生命周期初始化 end------------------
+
+ //------------------- 实时转写手动编辑模式 start------------------
+ // --- 转录文本编辑功能 ---
+ // 用户手动编辑后,以编辑结果为准,旧的实时结果不再回灌。
+ let editModeOriginalText = "";
+
+ function enterEditMode() {
+ editModeOriginalText = fullRealtimeTranscription.value || "";
+ editableText.value = editModeOriginalText;
+ editModeBufferStartIndex = segmentList.length;
+ const barrierTimestampMs = getLatestRealtimeTimestampRef
+ ? getLatestRealtimeTimestampRef()
+ : null;
+ startNewRealtimeEpoch({
+ barrierTimestampMs,
+ clearEditCarryover: false,
+ });
+ realtimeEditCarryover = buildRealtimeEditCarryoverRef
+ ? buildRealtimeEditCarryoverRef(editModeOriginalText)
+ : null;
+ isEditingTranscription.value = true;
+ }
+
+ function exitEditMode() {
+ const nextText = editableText.value ?? "";
+
+ isEditingTranscription.value = false;
+
+ pausedTranscription.value = nextText;
+ asrResult.value = nextText;
+ asrResultOnline.value = "";
+ asrResultOffline.value = "";
+ segmentRenderStartIndex = editModeBufferStartIndex;
+ editModeOriginalText = nextText;
+
+ if (renderFullTextRef) {
+ renderFullTextRef();
+ }
+ }
+
+ //--------------------实时转写手动编辑模式 end------------------
+
+ //------------------- 模板暴露与事件出口 start------------------
+ return {
+ // Core State
+ currentView,
+ dragover,
+ recordings,
+ selectedRecording,
+ selectedTab,
+ searchQuery,
+ isLoadingRecordings,
+ globalError,
+ maxFileSizeMB,
+ chunkingEnabled,
+ chunkingMode,
+ chunkingLimit,
+ chunkingLimitDisplay,
+ sortBy,
+ showAdvancedFilters,
+ filterTags,
+ filterDateRange,
+ filterDatePreset,
+ filterTextQuery,
+
+ // Pagination State
+ currentPage,
+ perPage,
+ totalRecordings,
+ totalPages,
+ hasNextPage,
+ hasPrevPage,
+ isLoadingMore,
+
+ // UI State
+ browser,
+ isSidebarCollapsed,
+ searchTipsExpanded,
+ isUserMenuOpen,
+ isDarkMode,
+ currentColorScheme,
+ showColorSchemeModal,
+ windowWidth,
+ isMobileScreen,
+ mobileTab,
+ isMetadataExpanded,
+
+ // i18n State
+ currentLanguage,
+ currentLanguageName,
+ availableLanguages,
+ showLanguageMenu,
+
+ // Upload State
+ uploadQueue,
+ currentlyProcessingFile,
+ processingProgress,
+ processingMessage,
+ isProcessingActive,
+ progressPopupMinimized,
+ progressPopupClosed,
+ totalInQueue,
+ completedInQueue,
+ finishedFilesInQueue,
+ clearCompletedUploads,
+
+ // Audio Recording
+ editingDraftId,
+ editingDraftName,
+
+ isRecording,
+ canRecordAudio,
+ canRecordSystemAudio,
+ systemAudioSupported,
+ systemAudioError,
+ audioBlobURL,
+ recordingTime,
+ recordingNotes,
+ visualizer,
+ micVisualizer,
+ systemVisualizer,
+ recordingMode,
+ recordingDisclaimer,
+ showRecordingDisclaimerModal,
+ acceptRecordingDisclaimer,
+ cancelRecordingDisclaimer,
+ showAdvancedOptions,
+ uploadLanguage,
+ uploadMinSpeakers,
+ uploadMaxSpeakers,
+ asrLanguage,
+ asrMinSpeakers,
+ asrMaxSpeakers,
+ availableTags,
+ selectedTagIds,
+ selectedTags,
+ uploadTagSearchFilter,
+ filteredAvailableTagsForUpload,
+ onTagSelected,
+ addTagToSelection,
+ removeTagFromSelection,
+ showSystemAudioHelp,
+
+ // Draft/Pause Recording
+ isPaused,
+ isStoppingRecording,
+ currentDraftId,
+ showRecordingControls,
+ pausedDuration,
+ pausedTranscription,
+ draftRecordings,
+ draftsExpanded,
+ pauseRecording,
+ resumeRecording,
+ loadDrafts,
+ continueDraftRecording,
+ deleteDraft,
+
+ // Modal State
+ showEditModal,
+ showDeleteModal,
+ showResetModal,
+ editingRecording,
+ recordingToDelete,
+ showEditTagsModal,
+ selectedNewTagId,
+ tagSearchFilter,
+ filteredAvailableTagsForModal,
+ editRecordingTags,
+ closeEditTagsModal,
+ addTagToRecording,
+ removeTagFromRecording,
+ getRecordingTags,
+ getAvailableTagsForRecording,
+ filterByTag,
+ clearTagFilter,
+ applyAdvancedFilters,
+ clearAllFilters,
+ showTextEditorModal,
+ showAsrEditorModal,
+ editingTranscriptionContent,
+ editingSegments,
+ availableSpeakers,
+
+ // Inline Editing
+ editingParticipants,
+ editingMeetingDate,
+ editingSummary,
+ editingNotes,
+
+ // Markdown Editor
+ notesMarkdownEditor,
+ markdownEditorInstance,
+ summaryMarkdownEditor,
+ summaryMarkdownEditorInstance,
+ recordingNotesEditor,
+
+ // Transcription
+ transcriptionViewMode,
+ legendExpanded,
+ highlightedSpeaker,
+ processedTranscription,
+
+ // Chat
+ showChat,
+ isChatMaximized,
+ toggleChatMaximize,
+ chatMessages,
+ chatInput,
+ isChatLoading,
+ chatMessagesRef,
+
+ // Audio Player
+ playerVolume,
+
+ // Column Resizing
+ leftColumnWidth,
+ rightColumnWidth,
+ isResizing,
+
+ // App Configuration
+ useAsrEndpoint,
+ currentUserName,
+
+ // Computed
+ filteredRecordings,
+ groupedRecordings,
+ activeRecordingMetadata,
+ datePresetOptions,
+ languageOptions,
+
+ // Color Schemes
+ colorSchemes,
+ asrResult,
+ asrResultOnline,
+ asrResultOffline,
+ fullRealtimeTranscription,
+ asrTextarea,
+ asrResultTextarea,
+ asrResultTextareaOnline,
+ analysisResult,
+ sumupResultTextarea,
+ isDialogVisible,
+ isyjDialogVisible,
+ baseInfo,
+ isModalOpen,
+ urlinfo,
+ urlmsg,
+ zjloding,
+ // Methods
+ openmodel,
+ openzjmodel,
+ closeModal,
+ setGlobalError,
+ formatFileSize,
+ formatDisplayDate,
+ formatStatus,
+ getStatusClass,
+ formatTime,
+ t,
+ tc,
+ changeLanguage,
+ toggleDarkMode,
+ applyColorScheme,
+ initializeColorScheme,
+ openColorSchemeModal,
+ closeColorSchemeModal,
+ selectColorScheme,
+ resetColorScheme,
+ toggleSidebar,
+ switchToUploadView,
+ selectRecording,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ handleFileSelect,
+ addFilesToQueue,
+ startRecording,
+ stopRecording,
+ uploadRecordedAudio,
+ discardRecording,
+ downloadRecordedAudio,
+ loadRecordings,
+ loadMoreRecordings,
+ performSearch,
+ debouncedSearch,
+ saveMetadata,
+ editRecording,
+ cancelEdit,
+ saveEdit,
+ confirmDelete,
+ cancelDelete,
+ deleteRecording,
+ toggleEditParticipants,
+ toggleEditMeetingDate,
+ toggleEditSummary,
+ cancelEditSummary,
+ saveEditSummary,
+ toggleEditNotes,
+ cancelEditNotes,
+ saveEditNotes,
+ initializeMarkdownEditor,
+ saveInlineEdit,
+ clickToEditNotes,
+ clickToEditSummary,
+ autoSaveNotes,
+ autoSaveSummary,
+ sendChatMessage,
+ isChatScrolledToBottom,
+ scrollChatToBottom,
+ startColumnResize,
+ handleChatKeydown,
+ seekAudio,
+ seekAudioFromEvent,
+ onPlayerVolumeChange,
+ showToast,
+ copyMessage,
+ copyTranscription,
+ copySummary,
+ copyNotes,
+ downloadSummary,
+ downloadTranscript,
+ downloadNotes,
+ downloadChat,
+ clearChat,
+ downloadEventICS,
+ downloadICS,
+ formatEventDateTime,
+ toggleInbox,
+ toggleHighlight,
+ toggleTranscriptionViewMode,
+ reprocessTranscription,
+ reprocessSummary,
+ generateSummary,
+ resetRecordingStatus,
+ confirmReset,
+ cancelReset,
+ executeReset,
+ openTranscriptionEditor,
+ openTextEditorModal,
+ closeTextEditorModal,
+ saveTranscription,
+ openAsrEditorModal,
+ closeAsrEditorModal,
+ saveAsrTranscription,
+ adjustTime,
+ filterSpeakers,
+ openSpeakerSuggestions,
+ closeSpeakerSuggestions,
+ selectSpeaker,
+ addSegment,
+ removeSegment,
+ showReprocessModal,
+ reprocessType,
+ reprocessRecording,
+ cancelReprocess,
+ executeReprocess,
+ asrReprocessOptions,
+ showSpeakerModal,
+ showShareModal,
+ recordingToShare,
+ shareOptions,
+ generatedShareLink,
+ existingShareDetected,
+ userShares,
+ isLoadingShares,
+ showSharesListModal,
+ shareToDelete,
+ showShareDeleteModal,
+ confirmDeleteShare,
+ cancelDeleteShare,
+ speakerMap,
+ speakerDisplayMap,
+ modalSpeakers,
+ regenerateSummaryAfterSpeakerUpdate,
+ identifiedSpeakers,
+ identifiedSpeakersInOrder,
+ hasSpeakerNames,
+ openSpeakerModal,
+ closeSpeakerModal,
+ saveSpeakerNames,
+ highlightedTranscript,
+ highlightedSpeaker,
+ highlightSpeakerInTranscript,
+ focusSpeaker,
+ blurSpeaker,
+ clearSpeakerHighlight,
+ currentSpeakerGroupIndex,
+ speakerGroups,
+ navigateToNextSpeakerGroup,
+ navigateToPrevSpeakerGroup,
+ speakerSuggestions,
+ loadingSuggestions,
+ activeSpeakerInput,
+ searchSpeakers,
+ selectSpeakerSuggestion,
+ closeSpeakerSuggestionsOnClick,
+ autoIdentifySpeakers,
+ isAutoIdentifying,
+ formatDuration,
+ openShareModal,
+ closeShareModal,
+ createShare,
+ openSharesList,
+ closeSharesList,
+ updateShare,
+ deleteShare,
+ copyShareLink,
+ startDraftRename,
+ saveDraftRename,
+ editingDraftId,
+ editingDraftName,
+ draftNameInput,
+ draftRecordings,
+ deleteDraft,
+ continueDraftRecording,
+ loadDrafts,
+
+ // Recording Size Monitoring
+ estimatedFileSize,
+ fileSizeWarningShown,
+ recordingQuality,
+ actualBitrate,
+ maxRecordingMB,
+ sizeCheckInterval,
+ updateFileSizeEstimate,
+ startSizeMonitoring,
+ stopSizeMonitoring,
+ sumupResult,
+ lastTimer,
+ promptVisible,
+ promptList,
+ openPromptModal,
+ closePromptModal,
+ confirmPromptSelection,
+ selectedPromptId,
+ recommendPromptId,
+ recommendPromptName,
+ isMarkdown,
+ renderedMarkdown,
+ recommendPromptReason,
+ showSpeaker,
+ isEditingTranscription,
+ editableText,
+ enterEditMode,
+ exitEditMode,
+ };
+ },
+ delimiters: ["${", "}"],
+ });
+
+ //--------------------模板暴露与事件出口 end------------------
+
+ //--------------------Vue 应用主体定义 end------------------
+
+ //------------------- Vue 全局注入与挂载 start------------------
+ // Add t function as a global property BEFORE mounting so it's available in templates immediately
+ app.config.globalProperties.t = safeT;
+
+ // Also add tc for pluralization
+ app.config.globalProperties.tc = (key, count, params = {}) => {
+ if (!window.i18n || !window.i18n.tc) {
+ return key;
+ }
+ return window.i18n.tc(key, count, params);
+ };
+
+ // Provide t and tc to the app
+ app.provide("t", safeT);
+ app.provide("tc", (key, count, params = {}) => {
+ if (!window.i18n || !window.i18n.tc) {
+ return key;
+ }
+ return window.i18n.tc(key, count, params);
+ });
+
+ // Mount the app
+ app.mount("#app");
+
+ // Hide loading overlay after Vue is mounted and ready
+ Vue.nextTick(() => {
+ const overlay = document.querySelector(".app-loading-overlay");
+ if (overlay) {
+ // Small delay to ensure everything is rendered
+ setTimeout(() => {
+ overlay.classList.add("fade-out");
+ setTimeout(() => {
+ overlay.remove();
+ document.body.classList.remove("app-loading");
+ }, 300);
+ }, 100);
+ } else {
+ document.body.classList.remove("app-loading");
+ }
+ });
+ //--------------------Vue 全局注入与挂载 end------------------
+});
diff --git a/speakr/static/js/app.js b/speakr/static/js/app.js
new file mode 100644
index 0000000..e28c71e
--- /dev/null
+++ b/speakr/static/js/app.js
@@ -0,0 +1,8929 @@
+const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
+// Wait for the DOM to be fully loaded before mounting the Vue app
+document.addEventListener("DOMContentLoaded", async () => {
+ // Initialize i18n before creating Vue app
+ if (window.i18n) {
+ const appElement = document.getElementById("app");
+ const userLang =
+ appElement?.dataset.userLanguage ||
+ localStorage.getItem("preferredLanguage") ||
+ "en";
+ await window.i18n.init(userLang);
+ console.log("i18n initialized with language:", userLang);
+ }
+
+ // CSRF Token Integration with Vue.js
+ const csrfToken = ref(
+ document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
+ );
+
+ // Register Service Worker only when the browser accepts the current TLS context.
+ if ("serviceWorker" in navigator && window.isSecureContext && window.location.hostname !== "localhost") {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker
+ .register("/tool/speakr/static/sw.js")
+ .then((registration) => {
+ console.log(
+ "ServiceWorker registration successful with scope: ",
+ registration.scope
+ );
+ })
+ .catch((error) => {
+ console.log("ServiceWorker registration failed: ", error);
+ });
+ });
+ }
+
+ // Create a safe t function that's always available
+ const safeT = (key, params = {}) => {
+ if (!window.i18n || !window.i18n.t) {
+ return key; // Return key as fallback without warning during initial render
+ }
+ return window.i18n.t(key, params);
+ };
+ let webReader = null;
+ let webReader2 = null;
+ // ASR Recorder 实例(提升到外层以便跨录音周期清理)
+ let rec = null;
+ let rec2 = null;
+ // openMixedStream 创建的 AudioContext(需跨周期清理)
+ let asrAudioContext = null;
+ let asrAudioContext2 = null;
+ // 用于标记是否需要重置转录状态(暂停/恢复时使用)
+ let resetTranscriptionFlag = false;
+ let pendingCorrectionCount = 0;
+ const app = createApp({
+ setup() {
+ // --- Core State ---
+ // 新增说话人占位
+ let segmentList = [];
+ let lastSpeaker = "";
+ let configwords = "";
+ const currentView = ref("upload"); // 'upload' or 'recording'
+ const dragover = ref(false);
+ const recordings = ref([]);
+ const selectedRecording = ref(null);
+ const selectedTab = ref("summary"); // 'summary' or 'notes'
+ const searchQuery = ref("");
+ const isLoadingRecordings = ref(true);
+ const globalError = ref(null);
+
+ // Advanced filter state
+ const showAdvancedFilters = ref(false);
+ const filterTags = ref([]); // Selected tag IDs for filtering
+ const filterDateRange = ref({ start: "", end: "" });
+ const filterDatePreset = ref(""); // 'today', 'yesterday', 'week', 'month', etc.
+ const filterTextQuery = ref("");
+
+ // --- Pagination State ---
+ const currentPage = ref(1);
+ const perPage = ref(25);
+ const totalRecordings = ref(0);
+ const totalPages = ref(0);
+ const hasNextPage = ref(false);
+ const hasPrevPage = ref(false);
+ const isLoadingMore = ref(false);
+ const searchDebounceTimer = ref(null);
+
+ // --- Enhanced Search & Organization State ---
+ const sortBy = ref("created_at"); // 'created_at' or 'meeting_date'
+ const selectedTagFilter = ref(null); // For filtering by clicked tag
+
+ // --- UI State ---
+ const browser = ref("unknown");
+ const isSidebarCollapsed = ref(false);
+ const searchTipsExpanded = ref(false);
+ const isUserMenuOpen = ref(false);
+ const isDarkMode = ref(false);
+ const currentColorScheme = ref("blue");
+ const showColorSchemeModal = ref(false);
+ const windowWidth = ref(window.innerWidth);
+ const mobileTab = ref("transcript");
+ const isMetadataExpanded = ref(false);
+
+ // --- i18n State ---
+ const currentLanguage = ref("en");
+ const currentLanguageName = ref("English");
+ const availableLanguages = ref([]);
+ const showLanguageMenu = ref(false);
+
+ // --- Upload State ---
+ const uploadQueue = ref([]);
+ const currentlyProcessingFile = ref(null);
+ const processingProgress = ref(0);
+ const processingMessage = ref("");
+ const isProcessingActive = ref(false);
+ const pollInterval = ref(null);
+ const progressPopupMinimized = ref(false);
+ const progressPopupClosed = ref(false);
+ const maxFileSizeMB = ref(250); // Default value, will be updated from API
+ const chunkingEnabled = ref(true); // Default value, will be updated from API
+ const chunkingMode = ref("size"); // 'size' or 'duration', will be updated from API
+ const chunkingLimit = ref(20); // Value in MB or seconds, will be updated from API
+ const chunkingLimitDisplay = ref("20MB"); // Human readable display, will be updated from API
+ const recordingDisclaimer = ref(""); // Recording disclaimer text from admin settings
+ const showRecordingDisclaimerModal = ref(false); // Controls disclaimer modal visibility
+ const pendingRecordingMode = ref(null); // Stores the recording mode while showing disclaimer
+
+ // --- Audio Recording State ---
+ const editingDraftId = ref(null);
+ const editingDraftName = ref('');
+ const draftNameInput = ref(null);
+
+ const isRecording = ref(false);
+ const isStoppingRecording = ref(false);
+ const mediaRecorder = ref(null);
+ const audioChunks = ref([]);
+ const audioBlobURL = ref(null);
+ const recordingTime = ref(0);
+ const recordingInterval = ref(null);
+ const canRecordAudio = ref(
+ navigator.mediaDevices && navigator.mediaDevices.getUserMedia
+ );
+ const canRecordSystemAudio = ref(false);
+ const systemAudioSupported = ref(false);
+ const systemAudioError = ref("");
+ const recordingNotes = ref("");
+ const showSystemAudioHelp = ref(false);
+ // ASR options for recording view
+ const asrLanguage = ref(""); // Empty string for auto-detect
+ const asrMinSpeakers = ref(""); // Empty string for auto-detect
+ const asrMaxSpeakers = ref(""); // Empty string for auto-detect
+ const audioContext = ref(null);
+ const analyser = ref(null);
+ const micAnalyser = ref(null);
+ const systemAnalyser = ref(null);
+ const visualizer = ref(null);
+ const micVisualizer = ref(null);
+ const systemVisualizer = ref(null);
+ const animationFrameId = ref(null);
+ const recordingMode = ref("microphone"); // 'microphone', 'system', or 'both'
+ const activeStreams = ref([]); // Track active streams for cleanup
+
+ // --- Recording Size Monitoring ---
+ const estimatedFileSize = ref(0);
+ const fileSizeWarningShown = ref(false);
+ const recordingQuality = ref("optimized"); // 'optimized', 'standard', 'high'
+ const actualBitrate = ref(0);
+ const maxRecordingMB = ref(200); // Maximum recording size before auto-stop
+ const sizeCheckInterval = ref(null);
+
+ // --- Draft/Pause Recording State ---
+ const isPaused = ref(false);
+ const currentDraftId = ref(null);
+ const pausedDuration = ref(0); // 暂停前的累计时长
+ const pausedTranscription = ref(''); // 暂停前的转录文本
+ const draftRecordings = ref([]); // 草稿列表
+ const draftsExpanded = ref(true); // 草稿列表是否展开
+
+ // Advanced Options for ASR
+ const showAdvancedOptions = ref(false);
+ const uploadLanguage = ref(""); // Empty string for auto-detect
+ const uploadMinSpeakers = ref(""); // Empty string for auto-detect
+ const uploadMaxSpeakers = ref(""); // Empty string for auto-detect
+
+ // Tag Selection
+ const availableTags = ref([]);
+ const selectedTagIds = ref([]); // Changed to array for multiple selection
+ const uploadTagSearchFilter = ref(""); // For filtering tags in upload view
+ const selectedTags = computed(() => {
+ return selectedTagIds.value
+ .map((tagId) => availableTags.value.find((tag) => tag.id == tagId))
+ .filter(Boolean); // Filter out undefined tags
+ });
+ // 自动判断是否为 Markdown
+ const isMarkdown = computed(() => {
+ const v = analysisResult.value;
+ return (
+ v.includes("#") ||
+ v.includes("**") ||
+ v.includes("```") ||
+ v.includes("* ") ||
+ v.includes("- ")
+ );
+ });
+
+ // 解析 Markdown → HTML
+ const renderedMarkdown = Vue.computed(() => {
+ return window.marked.parse(analysisResult.value || "", {
+ breaks: true,
+ });
+ });
+ // Computed property for filtered available tags in upload view
+ const filteredAvailableTagsForUpload = computed(() => {
+ const availableForSelection = availableTags.value.filter(
+ (tag) => !selectedTagIds.value.includes(tag.id)
+ );
+ if (!uploadTagSearchFilter.value) return availableForSelection;
+
+ const filter = uploadTagSearchFilter.value.toLowerCase();
+ return availableForSelection.filter((tag) =>
+ tag.name.toLowerCase().includes(filter)
+ );
+ });
+
+ // --- Modal State ---
+ const showEditModal = ref(false);
+ const showDeleteModal = ref(false);
+ const showEditTagsModal = ref(false);
+ const selectedNewTagId = ref("");
+ const tagSearchFilter = ref(""); // For filtering tags in the modal
+ const showReprocessModal = ref(false);
+ const showResetModal = ref(false);
+ const showSpeakerModal = ref(false);
+ const showShareModal = ref(false);
+ const showSharesListModal = ref(false);
+ const showTextEditorModal = ref(false);
+ const showAsrEditorModal = ref(false);
+ const editingRecording = ref(null);
+ const editingTranscriptionContent = ref("");
+ const editingSegments = ref([]);
+ const availableSpeakers = ref([]);
+ const recordingToShare = ref(null);
+ const shareOptions = reactive({
+ share_summary: true,
+ share_notes: true,
+ });
+ const generatedShareLink = ref("");
+ const existingShareDetected = ref(false);
+ const userShares = ref([]);
+ const isLoadingShares = ref(false);
+ const shareToDelete = ref(null);
+ const showShareDeleteModal = ref(false);
+ const recordingToDelete = ref(null);
+ const recordingToReset = ref(null);
+ const reprocessType = ref(null); // 'transcription' or 'summary'
+ const reprocessRecording = ref(null);
+ const isAutoIdentifying = ref(false);
+ const asrReprocessOptions = reactive({
+ language: "",
+ min_speakers: null,
+ max_speakers: null,
+ });
+ const speakerMap = ref({});
+ const regenerateSummaryAfterSpeakerUpdate = ref(true);
+ const speakerSuggestions = ref({});
+ const loadingSuggestions = ref({});
+ const activeSpeakerInput = ref(null);
+
+ // --- Inline Editing State ---
+ const editingParticipants = ref(false);
+ const editingMeetingDate = ref(false);
+ const editingSummary = ref(false);
+ const editingNotes = ref(false);
+ const tempNotesContent = ref("");
+ const tempSummaryContent = ref("");
+ const autoSaveTimer = ref(null);
+ const autoSaveDelay = 2000; // 2 seconds debounce
+
+ // --- Markdown Editor State ---
+ const notesMarkdownEditor = ref(null);
+ const markdownEditorInstance = ref(null);
+ const summaryMarkdownEditor = ref(null);
+ const summaryMarkdownEditorInstance = ref(null);
+ const recordingNotesEditor = ref(null);
+ const recordingMarkdownEditorInstance = ref(null);
+
+ // --- Transcription State ---
+ const transcriptionViewMode = ref("simple"); // 'simple' or 'bubble'
+ const legendExpanded = ref(false);
+ const highlightedSpeaker = ref(null);
+
+ // --- Chat State ---
+ const showChat = ref(false);
+ const isChatMaximized = ref(false);
+ const chatMessages = ref([]);
+ const chatInput = ref("");
+ const isChatLoading = ref(false);
+ const chatMessagesRef = ref(null);
+
+ // --- Audio Player State ---
+ const playerVolume = ref(1.0);
+
+ // --- Column Resizing State ---
+ const leftColumnWidth = ref(60); // 60% for left column (transcript)
+ const rightColumnWidth = ref(40); // 40% for right column (summary/chat)
+ const isResizing = ref(false);
+
+ // --- App Configuration ---
+ const useAsrEndpoint = ref(false);
+ const currentUserName = ref("");
+ const asrResult = ref("");
+ const asrResultOnline = ref("");
+ const asrResultOffline = ref("");
+ const asrResultTextarea = ref("");
+ const asrResultTextareaOnline = ref("");
+ const sumupResultTextarea = ref("");
+ const baseInfo = ref({ name: "领导发言稿", fontSize: 40 });
+ const isModalOpen = ref(false);
+
+ const asrTextarea = ref(null);
+ const analysisResult = ref("");
+ const isDialogVisible = ref(false);
+ const isyjDialogVisible = ref(false);
+ const lastTimer = ref(0);
+ const urlmsg = ref("获取链接中...");
+ const urlinfo = ref("");
+ const zjloding = ref(false);
+ const showSpeaker = ref(true);
+
+ // --- 转录文本编辑状态 ---
+ const isEditingTranscription = ref(false);
+ const editableText = ref("");
+ const fullRealtimeTranscription = computed(() => {
+ return (
+ (asrResult.value || "") +
+ (asrResultOffline.value || "") +
+ (asrResultOnline.value || "")
+ );
+ });
+ let segmentRenderStartIndex = 0;
+ let liveTranscriptionEpoch = 0;
+ let realtimeTimestampBarrierMs = -1;
+ let realtimeEditCarryover = null;
+ let clearRealtimePipelineRef = null;
+ let getLatestRealtimeTimestampRef = null;
+ let buildRealtimeEditCarryoverRef = null;
+ let renderFullTextRef = null;
+ let editModeBufferStartIndex = 0;
+
+ function startNewRealtimeEpoch({
+ barrierTimestampMs = null,
+ clearTimestampBarrier = false,
+ clearEditCarryover = true,
+ } = {}) {
+ liveTranscriptionEpoch += 1;
+
+ if (clearEditCarryover) {
+ realtimeEditCarryover = null;
+ }
+
+ if (clearTimestampBarrier) {
+ realtimeTimestampBarrierMs = -1;
+ } else if (Number.isFinite(barrierTimestampMs)) {
+ realtimeTimestampBarrierMs = Math.max(
+ realtimeTimestampBarrierMs,
+ barrierTimestampMs
+ );
+ }
+
+ if (clearRealtimePipelineRef) {
+ clearRealtimePipelineRef({
+ preserveTimestampBarrier: !clearTimestampBarrier,
+ });
+ }
+ }
+
+ // --- Computed Properties ---
+ const isMobileScreen = computed(() => {
+ return windowWidth.value < 1024;
+ });
+
+ const datePresetOptions = computed(() => {
+ return [
+ { value: "today", label: t("sidebar.today") },
+ { value: "yesterday", label: t("sidebar.yesterday") },
+ { value: "thisweek", label: t("sidebar.thisWeek") },
+ { value: "lastweek", label: t("sidebar.lastWeek") },
+ { value: "thismonth", label: t("sidebar.thisMonth") },
+ { value: "lastmonth", label: t("sidebar.lastMonth") },
+ ];
+ });
+
+ const languageOptions = computed(() => {
+ return [
+ { value: "", label: t("form.autoDetect") },
+ { value: "en", label: t("languages.en") },
+ { value: "es", label: t("languages.es") },
+ { value: "fr", label: t("languages.fr") },
+ { value: "de", label: t("languages.de") },
+ { value: "it", label: t("languages.it") },
+ { value: "pt", label: t("languages.pt") },
+ { value: "nl", label: t("languages.nl") },
+ { value: "ru", label: t("languages.ru") },
+ { value: "zh", label: t("languages.zh") },
+ { value: "ja", label: t("languages.ja") },
+ { value: "ko", label: t("languages.ko") },
+ ];
+ });
+
+ const filteredRecordings = computed(() => {
+ return recordings.value;
+ });
+
+ const highlightedTranscript = computed(() => {
+ if (!selectedRecording.value?.transcription) return "";
+ let html = selectedRecording.value.transcription;
+ // Escape HTML to prevent injection, but keep it minimal
+ html = html.replace(//g, ">");
+
+ // 1. Replace newlines with tags for proper line breaks in HTML
+ html = html.replace(/\n/g, " ");
+
+ // 2. Get speaker colors from the speakerMap if available
+ const speakerColors = {};
+ if (speakerMap.value) {
+ Object.keys(speakerMap.value).forEach((speaker, index) => {
+ speakerColors[speaker] =
+ speakerMap.value[speaker].color ||
+ `speaker-color-${(index % 8) + 1}`;
+ });
+ }
+
+ // 3. Wrap each speaker tag in a span for styling and interaction with colors
+ html = html.replace(/\[([^\]]+)\]/g, (match, speakerId) => {
+ const isHighlighted = speakerId === highlightedSpeaker.value;
+ const colorClass = speakerColors[speakerId] || "";
+ // Replace speaker ID with name if available
+ const displayName = speakerMap.value[speakerId]?.name || speakerId;
+ const displayText = `[${displayName}]`;
+ // Use a more specific and stylish class structure with color
+ return `${displayText} `;
+ });
+
+ return html;
+ });
+
+ const activeRecordingMetadata = computed(() => {
+ if (!selectedRecording.value) return [];
+
+ const recording = selectedRecording.value;
+ const metadata = [];
+
+ if (recording.created_at) {
+ metadata.push({
+ icon: "fas fa-history",
+ text: formatDisplayDate(recording.created_at),
+ });
+ }
+
+ if (recording.file_size) {
+ metadata.push({
+ icon: "fas fa-file-audio",
+ text: formatFileSize(recording.file_size),
+ });
+ }
+
+ if (recording.duration) {
+ metadata.push({
+ icon: "fas fa-clock",
+ text: formatDuration(recording.duration),
+ });
+ }
+
+ if (recording.original_filename) {
+ const maxLength = 30;
+ const truncated =
+ recording.original_filename.length > maxLength
+ ? recording.original_filename.substring(0, maxLength) + "..."
+ : recording.original_filename;
+ metadata.push({
+ icon: "fas fa-file",
+ text: truncated,
+ fullText: recording.original_filename,
+ });
+ }
+
+ // Add tags to metadata
+ if (recording.tags && recording.tags.length > 0) {
+ metadata.push({
+ icon: "fas fa-tags",
+ text: "", // Empty text since we'll render tags specially
+ tags: recording.tags, // Pass the tags array
+ isTagItem: true, // Flag to identify this as a tag item
+ });
+ }
+
+ return metadata;
+ });
+
+ const groupedRecordings = computed(() => {
+ // Sort recordings based on the selected sort criteria
+ const sortedRecordings = [...filteredRecordings.value].sort((a, b) => {
+ const dateA = getDateForSorting(a);
+ const dateB = getDateForSorting(b);
+
+ // Handle null dates (put them at the end)
+ if (!dateA && !dateB) return 0;
+ if (!dateA) return 1;
+ if (!dateB) return -1;
+
+ return dateB - dateA; // Most recent first
+ });
+
+ const groups = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ lastWeek: [],
+ thisMonth: [],
+ lastMonth: [],
+ older: [],
+ };
+
+ sortedRecordings.forEach((recording) => {
+ const date = getDateForSorting(recording);
+ if (!date) {
+ groups.older.push(recording);
+ return;
+ }
+
+ if (isToday(date)) {
+ groups.today.push(recording);
+ } else if (isYesterday(date)) {
+ groups.yesterday.push(recording);
+ } else if (isThisWeek(date)) {
+ groups.thisWeek.push(recording);
+ } else if (isLastWeek(date)) {
+ groups.lastWeek.push(recording);
+ } else if (isThisMonth(date)) {
+ groups.thisMonth.push(recording);
+ } else if (isLastMonth(date)) {
+ groups.lastMonth.push(recording);
+ } else {
+ groups.older.push(recording);
+ }
+ });
+
+ return [
+ { title: t("sidebar.today"), items: groups.today },
+ { title: t("sidebar.yesterday"), items: groups.yesterday },
+ { title: t("sidebar.thisWeek"), items: groups.thisWeek },
+ { title: t("sidebar.lastWeek"), items: groups.lastWeek },
+ { title: t("sidebar.thisMonth"), items: groups.thisMonth },
+ { title: t("sidebar.lastMonth"), items: groups.lastMonth },
+ { title: t("sidebar.older"), items: groups.older },
+ ].filter((g) => g.items.length > 0);
+ });
+
+ const totalInQueue = computed(() => uploadQueue.value.length);
+ const completedInQueue = computed(
+ () =>
+ uploadQueue.value.filter(
+ (item) => item.status === "completed" || item.status === "failed"
+ ).length
+ );
+ const finishedFilesInQueue = computed(() =>
+ uploadQueue.value.filter((item) =>
+ ["completed", "failed"].includes(item.status)
+ )
+ );
+ const showRecordingControls = computed(() => {
+ if (currentView.value !== "recording") {
+ return false;
+ }
+
+ const hasCompletedRecording =
+ !isRecording.value && !isPaused.value && !!audioBlobURL.value;
+
+ if (hasCompletedRecording) {
+ return false;
+ }
+
+ if (isRecording.value || isPaused.value || isStoppingRecording.value) {
+ return true;
+ }
+
+ return (
+ !audioBlobURL.value &&
+ (activeStreams.value.length > 0 ||
+ recordingTime.value > 0 ||
+ !!currentDraftId.value)
+ );
+ });
+
+ const clearCompletedUploads = () => {
+ uploadQueue.value = uploadQueue.value.filter(
+ (item) => !["completed", "failed"].includes(item.status)
+ );
+ };
+
+ const identifiedSpeakers = computed(() => {
+ // Ensure we have a valid recording and transcription
+ if (!selectedRecording.value?.transcription) {
+ return [];
+ }
+
+ const transcription = selectedRecording.value.transcription;
+ let transcriptionData;
+
+ try {
+ transcriptionData = JSON.parse(transcription);
+ } catch (e) {
+ transcriptionData = null;
+ }
+
+ // Updated to handle new simplified JSON format (array of segments)
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ // JSON format - extract speakers in order of appearance
+ const speakersInOrder = [];
+ const seenSpeakers = new Set();
+ transcriptionData.forEach((segment) => {
+ if (
+ segment.speaker &&
+ String(segment.speaker).trim() &&
+ !seenSpeakers.has(segment.speaker)
+ ) {
+ seenSpeakers.add(segment.speaker);
+ speakersInOrder.push(segment.speaker);
+ }
+ });
+ return speakersInOrder; // Keep order of appearance, don't sort
+ } else if (typeof transcription === "string") {
+ // Plain text format - find speakers in order of appearance
+ const speakerRegex = /\[([^\]]+)\]:/g;
+ const speakersInOrder = [];
+ const seenSpeakers = new Set();
+ let match;
+ while ((match = speakerRegex.exec(transcription)) !== null) {
+ const speaker = match[1].trim();
+ if (speaker && !seenSpeakers.has(speaker)) {
+ seenSpeakers.add(speaker);
+ speakersInOrder.push(speaker);
+ }
+ }
+ return speakersInOrder; // Keep order of appearance, don't sort
+ }
+ return [];
+ });
+
+ // identifiedSpeakersInOrder is now just an alias since identifiedSpeakers already preserves order
+ const identifiedSpeakersInOrder = computed(() => {
+ return identifiedSpeakers.value;
+ });
+
+ const hasSpeakerNames = computed(() => {
+ // Check if any speaker has a non-empty name
+ return Object.values(speakerMap.value).some(
+ (speakerData) => speakerData.name && speakerData.name.trim() !== ""
+ );
+ });
+
+ const processedTranscription = computed(() => {
+ if (!selectedRecording.value?.transcription) {
+ return {
+ hasDialogue: false,
+ content: "",
+ speakers: [],
+ simpleSegments: [],
+ bubbleRows: [],
+ };
+ }
+
+ const transcription = selectedRecording.value.transcription;
+ let transcriptionData;
+
+ try {
+ transcriptionData = JSON.parse(transcription);
+ } catch (e) {
+ transcriptionData = null;
+ }
+
+ // Handle new simplified JSON format (array of segments)
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ const wasDiarized = transcriptionData.some(
+ (segment) => segment.speaker
+ );
+
+ if (!wasDiarized) {
+ const segments = transcriptionData.map((segment) => ({
+ sentence: segment.sentence,
+ startTime: segment.start_time,
+ }));
+ return {
+ hasDialogue: false,
+ isJson: true,
+ content: segments.map((s) => s.sentence).join("\n"),
+ simpleSegments: segments,
+ speakers: [],
+ bubbleRows: [],
+ };
+ }
+
+ // Extract unique speakers
+ const speakers = [
+ ...new Set(
+ transcriptionData
+ .map((segment) => segment.speaker)
+ .filter(Boolean)
+ ),
+ ];
+ const speakerColors = {};
+ speakers.forEach((speaker, index) => {
+ speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
+ });
+
+ const simpleSegments = transcriptionData.map((segment) => ({
+ speakerId: segment.speaker,
+ speaker: speakerMap.value[segment.speaker]?.name || segment.speaker,
+ sentence: segment.sentence,
+ startTime: segment.start_time || segment.startTime,
+ endTime: segment.end_time || segment.endTime,
+ color: speakerColors[segment.speaker] || "speaker-color-1",
+ }));
+
+ const processedSimpleSegments = [];
+ let lastSpeakerId = null;
+ simpleSegments.forEach((segment) => {
+ processedSimpleSegments.push({
+ ...segment,
+ showSpeaker: segment.speakerId !== lastSpeakerId,
+ });
+ lastSpeakerId = segment.speakerId;
+ });
+
+ const bubbleRows = [];
+ let lastBubbleSpeakerId = null;
+ simpleSegments.forEach((segment) => {
+ if (
+ bubbleRows.length === 0 ||
+ segment.speakerId !== lastBubbleSpeakerId
+ ) {
+ bubbleRows.push({
+ speaker: segment.speaker,
+ color: segment.color,
+ isMe:
+ segment.speaker &&
+ typeof segment.speaker === "string" &&
+ segment.speaker.toLowerCase().includes("me"),
+ bubbles: [],
+ });
+ lastBubbleSpeakerId = segment.speakerId;
+ }
+ bubbleRows[bubbleRows.length - 1].bubbles.push({
+ sentence: segment.sentence,
+ startTime: segment.startTime || segment.start_time,
+ color: segment.color,
+ });
+ });
+
+ return {
+ hasDialogue: true,
+ isJson: true,
+ segments: simpleSegments,
+ simpleSegments: processedSimpleSegments,
+ bubbleRows: bubbleRows,
+ speakers: speakers.map((speaker) => ({
+ name: speakerMap.value[speaker]?.name || speaker,
+ color: speakerColors[speaker],
+ })),
+ };
+ } else {
+ // Fallback for plain text transcription
+ const speakerRegex = /\[([^\]]+)\]:\s*/g;
+ const hasDialogue = speakerRegex.test(transcription);
+
+ if (!hasDialogue) {
+ return {
+ hasDialogue: false,
+ isJson: false,
+ content: transcription,
+ speakers: [],
+ simpleSegments: [],
+ bubbleRows: [],
+ };
+ }
+
+ speakerRegex.lastIndex = 0;
+ const speakers = new Set();
+ let match;
+ while ((match = speakerRegex.exec(transcription)) !== null) {
+ speakers.add(match[1]);
+ }
+
+ const speakerList = Array.from(speakers);
+ const speakerColors = {};
+ speakerList.forEach((speaker, index) => {
+ speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`;
+ });
+
+ const segments = [];
+ const lines = transcription.split("\n");
+ let currentSpeakerId = null;
+ let currentText = "";
+
+ for (const line of lines) {
+ const speakerMatch = line.match(/^\[([^\]]+)\]:\s*(.*)$/);
+ if (speakerMatch) {
+ if (currentSpeakerId && currentText.trim()) {
+ segments.push({
+ speakerId: currentSpeakerId,
+ speaker:
+ speakerMap.value[currentSpeakerId]?.name ||
+ currentSpeakerId,
+ sentence: currentText.trim(),
+ color: speakerColors[currentSpeakerId] || "speaker-color-1",
+ });
+ }
+ currentSpeakerId = speakerMatch[1];
+ currentText = speakerMatch[2];
+ } else if (currentSpeakerId && line.trim()) {
+ currentText += " " + line.trim();
+ } else if (!currentSpeakerId && line.trim()) {
+ segments.push({
+ speakerId: null,
+ speaker: null,
+ sentence: line.trim(),
+ color: "speaker-color-1",
+ });
+ }
+ }
+
+ if (currentSpeakerId && currentText.trim()) {
+ segments.push({
+ speakerId: currentSpeakerId,
+ speaker:
+ speakerMap.value[currentSpeakerId]?.name || currentSpeakerId,
+ sentence: currentText.trim(),
+ color: speakerColors[currentSpeakerId] || "speaker-color-1",
+ });
+ }
+
+ const simpleSegments = [];
+ let lastSpeakerId = null;
+ segments.forEach((segment) => {
+ simpleSegments.push({
+ ...segment,
+ showSpeaker: segment.speakerId !== lastSpeakerId,
+ sentence: segment.sentence || segment.text,
+ });
+ lastSpeakerId = segment.speakerId;
+ });
+
+ const bubbleRows = [];
+ let currentRow = null;
+ segments.forEach((segment) => {
+ if (!currentRow || currentRow.speakerId !== segment.speakerId) {
+ if (currentRow) bubbleRows.push(currentRow);
+ currentRow = {
+ speakerId: segment.speakerId,
+ speaker: segment.speaker,
+ color: segment.color,
+ bubbles: [],
+ isMe:
+ segment.speaker &&
+ segment.speaker.toLowerCase().includes("me"),
+ };
+ }
+ currentRow.bubbles.push({
+ sentence: segment.sentence,
+ color: segment.color,
+ });
+ });
+ if (currentRow) bubbleRows.push(currentRow);
+
+ return {
+ hasDialogue: true,
+ isJson: false,
+ segments: segments,
+ simpleSegments: simpleSegments,
+ bubbleRows: bubbleRows,
+ speakers: speakerList.map((speaker) => ({
+ name: speakerMap.value[speaker]?.name || speaker,
+ color: speakerColors[speaker] || "speaker-color-1",
+ })),
+ };
+ }
+ });
+
+ // --- Color Scheme Management ---
+ const colorSchemes = {
+ light: [
+ {
+ id: "blue",
+ name: "Ocean Blue",
+ description: "Classic blue theme with professional appeal",
+ class: "",
+ },
+ {
+ id: "emerald",
+ name: "Forest Emerald",
+ description: "Fresh green theme for a natural feel",
+ class: "theme-light-emerald",
+ },
+ {
+ id: "purple",
+ name: "Royal Purple",
+ description: "Elegant purple theme with sophistication",
+ class: "theme-light-purple",
+ },
+ {
+ id: "rose",
+ name: "Sunset Rose",
+ description: "Warm pink theme with gentle energy",
+ class: "theme-light-rose",
+ },
+ {
+ id: "amber",
+ name: "Golden Amber",
+ description: "Warm yellow theme for brightness",
+ class: "theme-light-amber",
+ },
+ {
+ id: "teal",
+ name: "Ocean Teal",
+ description: "Cool teal theme for tranquility",
+ class: "theme-light-teal",
+ },
+ ],
+ dark: [
+ {
+ id: "blue",
+ name: "Midnight Blue",
+ description: "Deep blue theme for focused work",
+ class: "",
+ },
+ {
+ id: "emerald",
+ name: "Dark Forest",
+ description: "Rich green theme for comfortable viewing",
+ class: "theme-dark-emerald",
+ },
+ {
+ id: "purple",
+ name: "Deep Purple",
+ description: "Mysterious purple theme for creativity",
+ class: "theme-dark-purple",
+ },
+ {
+ id: "rose",
+ name: "Dark Rose",
+ description: "Muted pink theme with subtle warmth",
+ class: "theme-dark-rose",
+ },
+ {
+ id: "amber",
+ name: "Dark Amber",
+ description: "Warm brown theme for cozy sessions",
+ class: "theme-dark-amber",
+ },
+ {
+ id: "teal",
+ name: "Deep Teal",
+ description: "Dark teal theme for calm focus",
+ class: "theme-dark-teal",
+ },
+ ],
+ };
+
+ // --- Utility Methods ---
+ const openmodel = async (e) => {
+ urlmsg.value = "获取链接中...";
+ isModalOpen.value = true;
+ let urls = `/tool/speakr/api/external/summarize_text`;
+ const response = await fetch(urls, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: asrResult.value,
+ prompt_id: e ? e.id : "",
+ }),
+ });
+ const data = await response.json();
+
+ if (response) {
+ urlinfo.value = data.string;
+ urlmsg.value = "点击打开投屏页面";
+ console.log(data.value, "urlinfo.value", response, data);
+ }
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to reset status");
+ }
+ };
+ const openzjmodel = async () => {
+ zjloding.value = true;
+ let urls = `/tool/speakr/api/external/submit`;
+ try {
+ const response = await fetch(urls, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: analysisResult.value,
+ }),
+ });
+ const data = await response.json();
+
+ if (response) {
+ window.open(data.string, "_blank");
+ zjloding.value = false;
+ }
+ if (!response.ok) {
+ zjloding.value = false;
+
+ throw new Error(data.error || "Failed to reset status");
+ }
+ } catch (error) {
+ console.error("接口请求失败:", error);
+ zjloding.value = false;
+ }
+ };
+ const closeModal = () => {
+ isModalOpen.value = false;
+ };
+ const setGlobalError = (message, duration = 7000) => {
+ globalError.value = message;
+ if (duration > 0) {
+ setTimeout(() => {
+ if (globalError.value === message) globalError.value = null;
+ }, duration);
+ }
+ };
+
+ const formatFileSize = (bytes) => {
+ if (bytes == null || bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ if (bytes < 0) bytes = 0;
+ const i =
+ bytes === 0
+ ? 0
+ : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
+ const size =
+ i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
+ return size + " " + sizes[i];
+ };
+
+ const formatDisplayDate = (dateString) => {
+ if (!dateString) return "";
+ try {
+ // Try to parse the date string directly first (it might already be formatted)
+ let date = new Date(dateString);
+ // If that fails or results in invalid date, try different approaches
+ if (isNaN(date.getTime())) {
+ // Try appending time if it looks like a date-only string (YYYY-MM-DD format)
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
+ date = new Date(dateString + "T00:00:00");
+ } else {
+ // If it's already a formatted string, just return it
+ return dateString;
+ }
+ }
+
+ // If we still have an invalid date, return the original string
+ if (isNaN(date.getTime())) {
+ return dateString;
+ }
+
+ return date.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ } catch (e) {
+ console.error("Error formatting date:", e);
+ return dateString;
+ }
+ };
+
+ const formatStatus = (status) => {
+ if (!status || status === "COMPLETED") return "";
+ const statusMap = {
+ PENDING: t("status.queued"),
+ PROCESSING: t("status.processing"),
+ TRANSCRIBING: t("status.transcribing"),
+ SUMMARIZING: t("status.summarizing"),
+ FAILED: t("status.failed"),
+ UPLOADING: t("status.uploading"),
+ };
+ return (
+ statusMap[status] ||
+ status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()
+ );
+ };
+
+ const getStatusClass = (status) => {
+ switch (status) {
+ case "PENDING":
+ return "status-pending";
+ case "PROCESSING":
+ return "status-processing";
+ case "SUMMARIZING":
+ return "status-summarizing";
+ case "COMPLETED":
+ return "";
+ case "FAILED":
+ return "status-failed";
+ default:
+ return "status-pending";
+ }
+ };
+
+ const formatTime = (seconds) => {
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${minutes.toString().padStart(2, "0")}:${secs
+ .toString()
+ .padStart(2, "0")}`;
+ };
+
+ const formatDuration = (totalSeconds) => {
+ if (totalSeconds == null || totalSeconds < 0) return "N/A";
+
+ if (totalSeconds < 1) {
+ return `${totalSeconds.toFixed(2)} seconds`;
+ }
+
+ totalSeconds = Math.round(totalSeconds);
+
+ if (totalSeconds < 60) {
+ return `${totalSeconds} sec`;
+ }
+
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ let parts = [];
+ if (hours > 0) {
+ parts.push(`${hours} hr`);
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes} min`);
+ }
+ // Only show seconds if duration is less than an hour
+ if (hours === 0 && seconds > 0) {
+ parts.push(`${seconds} sec`);
+ }
+
+ return parts.join(" ");
+ };
+
+ const createLocalizedRecordingFilename = () => {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ return `录音_${timestamp}.webm`;
+ };
+
+ const getDownloadFilenameFromHeaders = (
+ contentDisposition,
+ fallbackFilename
+ ) => {
+ if (!contentDisposition) {
+ return fallbackFilename;
+ }
+
+ const utf8Match = /filename\*=utf-8''([^;]+)/i.exec(contentDisposition);
+ if (utf8Match && utf8Match[1]) {
+ return decodeURIComponent(utf8Match[1]);
+ }
+
+ const regularMatch = /filename="([^"]+)"/i.exec(contentDisposition);
+ if (regularMatch && regularMatch[1]) {
+ return regularMatch[1];
+ }
+
+ return fallbackFilename;
+ };
+
+ // --- Recording Size Monitoring Functions ---
+ const updateFileSizeEstimate = () => {
+ if (!isRecording.value || !actualBitrate.value) return;
+
+ // Calculate estimated size based on recording time and bitrate
+ const recordingTimeSeconds = recordingTime.value;
+ const estimatedBits = actualBitrate.value * recordingTimeSeconds;
+ const estimatedBytes = estimatedBits / 8;
+ estimatedFileSize.value = estimatedBytes;
+
+ // Check if we're approaching the size limit
+ const sizeMB = estimatedBytes / (1024 * 1024);
+ const warningThresholdMB = maxRecordingMB.value * 0.8; // 80% of max size
+
+ if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) {
+ fileSizeWarningShown.value = true;
+ showToast(
+ `Recording size is ${formatFileSize(
+ estimatedBytes
+ )}. Consider stopping soon to avoid auto-stop at ${
+ maxRecordingMB.value
+ }MB.`,
+ "fa-exclamation-triangle",
+ 5000
+ );
+ }
+
+ // Auto-stop if we exceed the maximum size
+ if (sizeMB > maxRecordingMB.value) {
+ console.log(
+ `Auto-stopping recording: size ${formatFileSize(
+ estimatedBytes
+ )} exceeds limit of ${maxRecordingMB.value}MB`
+ );
+ stopRecording();
+ showToast(
+ `Recording automatically stopped at ${formatFileSize(
+ estimatedBytes
+ )} to prevent excessive file size.`,
+ "fa-stop-circle",
+ 7000
+ );
+ }
+ };
+
+ const startSizeMonitoring = () => {
+ if (sizeCheckInterval.value) {
+ clearInterval(sizeCheckInterval.value);
+ }
+
+ // Reset size monitoring state
+ estimatedFileSize.value = 0;
+ fileSizeWarningShown.value = false;
+
+ // Start monitoring every 5 seconds
+ sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 5000);
+ };
+
+ const stopSizeMonitoring = () => {
+ if (sizeCheckInterval.value) {
+ clearInterval(sizeCheckInterval.value);
+ sizeCheckInterval.value = null;
+ }
+ };
+ const promptVisible = ref(false);
+ const promptList = ref([]);
+ const recommendPromptId = ref(null);
+ const recommendPromptName = ref("");
+ const recommendPromptReason = ref("");
+ const selectedPromptId = ref(
+ localStorage.getItem("selected_prompt_id") || null
+ );
+ // const selectedPromptId = ref(null)
+ let modelTypeForP = "";
+ /** 打开弹框 */
+ const openPromptModal = (e) => {
+ modelTypeForP = e;
+ recommendPromptId.value = null;
+ recommendPromptName.value = "";
+ recommendPromptReason.value = "";
+ promptVisible.value = true;
+ getPromptList(e);
+ getRecommend();
+ };
+
+ /** 关闭弹框 */
+ const closePromptModal = () => {
+ promptVisible.value = false;
+ };
+
+ /** 获取 Prompt 列表 */
+ const getPromptList = async (e) => {
+ try {
+ const res = await fetch(
+ `/tool/speakr/api/external/prompts/list`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ skip: 0, limit: 999 }),
+ }
+ );
+
+ const data = await res.json();
+ promptList.value = data.prompt_list || [];
+ } catch (e) {
+ console.error("提示词加载失败", e);
+ }
+ };
+ /** 根据文本获取Prompt推荐 */
+ const getRecommend = async (e) => {
+ try {
+ const res = await fetch(
+ `/tool/speakr/api/external/recommend_prompts`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ input_str: asrResult.value }),
+ }
+ );
+
+ const data = await res.json();
+ recommendPromptId.value = data.id;
+ recommendPromptName.value = data.name;
+ recommendPromptReason.value = data.reason;
+ } catch (e) {
+ console.error("获取推荐失败", e);
+ }
+ };
+ /** 确认选择 */
+ const confirmPromptSelection = () => {
+ if (selectedPromptId.value) {
+ localStorage.setItem("selected_prompt_id", selectedPromptId.value);
+ }
+
+ const selectedPrompt = promptList.value.find(
+ (i) => i.id == selectedPromptId.value
+ );
+ console.log("选中的提示词:", selectedPrompt);
+ if (modelTypeForP === "zj") {
+ sumupResult(selectedPrompt);
+ } else {
+ openmodel(selectedPrompt);
+ }
+
+ // emit("promptSelected", selectedPrompt)
+
+ promptVisible.value = false;
+ };
+ const sumupResult = async (e) => {
+ isDialogVisible.value = true;
+ // if (!asrResult.value) return;
+
+ try {
+ analysisResult.value = "思考中...";
+
+ const response = await fetch(
+ `/tool/speakr/api/external/summarize_text_stream`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ input_str: asrResult.value,
+ // input_str: '香港火灾情况更新** 香港特区政府召开新闻发布会,通报火灾的搜救与安置最新进展。警方表示,已完成5座大厦的搜索,其余2座仍在进行中。至当天16时,火灾已造成 **156人遇难**,目前有约35人参与搜救工作,其中5人连续工作。2. **对话内容提及天气** 有用户询问“今天天气怎么样”,地点为“安康”,表明对话中涉及对当地天气的关注。3. **对话片段含非正式表达** 有用户发言“我是走开始啊不是”,语句不通顺,可能是口语化表达或输入错误,语义不明确。',
+
+ prompt_id: e ? e.id : "",
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to create share link");
+ }
+
+ // 重置结果并开始流式处理
+ analysisResult.value = "";
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ // 解码并处理流数据
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split("\n");
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.content) {
+ analysisResult.value += data.content;
+ }
+ if (data.finished) {
+ console.log(analysisResult.value, "analysisResult.value");
+
+ showToast("总结完成!", "fa-check-circle");
+ break;
+ }
+ } catch (e) {
+ console.log("解析流数据出错:", e);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ setGlobalError(`Failed to create share link: ${error.message}`);
+ }
+ };
+ // --- Enhanced Date Utility Functions ---
+ const getDateForSorting = (recording) => {
+ const dateStr =
+ sortBy.value === "meeting_date"
+ ? recording.meeting_date
+ : recording.created_at;
+ if (!dateStr) return null;
+ return new Date(dateStr);
+ };
+
+ const isToday = (date) => {
+ const today = new Date();
+ return isSameDay(date, today);
+ };
+
+ const isYesterday = (date) => {
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ return isSameDay(date, yesterday);
+ };
+
+ const isThisWeek = (date) => {
+ const now = new Date();
+ const startOfWeek = new Date(now);
+ const day = now.getDay();
+ const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
+ startOfWeek.setDate(diff);
+ startOfWeek.setHours(0, 0, 0, 0);
+
+ const endOfWeek = new Date(startOfWeek);
+ endOfWeek.setDate(startOfWeek.getDate() + 6);
+ endOfWeek.setHours(23, 59, 59, 999);
+
+ return date >= startOfWeek && date <= endOfWeek;
+ };
+
+ const isLastWeek = (date) => {
+ const now = new Date();
+ const startOfLastWeek = new Date(now);
+ const day = now.getDay();
+ const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
+ startOfLastWeek.setDate(diff);
+ startOfLastWeek.setHours(0, 0, 0, 0);
+
+ const endOfLastWeek = new Date(startOfLastWeek);
+ endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
+ endOfLastWeek.setHours(23, 59, 59, 999);
+
+ return date >= startOfLastWeek && date <= endOfLastWeek;
+ };
+
+ const isThisMonth = (date) => {
+ const now = new Date();
+ return (
+ date.getFullYear() === now.getFullYear() &&
+ date.getMonth() === now.getMonth()
+ );
+ };
+
+ const isLastMonth = (date) => {
+ const now = new Date();
+ const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ return (
+ date.getFullYear() === lastMonth.getFullYear() &&
+ date.getMonth() === lastMonth.getMonth()
+ );
+ };
+
+ const isSameDay = (date1, date2) => {
+ return (
+ date1.getFullYear() === date2.getFullYear() &&
+ date1.getMonth() === date2.getMonth() &&
+ date1.getDate() === date2.getDate()
+ );
+ };
+
+ const reprocessTranscription = (recordingId) => {
+ const recording =
+ recordings.value.find((r) => r.id === recordingId) ||
+ selectedRecording.value;
+ confirmReprocess("transcription", recording);
+ };
+
+ const reprocessSummary = (recordingId) => {
+ const recording =
+ recordings.value.find((r) => r.id === recordingId) ||
+ selectedRecording.value;
+ confirmReprocess("summary", recording);
+ };
+
+ const generateSummary = async () => {
+ if (!selectedRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/generate_summary`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken,
+ },
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to generate summary");
+ }
+
+ // Update the recording status to show it's being processed
+ selectedRecording.value.status = "SUMMARIZING";
+
+ // Also update in recordings list if it exists
+ const recordingInList = recordings.value.find(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (recordingInList) {
+ recordingInList.status = "SUMMARIZING";
+ }
+
+ showToast("Summary generation started", "success");
+ } catch (error) {
+ console.error("Error generating summary:", error);
+ setGlobalError(`Failed to generate summary: ${error.message}`);
+ }
+ };
+
+ const resetRecordingStatus = async (recordingId) => {
+ const recording = recordings.value.find((r) => r.id === recordingId);
+ if (!recording) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/reset_status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to reset status");
+ }
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ if (selectedRecording.value?.id === recording.id) {
+ selectedRecording.value = data.recording;
+ }
+ showToast("Recording status has been reset.", "fa-check-circle");
+ } catch (error) {
+ setGlobalError(`Failed to reset status: ${error.message}`);
+ }
+ };
+
+ const confirmReprocess = (type, recording) => {
+ reprocessType.value = type;
+ reprocessRecording.value = recording;
+ showReprocessModal.value = true;
+ };
+
+ const openTranscriptionEditor = () => {
+ if (processedTranscription.value.isJson) {
+ openAsrEditorModal();
+ } else {
+ openTextEditorModal();
+ }
+ };
+
+ const openTextEditorModal = () => {
+ if (!selectedRecording.value) return;
+ editingTranscriptionContent.value =
+ selectedRecording.value.transcription;
+ showTextEditorModal.value = true;
+ };
+
+ const closeTextEditorModal = () => {
+ showTextEditorModal.value = false;
+ editingTranscriptionContent.value = "";
+ };
+
+ const saveTranscription = async () => {
+ if (!selectedRecording.value) return;
+ await saveTranscriptionContent(editingTranscriptionContent.value);
+ closeTextEditorModal();
+ };
+
+ const openAsrEditorModal = async () => {
+ if (!selectedRecording.value) return;
+ try {
+ const segments = JSON.parse(selectedRecording.value.transcription);
+ editingSegments.value = segments.map((s, i) => ({
+ ...s,
+ id: i,
+ showSuggestions: false,
+ filteredSpeakers: [],
+ }));
+
+ // Populate available speakers
+ const speakersInTranscript = [
+ ...new Set(segments.map((s) => s.speaker)),
+ ];
+ const response = await fetch("/tool/speakr/speakers");
+ const speakersFromDb = await response.json();
+ const speakerNamesFromDb = speakersFromDb.map((s) => s.name);
+ availableSpeakers.value = [
+ ...new Set([...speakersInTranscript, ...speakerNamesFromDb]),
+ ].sort();
+
+ showAsrEditorModal.value = true;
+ } catch (e) {
+ console.error(
+ "Could not parse transcription as JSON for ASR editor:",
+ e
+ );
+ setGlobalError(
+ "This transcription is not in the correct format for the ASR editor."
+ );
+ }
+ };
+
+ const closeAsrEditorModal = () => {
+ showAsrEditorModal.value = false;
+ editingSegments.value = [];
+ availableSpeakers.value = [];
+ };
+
+ const saveAsrTranscription = async () => {
+ const contentToSave = JSON.stringify(
+ editingSegments.value.map(
+ ({ id, showSuggestions, filteredSpeakers, ...rest }) => rest
+ )
+ );
+ await saveTranscriptionContent(contentToSave);
+ closeAsrEditorModal();
+ };
+
+ const adjustTime = (index, field, amount) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index][field] = parseFloat(
+ (editingSegments.value[index][field] + amount).toFixed(3)
+ );
+ }
+ };
+
+ const filterSpeakers = (index) => {
+ const segment = editingSegments.value[index];
+ if (segment) {
+ const query = segment.speaker.toLowerCase();
+ segment.filteredSpeakers = availableSpeakers.value.filter((s) =>
+ s.toLowerCase().includes(query)
+ );
+ }
+ };
+
+ const openSpeakerSuggestions = (index) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].showSuggestions = true;
+ filterSpeakers(index);
+ }
+ };
+
+ const closeSpeakerSuggestions = (index) => {
+ if (editingSegments.value[index]) {
+ window.setTimeout(() => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].showSuggestions = false;
+ }
+ }, 200); // Delay to allow click event to register
+ }
+ };
+
+ const selectSpeaker = (index, speaker) => {
+ if (editingSegments.value[index]) {
+ editingSegments.value[index].speaker = speaker;
+ editingSegments.value[index].showSuggestions = false;
+ }
+ };
+
+ const addSegment = () => {
+ const lastSegment =
+ editingSegments.value[editingSegments.value.length - 1];
+ editingSegments.value.push({
+ id: Date.now(),
+ speaker: lastSegment ? lastSegment.speaker : "SPEAKER_00",
+ start_time: lastSegment ? lastSegment.end_time : 0,
+ end_time: lastSegment ? lastSegment.end_time + 1 : 1,
+ sentence: "",
+ });
+ };
+
+ const removeSegment = (index) => {
+ editingSegments.value.splice(index, 1);
+ };
+
+ const saveTranscriptionContent = async (content) => {
+ if (!selectedRecording.value) return;
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/update_transcription`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ transcription: content }),
+ }
+ );
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update transcription");
+ const index = recordings.value.findIndex(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ selectedRecording.value = data.recording;
+ showToast("Transcription updated successfully!", "fa-check-circle");
+ } catch (error) {
+ console.error("Save Transcription Error:", error);
+ setGlobalError(`Failed to save transcription: ${error.message}`);
+ }
+ };
+
+ const cancelReprocess = () => {
+ showReprocessModal.value = false;
+ reprocessType.value = null;
+ reprocessRecording.value = null;
+ };
+
+ const executeReprocess = async () => {
+ if (!reprocessRecording.value || !reprocessType.value) return;
+
+ const recordingId = reprocessRecording.value.id;
+ const type = reprocessType.value;
+
+ // Close the modal first
+ cancelReprocess();
+
+ if (type === "transcription") {
+ await performReprocessTranscription(
+ recordingId,
+ asrReprocessOptions.language,
+ asrReprocessOptions.min_speakers,
+ asrReprocessOptions.max_speakers
+ );
+ } else if (type === "summary") {
+ await performReprocessSummary(recordingId);
+ }
+ };
+
+ const performReprocessTranscription = async (
+ recordingId,
+ language,
+ minSpeakers,
+ maxSpeakers
+ ) => {
+ if (!recordingId) {
+ setGlobalError("No recording ID provided for reprocessing.");
+ return;
+ }
+
+ try {
+ const payload = {
+ language: language,
+ min_speakers: minSpeakers,
+ max_speakers: maxSpeakers,
+ };
+
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reprocess_transcription`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to start transcription reprocessing"
+ );
+
+ // Ensure the recording status is properly set to PROCESSING
+ if (data.recording && data.recording.status !== "PROCESSING") {
+ console.warn(
+ `Warning: Reprocess transcription returned unexpected status: ${data.recording.status}`
+ );
+ data.recording.status = "PROCESSING";
+ }
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Transcription reprocessing started", "fa-sync-alt");
+
+ // Switch to Summary tab to show progress
+ selectedTab.value = "summary";
+
+ // Show progress modal for reprocessing
+ showProgressModalForReprocessing(recordingId, "transcription");
+
+ // Start polling for status updates
+ startReprocessingPoll(recordingId);
+ } catch (error) {
+ console.error("Reprocess Transcription Error:", error);
+ setGlobalError(
+ `Failed to start transcription reprocessing: ${error.message}`
+ );
+ }
+ };
+
+ const performReprocessSummary = async (recordingId) => {
+ if (!recordingId) {
+ setGlobalError("No recording ID provided for reprocessing.");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reprocess_summary`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to start summary reprocessing"
+ );
+
+ // Ensure the recording status is properly set to SUMMARIZING
+ if (data.recording && data.recording.status !== "SUMMARIZING") {
+ console.warn(
+ `Warning: Reprocess summary returned unexpected status: ${data.recording.status}`
+ );
+ data.recording.status = "SUMMARIZING";
+ }
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Summary reprocessing started", "fa-sync-alt");
+
+ // Switch to Summary tab to show progress
+ selectedTab.value = "summary";
+
+ // Show progress modal for reprocessing
+ showProgressModalForReprocessing(recordingId, "summary");
+
+ // Start polling for status updates
+ startReprocessingPoll(recordingId);
+ } catch (error) {
+ console.error("Reprocess Summary Error:", error);
+ setGlobalError(
+ `Failed to start summary reprocessing: ${error.message}`
+ );
+ }
+ };
+
+ // Show progress modal for reprocessing operations
+ const showProgressModalForReprocessing = (recordingId, type) => {
+ const recording = recordings.value.find((r) => r.id === recordingId);
+ if (!recording) return;
+
+ // Create a mock file item for the progress modal
+ const reprocessItem = {
+ file: {
+ name: recording.title || `Recording ${recordingId}`,
+ size: recording.file_size || 0,
+ },
+ status: "pending",
+ recordingId: recordingId,
+ clientId: `reprocess-${type}-${recordingId}-${Date.now()}`,
+ error: null,
+ isReprocessing: true,
+ reprocessType: type,
+ };
+
+ // Add to upload queue for progress tracking
+ uploadQueue.value.unshift(reprocessItem);
+
+ // Show progress modal
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ // Set as currently processing file
+ currentlyProcessingFile.value = reprocessItem;
+ processingProgress.value = 10;
+ processingMessage.value =
+ type === "transcription"
+ ? "Starting transcription reprocessing..."
+ : "Starting summary reprocessing...";
+ };
+
+ // Polling for reprocessing status updates
+ const reprocessingPolls = ref(new Map()); // Track active polls by recording ID
+
+ const startReprocessingPoll = (recordingId) => {
+ // Clear any existing poll for this recording
+ if (reprocessingPolls.value.has(recordingId)) {
+ clearInterval(reprocessingPolls.value.get(recordingId));
+ }
+
+ // console.log(`Starting reprocessing poll for recording ${recordingId}`);
+
+ const pollInterval = setInterval(async () => {
+ try {
+ const response = await fetch(`/tool/speakr/status/${recordingId}`);
+ if (!response.ok) {
+ console.error(`Status check failed for recording ${recordingId}`);
+ stopReprocessingPoll(recordingId);
+ return;
+ }
+
+ const data = await response.json();
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+ if (index !== -1) {
+ recordings.value[index] = data;
+ }
+
+ // Update selected recording if it's the one being reprocessed
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data;
+ }
+
+ const queueItem = uploadQueue.value.find(
+ (item) => item.recordingId === recordingId
+ );
+
+ // Update the item's status and name for intermediate states
+ if (queueItem) {
+ queueItem.status = data.status; // e.g., 'PROCESSING', 'SUMMARIZING'
+ queueItem.file.name = data.title || data.original_filename;
+ }
+
+ // Update progress modal if this is the currently processing item
+ if (
+ queueItem &&
+ currentlyProcessingFile.value?.clientId === queueItem.clientId
+ ) {
+ updateReprocessingProgress(data.status, queueItem);
+ }
+
+ // Stop polling if processing is complete
+ if (data.status === "COMPLETED" || data.status === "FAILED") {
+ console.log(
+ `Reprocessing ${data.status.toLowerCase()} for recording ${recordingId}`
+ );
+ stopReprocessingPoll(recordingId);
+
+ // Update queue item status to final lowercase state
+ if (queueItem) {
+ queueItem.status =
+ data.status === "COMPLETED" ? "completed" : "failed";
+ if (data.status === "FAILED") {
+ queueItem.error = data.error_message || "Reprocessing failed";
+ }
+ }
+
+ // Clear current processing if this was the active item
+ if (currentlyProcessingFile.value?.recordingId === recordingId) {
+ resetCurrentFileProcessingState();
+ }
+
+ if (data.status === "COMPLETED") {
+ showToast(
+ "Reprocessing completed successfully",
+ "fa-check-circle"
+ );
+ } else {
+ showToast("Reprocessing failed", "fa-exclamation-circle");
+ }
+ }
+ } catch (error) {
+ console.error(
+ `Error polling status for recording ${recordingId}:`,
+ error
+ );
+ stopReprocessingPoll(recordingId);
+ }
+ }, 3000);
+
+ reprocessingPolls.value.set(recordingId, pollInterval);
+ };
+
+ const updateReprocessingProgress = (status, queueItem) => {
+ switch (status) {
+ case "PENDING":
+ processingProgress.value = 20;
+ processingMessage.value = `Waiting to start ${queueItem.reprocessType} reprocessing...`;
+ break;
+ case "PROCESSING":
+ processingProgress.value = Math.round(
+ Math.min(70, processingProgress.value + Math.random() * 10)
+ );
+ processingMessage.value =
+ queueItem.reprocessType === "transcription"
+ ? "Reprocessing transcription..."
+ : "Processing audio...";
+ break;
+ case "SUMMARIZING":
+ processingProgress.value = Math.round(
+ Math.min(90, processingProgress.value + Math.random() * 10)
+ );
+ processingMessage.value =
+ queueItem.reprocessType === "summary"
+ ? "Regenerating summary..."
+ : "Generating title and summary...";
+ break;
+ case "COMPLETED":
+ processingProgress.value = 100;
+ processingMessage.value = "Reprocessing completed!";
+ break;
+ case "FAILED":
+ processingProgress.value = 100;
+ processingMessage.value = "Reprocessing failed.";
+ break;
+ default:
+ processingProgress.value = 15;
+ processingMessage.value = "Starting reprocessing...";
+ }
+ };
+
+ const stopReprocessingPoll = (recordingId) => {
+ if (reprocessingPolls.value.has(recordingId)) {
+ clearInterval(reprocessingPolls.value.get(recordingId));
+ reprocessingPolls.value.delete(recordingId);
+ console.log(`Stopped reprocessing poll for recording ${recordingId}`);
+ }
+ };
+
+ const confirmReset = (recording) => {
+ recordingToReset.value = recording;
+ showResetModal.value = true;
+ };
+
+ const cancelReset = () => {
+ showResetModal.value = false;
+ recordingToReset.value = null;
+ };
+
+ const executeReset = async () => {
+ if (!recordingToReset.value) return;
+ const recordingId = recordingToReset.value.id;
+
+ // Close the modal first
+ cancelReset();
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recordingId}/reset_status`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to reset status");
+
+ // Update the recording in the UI
+ const index = recordings.value.findIndex((r) => r.id === recordingId);
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+
+ // Update selected recording if it's the one being reset
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data.recording;
+ }
+
+ showToast("Recording status has been reset.", "fa-check-circle");
+ } catch (error) {
+ console.error("Reset Status Error:", error);
+ setGlobalError(`Failed to reset status: ${error.message}`);
+ }
+ };
+
+ const searchSpeakers = async (query, speakerId) => {
+ if (!query || query.length < 2) {
+ speakerSuggestions.value[speakerId] = [];
+ return;
+ }
+
+ loadingSuggestions.value[speakerId] = true;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/speakers/search?q=${encodeURIComponent(query)}`
+ );
+ if (!response.ok) throw new Error("Failed to search speakers");
+
+ const speakers = await response.json();
+ speakerSuggestions.value[speakerId] = speakers;
+ } catch (error) {
+ console.error("Error searching speakers:", error);
+ speakerSuggestions.value[speakerId] = [];
+ } finally {
+ loadingSuggestions.value[speakerId] = false;
+ }
+ };
+
+ const selectSpeakerSuggestion = (speakerId, suggestion) => {
+ if (speakerMap.value[speakerId]) {
+ speakerMap.value[speakerId].name = suggestion.name;
+ speakerSuggestions.value[speakerId] = [];
+ }
+ };
+
+ const closeSpeakerSuggestionsOnClick = (event) => {
+ // Check if the click was on an input field or dropdown
+ const clickedInput = event.target.closest('input[type="text"]');
+ const clickedDropdown = event.target.closest(".absolute.z-10");
+
+ // If not clicking on input or dropdown, close all suggestions
+ if (!clickedInput && !clickedDropdown) {
+ Object.keys(speakerSuggestions.value).forEach((speakerId) => {
+ speakerSuggestions.value[speakerId] = [];
+ });
+ }
+ };
+
+ // Create a mapping for display-friendly speaker IDs
+ const speakerDisplayMap = ref({});
+ const modalSpeakers = ref([]);
+ const is_final = ref(false);
+ const openSpeakerModal = () => {
+ // Clear any existing speaker map data first
+ speakerMap.value = {};
+ speakerDisplayMap.value = {};
+
+ // Get the same speaker order used in processedTranscription
+ const transcription = selectedRecording.value?.transcription;
+ let speakers = [];
+
+ if (transcription) {
+ try {
+ const transcriptionData = JSON.parse(transcription);
+ if (transcriptionData && Array.isArray(transcriptionData)) {
+ // Use the exact same logic as processedTranscription to get speakers
+ speakers = [
+ ...new Set(
+ transcriptionData
+ .map((segment) => segment.speaker)
+ .filter(Boolean)
+ ),
+ ];
+ }
+ } catch (e) {
+ // Fall back to identifiedSpeakers if JSON parsing fails
+ speakers = identifiedSpeakers.value;
+ }
+ }
+
+ // Set modalSpeakers for the template to use
+ modalSpeakers.value = speakers;
+
+ // Initialize speaker map with the same order and colors as the transcript
+ speakerMap.value = speakers.reduce((acc, speaker, index) => {
+ acc[speaker] = {
+ name: "",
+ isMe: false,
+ color: `speaker-color-${(index % 8) + 1}`, // Same color assignment as processedTranscription
+ };
+ // Keep the original speaker ID for display
+ speakerDisplayMap.value[speaker] = speaker;
+ return acc;
+ }, {});
+
+ highlightedSpeaker.value = null;
+ speakerSuggestions.value = {};
+ loadingSuggestions.value = {};
+ isAutoIdentifying.value = false;
+ showSpeakerModal.value = true;
+ };
+
+ const closeSpeakerModal = () => {
+ showSpeakerModal.value = false;
+ highlightedSpeaker.value = null;
+ // Clear the speaker map to prevent stale data from persisting
+ speakerMap.value = {};
+ speakerSuggestions.value = {};
+ loadingSuggestions.value = {};
+
+ // Clean up click handler if it exists
+ if (window.speakerModalClickHandler) {
+ const modalContent = document.querySelector(".modal-content");
+ if (modalContent) {
+ modalContent.removeEventListener(
+ "click",
+ window.speakerModalClickHandler
+ );
+ }
+ delete window.speakerModalClickHandler;
+ }
+ };
+
+ const saveSpeakerNames = async () => {
+ if (!selectedRecording.value) return;
+
+ // Create a filtered speaker map that excludes entries with blank names
+ const filteredSpeakerMap = Object.entries(speakerMap.value).reduce(
+ (acc, [speakerId, speakerData]) => {
+ if (speakerData.name && speakerData.name.trim() !== "") {
+ acc[speakerId] = speakerData;
+ }
+ return acc;
+ },
+ {}
+ );
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/update_speakers`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ speaker_map: filteredSpeakerMap, // Send the filtered map
+ regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value,
+ }),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update speakers");
+
+ // On success, close the modal and clear the speakerMap state *before*
+ // updating the recording data. This prevents a race condition where the
+ // view could re-render using the new data but the old, lingering speakerMap.
+ closeSpeakerModal();
+
+ // The backend returns the fully updated recording object.
+ // We can directly update our local state with this fresh data.
+ const index = recordings.value.findIndex(
+ (r) => r.id === selectedRecording.value.id
+ );
+ if (index !== -1) {
+ recordings.value[index] = data.recording;
+ }
+ selectedRecording.value = data.recording;
+
+ showToast("Speaker names updated successfully!", "fa-check-circle");
+
+ // If a summary regeneration was requested, start polling for its status.
+ if (regenerateSummaryAfterSpeakerUpdate.value) {
+ startReprocessingPoll(selectedRecording.value.id);
+ }
+ } catch (error) {
+ console.error("Save Speaker Names Error:", error);
+ setGlobalError(`Failed to save speaker names: ${error.message}`);
+ }
+ };
+
+ // Speaker group navigation state
+ const currentSpeakerGroupIndex = ref(-1);
+ const speakerGroups = ref([]);
+
+ const findSpeakerGroups = (speakerId) => {
+ if (!speakerId) return [];
+
+ const groups = [];
+ const modalTranscript = document.querySelector(
+ "div.speaker-modal-transcript"
+ );
+ const mainTranscript = document.querySelector(
+ ".transcription-simple-view, .transcription-with-speakers, .transcription-content"
+ );
+ const transcriptContainer = modalTranscript || mainTranscript;
+
+ if (!transcriptContainer) return [];
+
+ // For JSON-based transcripts with segments
+ const allSegments =
+ transcriptContainer.querySelectorAll(".speaker-segment");
+ if (allSegments.length > 0) {
+ let currentGroup = null;
+ let lastSpeakerId = null;
+
+ allSegments.forEach((segment) => {
+ const speakerTag = segment.querySelector("[data-speaker-id]");
+ const segmentSpeakerId = speakerTag?.dataset.speakerId;
+
+ if (segmentSpeakerId === speakerId) {
+ // If this is a new group (not consecutive with previous)
+ if (lastSpeakerId !== speakerId) {
+ currentGroup = {
+ startElement: segment,
+ elements: [segment],
+ };
+ groups.push(currentGroup);
+ } else if (currentGroup) {
+ // Add to existing group
+ currentGroup.elements.push(segment);
+ }
+ }
+ lastSpeakerId = segmentSpeakerId;
+ });
+ } else {
+ // For plain text transcripts with speaker tags
+ const allTags =
+ transcriptContainer.querySelectorAll("[data-speaker-id]");
+ let currentGroup = null;
+
+ allTags.forEach((tag) => {
+ if (tag.dataset.speakerId === speakerId) {
+ // Find the parent element that contains this speaker's content
+ const parentSegment =
+ tag.closest(".speaker-segment") || tag.parentElement;
+
+ if (
+ !currentGroup ||
+ !currentGroup.lastElement ||
+ !parentSegment.previousElementSibling ||
+ parentSegment.previousElementSibling !==
+ currentGroup.lastElement
+ ) {
+ // Start a new group
+ currentGroup = {
+ startElement: parentSegment,
+ elements: [parentSegment],
+ lastElement: parentSegment,
+ };
+ groups.push(currentGroup);
+ } else {
+ // Continue the group
+ currentGroup.elements.push(parentSegment);
+ currentGroup.lastElement = parentSegment;
+ }
+ }
+ });
+ }
+
+ return groups;
+ };
+
+ const highlightSpeakerInTranscript = (speakerId) => {
+ highlightedSpeaker.value = speakerId;
+
+ if (speakerId) {
+ // Find all speaker groups for navigation
+ speakerGroups.value = findSpeakerGroups(speakerId);
+ currentSpeakerGroupIndex.value = 0;
+
+ // Scroll to the first group
+ if (speakerGroups.value.length > 0) {
+ nextTick(() => {
+ const firstGroup = speakerGroups.value[0];
+ if (firstGroup && firstGroup.startElement) {
+ firstGroup.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ });
+ }
+ } else {
+ speakerGroups.value = [];
+ currentSpeakerGroupIndex.value = -1;
+ }
+ };
+
+ const navigateToNextSpeakerGroup = () => {
+ if (speakerGroups.value.length === 0) return;
+
+ // Don't reset the speaker groups, just update the index
+ currentSpeakerGroupIndex.value =
+ (currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length;
+ const group = speakerGroups.value[currentSpeakerGroupIndex.value];
+ if (group && group.startElement) {
+ group.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ };
+
+ const navigateToPrevSpeakerGroup = () => {
+ if (speakerGroups.value.length === 0) return;
+
+ // Don't reset the speaker groups, just update the index
+ currentSpeakerGroupIndex.value =
+ currentSpeakerGroupIndex.value <= 0
+ ? speakerGroups.value.length - 1
+ : currentSpeakerGroupIndex.value - 1;
+ const group = speakerGroups.value[currentSpeakerGroupIndex.value];
+ if (group && group.startElement) {
+ group.startElement.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ };
+
+ // Enhanced speaker highlighting with focus/blur events for text inputs
+ const focusSpeaker = (speakerId) => {
+ // Set this as the active speaker input
+ activeSpeakerInput.value = speakerId;
+ // Only highlight if not already highlighted (to preserve navigation state)
+ if (highlightedSpeaker.value !== speakerId) {
+ highlightSpeakerInTranscript(speakerId);
+ }
+ };
+
+ const blurSpeaker = () => {
+ // Clear the active speaker input after a delay to allow clicking on suggestions
+ setTimeout(() => {
+ activeSpeakerInput.value = null;
+ speakerSuggestions.value = {};
+ }, 200);
+ clearSpeakerHighlight();
+ };
+
+ const clearSpeakerHighlight = () => {
+ highlightedSpeaker.value = null;
+ };
+
+ const autoIdentifySpeakers = async () => {
+ if (!selectedRecording.value) {
+ showToast("No recording selected.", "fa-exclamation-circle");
+ return;
+ }
+
+ isAutoIdentifying.value = true;
+ showToast("Starting automatic speaker identification...", "fa-magic");
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/auto_identify_speakers`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ current_speaker_map: speakerMap.value,
+ }),
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ data.error || "Unknown error occurred during auto-identification."
+ );
+ }
+
+ // Check if there's a message (e.g., all speakers already identified)
+ if (data.message) {
+ showToast(data.message, "fa-info-circle");
+ return;
+ }
+
+ // Update speakerMap with the identified names (only for unidentified speakers)
+ let identifiedCount = 0;
+ for (const speakerId in data.speaker_map) {
+ const identifiedName = data.speaker_map[speakerId];
+ if (
+ speakerMap.value[speakerId] &&
+ identifiedName &&
+ identifiedName.trim() !== ""
+ ) {
+ speakerMap.value[speakerId].name = identifiedName;
+ identifiedCount++;
+ }
+ }
+
+ if (identifiedCount > 0) {
+ showToast(
+ `${identifiedCount} speaker(s) identified successfully!`,
+ "fa-check-circle"
+ );
+ } else {
+ showToast(
+ "No speakers could be identified from the context.",
+ "fa-info-circle"
+ );
+ }
+ } catch (error) {
+ console.error("Auto Identify Speakers Error:", error);
+ showToast(`Error: ${error.message}`, "fa-exclamation-circle", 5000);
+ } finally {
+ isAutoIdentifying.value = false;
+ }
+ };
+
+ const toggleInbox = async (recording) => {
+ if (!recording || !recording.id) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/toggle_inbox`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to toggle inbox status");
+
+ // Update the recording in the UI
+ recording.is_inbox = data.is_inbox;
+
+ // Update in the recordings list
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].is_inbox = data.is_inbox;
+ }
+
+ showToast(
+ `Recording ${data.is_inbox ? "moved to inbox" : "marked as read"}`
+ );
+ } catch (error) {
+ console.error("Toggle Inbox Error:", error);
+ setGlobalError(`Failed to toggle inbox status: ${error.message}`);
+ }
+ };
+
+ // Toggle highlighted status
+ const toggleHighlight = async (recording) => {
+ if (!recording || !recording.id) return;
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${recording.id}/toggle_highlight`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(
+ data.error || "Failed to toggle highlighted status"
+ );
+
+ // Update the recording in the UI
+ recording.is_highlighted = data.is_highlighted;
+
+ // Update in the recordings list
+ const index = recordings.value.findIndex(
+ (r) => r.id === recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].is_highlighted = data.is_highlighted;
+ }
+
+ showToast(
+ `Recording ${data.is_highlighted ? "highlighted" : "unhighlighted"}`
+ );
+ } catch (error) {
+ console.error("Toggle Highlight Error:", error);
+ setGlobalError(
+ `Failed to toggle highlighted status: ${error.message}`
+ );
+ }
+ };
+
+ // --- Dark Mode ---
+ const toggleDarkMode = () => {
+ isDarkMode.value = !isDarkMode.value;
+ if (isDarkMode.value) {
+ document.documentElement.classList.add("dark");
+ localStorage.setItem("darkMode", "true");
+ } else {
+ document.documentElement.classList.remove("dark");
+ localStorage.setItem("darkMode", "false");
+ }
+ };
+
+ const initializeDarkMode = () => {
+ const prefersDark = window.matchMedia(
+ "(prefers-color-scheme: dark)"
+ ).matches;
+ const savedMode = localStorage.getItem("darkMode");
+ if (savedMode === "true" || (savedMode === null && prefersDark)) {
+ isDarkMode.value = true;
+ document.documentElement.classList.add("dark");
+ } else {
+ isDarkMode.value = false;
+ document.documentElement.classList.remove("dark");
+ }
+ };
+
+ const applyColorScheme = (schemeId, mode = null) => {
+ const targetMode = mode || (isDarkMode.value ? "dark" : "light");
+ const scheme = colorSchemes[targetMode].find((s) => s.id === schemeId);
+
+ if (!scheme) {
+ console.warn(
+ `Color scheme '${schemeId}' not found for mode '${targetMode}'`
+ );
+ return;
+ }
+
+ const allThemeClasses = [
+ ...colorSchemes.light.map((s) => s.class),
+ ...colorSchemes.dark.map((s) => s.class),
+ ].filter((c) => c !== "");
+
+ document.documentElement.classList.remove(...allThemeClasses);
+
+ if (scheme.class) {
+ document.documentElement.classList.add(scheme.class);
+ }
+
+ currentColorScheme.value = schemeId;
+ localStorage.setItem("colorScheme", schemeId);
+ };
+
+ const initializeColorScheme = () => {
+ const savedScheme = localStorage.getItem("colorScheme") || "blue";
+ currentColorScheme.value = savedScheme;
+ applyColorScheme(savedScheme);
+ };
+
+ const openColorSchemeModal = () => {
+ showColorSchemeModal.value = true;
+ };
+
+ const closeColorSchemeModal = () => {
+ showColorSchemeModal.value = false;
+ };
+
+ const selectColorScheme = (schemeId) => {
+ applyColorScheme(schemeId);
+ showToast(
+ `Applied ${
+ colorSchemes[isDarkMode.value ? "dark" : "light"].find(
+ (s) => s.id === schemeId
+ )?.name
+ } theme`,
+ "fa-palette"
+ );
+ };
+
+ const resetColorScheme = () => {
+ applyColorScheme("blue");
+ showToast("Reset to default Ocean Blue theme", "fa-undo");
+ };
+
+ // Watch for dark mode changes to reapply color scheme
+ watch(isDarkMode, () => {
+ applyColorScheme(currentColorScheme.value);
+ });
+ function confirmStopRecording() {
+ return new Promise((resolve) => {
+ // 防止重复创建
+ if (document.getElementById("recording-confirm-mask")) {
+ return;
+ }
+
+ // 遮罩层
+ const mask = document.createElement("div");
+ mask.id = "recording-confirm-mask";
+ mask.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.45);
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ `;
+
+ // 弹框
+ const dialog = document.createElement("div");
+ dialog.style.cssText = `
+ width: 360px;
+ background: #1f2937;
+ color: #fff;
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: 0 10px 30px rgba(0,0,0,.3);
+ font-family: system-ui;
+ `;
+
+ dialog.innerHTML = `
+
+ 确认停止录音
+
+
+ 当前正在录音,切换页面将会停止录音,是否继续?
+
+
+
+ 继续录音
+
+
+ 停止并切换
+
+
+ `;
+
+ mask.appendChild(dialog);
+ document.body.appendChild(mask);
+
+ const cleanup = () => {
+ document.body.removeChild(mask);
+ };
+
+ dialog.querySelector("#confirm-ok").onclick = () => {
+ cleanup();
+ resolve(true);
+ };
+
+ dialog.querySelector("#confirm-cancel").onclick = () => {
+ cleanup();
+ resolve(false);
+ };
+
+ // 点击遮罩关闭 = 取消
+ mask.onclick = (e) => {
+ if (e.target === mask) {
+ cleanup();
+ resolve(false);
+ }
+ };
+ });
+ }
+
+ // --- Sidebar Toggle ---
+ const toggleSidebar = () => {
+ isSidebarCollapsed.value = !isSidebarCollapsed.value;
+ };
+
+ // --- View Management ---
+ const switchToUploadView = async () => {
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } else {
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ }
+ };
+
+ const selectRecording = async (recording) => {
+ if (currentView.value === "recording" && isRecording.value) {
+ // If we are in the middle of a recording, don't switch views
+ setGlobalError(
+ "请先停止当前录音,再选择其他录音。"
+ );
+ return;
+ }
+ selectedRecording.value = recording;
+
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "detail";
+ } else {
+ currentView.value = "detail";
+ }
+ if (recording && recording.id) {
+ localStorage.setItem("lastSelectedRecordingId", recording.id);
+ } else {
+ localStorage.removeItem("lastSelectedRecordingId");
+ }
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } else {
+ if (currentView.value === "recording" && isRec === true) {
+ const confirmed = await confirmStopRecording();
+ if (!confirmed) return;
+
+ currentView.value = "detail";
+ } else {
+ currentView.value = "detail";
+ }
+ if (recording && recording.id) {
+ localStorage.setItem("lastSelectedRecordingId", recording.id);
+ } else {
+ localStorage.removeItem("lastSelectedRecordingId");
+ }
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ }
+ };
+
+ // --- File Upload ---
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ dragover.value = true;
+ };
+
+ const handleDragLeave = (e) => {
+ if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) {
+ return;
+ }
+ dragover.value = false;
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ dragover.value = false;
+ addFilesToQueue(e.dataTransfer.files);
+ };
+
+ const handleFileSelect = (e) => {
+ addFilesToQueue(e.target.files);
+ e.target.value = null;
+ };
+
+ const addFilesToQueue = (files) => {
+ let filesAdded = 0;
+ for (const file of files) {
+ const fileObject = file.file ? file.file : file;
+ const notes = file.notes || null;
+ const tags = file.tags || selectedTags.value || [];
+ const asrOptions = file.asrOptions || {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ };
+
+ // Check if it's an audio file or video container with audio
+ const isAudioFile =
+ fileObject &&
+ (fileObject.type.startsWith("audio/") ||
+ fileObject.type === "video/mp4" ||
+ fileObject.type === "video/quicktime" ||
+ fileObject.type === "video/x-msvideo" ||
+ fileObject.type === "video/webm" ||
+ fileObject.name.toLowerCase().endsWith(".amr") ||
+ fileObject.name.toLowerCase().endsWith(".3gp") ||
+ fileObject.name.toLowerCase().endsWith(".3gpp") ||
+ fileObject.name.toLowerCase().endsWith(".mp4") ||
+ fileObject.name.toLowerCase().endsWith(".mov") ||
+ fileObject.name.toLowerCase().endsWith(".avi") ||
+ fileObject.name.toLowerCase().endsWith(".mkv") ||
+ fileObject.name.toLowerCase().endsWith(".webm"));
+
+ if (isAudioFile) {
+ // Only check general file size limit (chunking handles OpenAI 25MB limit automatically)
+ if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) {
+ setGlobalError(
+ `File "${fileObject.name}" exceeds the maximum size of ${maxFileSizeMB.value} MB and was skipped.`
+ );
+ continue;
+ }
+
+ const clientId = `client-${Date.now()}-${Math.random()
+ .toString(36)
+ .substring(2, 9)}`;
+
+ // Auto-summarization will always occur for all uploads
+ const willAutoSummarize = true;
+
+ uploadQueue.value.push({
+ file: fileObject,
+ notes: notes,
+ tags: tags,
+ asrOptions: asrOptions,
+ status: "queued",
+ recordingId: null,
+ clientId: clientId,
+ error: null,
+ willAutoSummarize: willAutoSummarize,
+ });
+ filesAdded++;
+ } else if (fileObject) {
+ setGlobalError(
+ `Invalid file type "${fileObject.name}". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.`
+ );
+ }
+ }
+ if (filesAdded > 0) {
+ console.log(`Added ${filesAdded} file(s) to the queue.`);
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+ }
+ };
+
+ const resetCurrentFileProcessingState = () => {
+ if (pollInterval.value) clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ currentlyProcessingFile.value = null;
+ processingProgress.value = 0;
+ processingMessage.value = "";
+ };
+
+ const startProcessingQueue = async () => {
+ console.log("Attempting to start processing queue...");
+ if (isProcessingActive.value) {
+ console.log("Queue processor already active.");
+ return;
+ }
+
+ isProcessingActive.value = true;
+ resetCurrentFileProcessingState();
+
+ const nextFileItem = uploadQueue.value.find(
+ (item) => item.status === "queued"
+ );
+
+ if (nextFileItem) {
+ console.log(
+ `Processing next file: ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId})`
+ );
+ currentlyProcessingFile.value = nextFileItem;
+
+ // Check if this is a "reload" item (existing recording being tracked)
+ if (nextFileItem.clientId.startsWith("reload-")) {
+ // Skip upload, go directly to polling existing recording
+ console.log(
+ `Skipping upload for existing recording: ${nextFileItem.recordingId}`
+ );
+ nextFileItem.status = "processing";
+ startStatusPolling(nextFileItem, nextFileItem.recordingId);
+ return;
+ }
+
+ nextFileItem.status = "uploading";
+ processingMessage.value = "Preparing upload...";
+ processingProgress.value = 5;
+
+ try {
+ const formData = new FormData();
+ formData.append("file", nextFileItem.file);
+ if (nextFileItem.notes) {
+ formData.append("notes", nextFileItem.notes);
+ }
+
+ // Add tags if selected (multiple tags)
+ // Use tags from the queue item if available, otherwise use global selectedTagIds
+ const tagsToUse = nextFileItem.tags || selectedTags.value || [];
+ tagsToUse.forEach((tag, index) => {
+ const tagId = tag.id || tag; // Handle both tag objects and tag IDs
+ formData.append(`tag_ids[${index}]`, tagId);
+ });
+
+ // Add ASR advanced options if ASR endpoint is enabled
+ if (useAsrEndpoint.value) {
+ // Use ASR options from the queue item if available, otherwise use global values
+ const asrOpts = nextFileItem.asrOptions || {};
+ const language = asrOpts.language || uploadLanguage.value;
+ const minSpeakers =
+ asrOpts.min_speakers || uploadMinSpeakers.value;
+ const maxSpeakers =
+ asrOpts.max_speakers || uploadMaxSpeakers.value;
+
+ if (language) {
+ formData.append("language", language);
+ }
+ // Only send speaker limits if they're actually set
+ if (minSpeakers && minSpeakers !== "") {
+ formData.append("min_speakers", minSpeakers.toString());
+ }
+ if (maxSpeakers && maxSpeakers !== "") {
+ formData.append("max_speakers", maxSpeakers.toString());
+ }
+ }
+
+ processingMessage.value = "Uploading file...";
+ processingProgress.value = 10;
+
+ const response = await fetch("/tool/speakr/upload", {
+ method: "POST",
+ body: formData,
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ let errorMsg =
+ data.error || `Upload failed with status ${response.status}`;
+ if (response.status === 413)
+ errorMsg =
+ data.error ||
+ `File too large. Max: ${
+ data.max_size_mb?.toFixed(0) || maxFileSizeMB.value
+ } MB.`;
+ throw new Error(errorMsg);
+ }
+
+ if (response.status === 202 && data.id) {
+ console.log(
+ `File ${nextFileItem.file.name} uploaded. Recording ID: ${data.id}. Starting status poll.`
+ );
+ nextFileItem.status = "pending";
+ nextFileItem.recordingId = data.id;
+ processingMessage.value =
+ "Upload complete. Waiting for processing...";
+ processingProgress.value = 30;
+
+ recordings.value.unshift(data);
+ totalRecordings.value++; // Update total count
+ pollProcessingStatus(nextFileItem);
+ } else {
+ throw new Error(
+ "Unexpected success response from server after upload."
+ );
+ }
+ } catch (error) {
+ console.error(
+ `Upload/Processing Error for ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId}):`,
+ error
+ );
+ nextFileItem.status = "failed";
+ nextFileItem.error = error.message;
+ const failedRecordIndex = recordings.value.findIndex(
+ (r) => r.id === nextFileItem.recordingId
+ );
+ if (failedRecordIndex !== -1) {
+ recordings.value[failedRecordIndex].status = "FAILED";
+ recordings.value[
+ failedRecordIndex
+ ].transcription = `Upload/Processing failed: ${error.message}`;
+ } else {
+ setGlobalError(
+ `Failed to process "${nextFileItem.file.name}": ${error.message}`
+ );
+ }
+
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ } else {
+ console.log("Upload queue is empty or no files are queued.");
+ isProcessingActive.value = false;
+ }
+ };
+
+ const startStatusPolling = (fileItem, recordingId) => {
+ fileItem.recordingId = recordingId;
+ pollProcessingStatus(fileItem);
+ };
+
+ const pollProcessingStatus = (fileItem) => {
+ if (pollInterval.value) clearInterval(pollInterval.value);
+
+ const recordingId = fileItem.recordingId;
+ if (!recordingId) {
+ console.error(
+ "Cannot poll status without recording ID for",
+ fileItem.file.name
+ );
+ fileItem.status = "failed";
+ fileItem.error = "Internal error: Missing recording ID for polling.";
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ nextTick(startProcessingQueue);
+ return;
+ }
+
+ processingMessage.value = "Waiting for transcription...";
+ processingProgress.value = 40;
+
+ pollInterval.value = setInterval(async () => {
+ // Check if we should stop polling
+ const shouldStopPolling =
+ !currentlyProcessingFile.value ||
+ currentlyProcessingFile.value.clientId !== fileItem.clientId ||
+ fileItem.status === "failed" ||
+ (fileItem.status === "completed" &&
+ (!fileItem.willAutoSummarize || fileItem.summaryCompleted));
+
+ if (shouldStopPolling) {
+ console.log(
+ `Polling stopped for ${fileItem.clientId} as it's no longer active or finished.`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ if (
+ currentlyProcessingFile.value &&
+ currentlyProcessingFile.value.clientId === fileItem.clientId
+ ) {
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ return;
+ }
+
+ try {
+ console.log(
+ `Polling status for recording ID: ${recordingId} (${fileItem.file.name})`
+ );
+ const response = await fetch(`/tool/speakr/status/${recordingId}`);
+ if (!response.ok)
+ throw new Error(
+ `Status check failed with status ${response.status}`
+ );
+
+ const data = await response.json();
+ const galleryIndex = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+
+ if (galleryIndex !== -1) {
+ recordings.value[galleryIndex] = data;
+ if (selectedRecording.value?.id === recordingId) {
+ selectedRecording.value = data;
+ }
+ }
+
+ const previousStatus = fileItem.status;
+ fileItem.status = data.status;
+ fileItem.file.name = data.title || data.original_filename;
+
+ if (data.status === "COMPLETED") {
+ console.log(
+ `Processing COMPLETED for ${fileItem.file.name} (ID: ${recordingId})`
+ );
+
+ // If this was previously summarizing, it's now fully complete
+ if (previousStatus === "summarizing") {
+ console.log(`Auto-summary completed for ${fileItem.file.name}`);
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true;
+
+ // This is final completion - clean up immediately and synchronously
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Completed item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ // Use immediate startProcessingQueue instead of nextTick to avoid duplication
+ startProcessingQueue();
+ return; // Exit early to prevent further processing
+ }
+ // If auto-summarization will occur and hasn't started yet, wait for it
+ else if (
+ fileItem.willAutoSummarize &&
+ !fileItem.hasCheckedForAutoSummary
+ ) {
+ processingMessage.value = "Transcription complete!";
+ processingProgress.value = 85;
+ fileItem.status = "awaiting_summary"; // Use intermediate status to keep it in upload queue
+ // Don't mark as summaryCompleted yet, continue polling
+ }
+ // No auto-summarization expected, complete normally
+ else {
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true; // No summary expected, so consider it complete
+
+ // Complete immediately for files without auto-summarization
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Completed item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ startProcessingQueue();
+ return; // Exit early to prevent further processing
+ }
+
+ // For files with auto-summarization, mark that they've been checked and continue polling
+ if (
+ fileItem.willAutoSummarize &&
+ !fileItem.hasCheckedForAutoSummary
+ ) {
+ fileItem.hasCheckedForAutoSummary = true;
+ fileItem.autoSummaryStartTime = Date.now();
+ console.log(
+ `Auto-summary expected for ${fileItem.file.name}, continuing to poll...`
+ );
+ // Don't complete yet, continue polling
+ return;
+ }
+
+ // If we have auto-summarization and we've been waiting, check if we should timeout
+ if (
+ fileItem.willAutoSummarize &&
+ fileItem.hasCheckedForAutoSummary
+ ) {
+ const waitTime = Date.now() - fileItem.autoSummaryStartTime;
+ const maxWaitTime = 60000; // 60 seconds
+
+ if (waitTime > maxWaitTime) {
+ // Timeout - complete the process
+ console.log(
+ `Auto-summary timeout for ${fileItem.file.name}, completing...`
+ );
+ processingMessage.value = "Processing complete!";
+ processingProgress.value = 100;
+ fileItem.status = "completed";
+ fileItem.summaryCompleted = true; // Mark as complete due to timeout
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Keep completed items visible in the modal - don't remove them
+ console.log(
+ `Timed-out item ${fileItem.clientId} will remain visible in queue`
+ );
+
+ startProcessingQueue();
+ } else {
+ // Still waiting for auto-summary, continue polling
+ return;
+ }
+ }
+
+ // Normal completion path (no auto-summary check needed)
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+
+ // Remove this item from uploadQueue immediately to prevent duplication
+ const queueIndex = uploadQueue.value.findIndex(
+ (item) => item.clientId === fileItem.clientId
+ );
+ if (queueIndex !== -1) {
+ uploadQueue.value.splice(queueIndex, 1);
+ console.log(
+ `Removed completed item ${fileItem.clientId} from queue immediately`
+ );
+ }
+
+ startProcessingQueue();
+ } else if (data.status === "FAILED") {
+ console.log(
+ `Processing FAILED for ${fileItem.file.name} (ID: ${recordingId})`
+ );
+ processingMessage.value = "Processing failed.";
+ processingProgress.value = 100;
+ fileItem.status = "failed";
+ fileItem.error =
+ data.transcription ||
+ data.summary ||
+ "Processing failed on server.";
+ setGlobalError(
+ `Processing failed for "${data.title || fileItem.file.name}".`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ } else if (data.status === "PROCESSING") {
+ // Check if this file will actually use chunking based on all conditions:
+ // 1. Chunking must be enabled in config
+ // 2. Must NOT be using ASR endpoint (ASR handles large files natively)
+ // 3. For size-based: File size must exceed the limit (can determine immediately)
+ // 4. For time-based: Can't determine client-side, but backend logs show it gets duration
+
+ const couldUseChunking =
+ chunkingEnabled.value && !useAsrEndpoint.value;
+
+ if (couldUseChunking) {
+ if (chunkingMode.value === "size") {
+ // Size-based chunking: we can determine definitively
+ const chunkThresholdBytes = chunkingLimit.value * 1024 * 1024;
+ const willUseChunking =
+ fileItem.file.size > chunkThresholdBytes;
+
+ if (willUseChunking) {
+ processingMessage.value =
+ "Processing large file (chunking in progress)...";
+ // If auto-summarization will occur, cap at 70%, otherwise 80%
+ const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 3
+ )
+ );
+ } else {
+ processingMessage.value = "转录进行中...";
+ // If auto-summarization will occur, cap at 65%, otherwise 75%
+ const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 5
+ )
+ );
+ }
+ } else {
+ // Duration-based chunking: Backend determines this after getting duration
+ // Show a neutral processing message since we can't know client-side
+ processingMessage.value =
+ "Processing file (chunking determined server-side)...";
+ const maxProgress = fileItem.willAutoSummarize ? 70 : 80;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 3
+ )
+ );
+ }
+ } else {
+ processingMessage.value = "转录进行中...";
+ const maxProgress = fileItem.willAutoSummarize ? 65 : 75;
+ processingProgress.value = Math.round(
+ Math.min(
+ maxProgress,
+ processingProgress.value + Math.random() * 5
+ )
+ );
+ }
+ } else if (data.status === "SUMMARIZING") {
+ console.log(`Auto-summary started for ${fileItem.file.name}`);
+ processingMessage.value = "Generating summary...";
+ processingProgress.value = 90;
+ fileItem.status = "summarizing";
+ } else {
+ processingMessage.value = "Waiting in queue...";
+ processingProgress.value = 45;
+ }
+ } catch (error) {
+ console.error(
+ `Polling Error for ${fileItem.file.name} (ID: ${recordingId}):`,
+ error
+ );
+ fileItem.status = "failed";
+ fileItem.error = `Error checking status: ${error.message}`;
+ setGlobalError(
+ `Error checking status for "${fileItem.file.name}": ${error.message}.`
+ );
+ const galleryIndex = recordings.value.findIndex(
+ (r) => r.id === recordingId
+ );
+ if (galleryIndex !== -1)
+ recordings.value[galleryIndex].status = "FAILED";
+
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ }, 5000);
+ };
+
+ // --- Data Loading ---
+ const loadRecordings = async (
+ page = 1,
+ append = false,
+ searchQuery = ""
+ ) => {
+ globalError.value = null;
+ if (!append) {
+ isLoadingRecordings.value = true;
+ } else {
+ isLoadingMore.value = true;
+ }
+
+ try {
+ const params = new URLSearchParams({
+ page: page.toString(),
+ per_page: perPage.value.toString(),
+ });
+
+ if (searchQuery.trim()) {
+ params.set("q", searchQuery.trim());
+ }
+
+ const response = await fetch(`/tool/speakr/api/recordings?${params}`);
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to load recordings");
+
+ // Update pagination state
+ currentPage.value = data.pagination.page;
+ totalRecordings.value = data.pagination.total;
+ totalPages.value = data.pagination.total_pages;
+ hasNextPage.value = data.pagination.has_next;
+ hasPrevPage.value = data.pagination.has_prev;
+
+ // Update recordings data
+ if (append) {
+ // Append to existing recordings (infinite scroll)
+ recordings.value = [...recordings.value, ...data.recordings];
+ } else {
+ // Replace recordings (fresh load or search)
+ recordings.value = data.recordings;
+
+ // Try to restore last selected recording
+ const lastRecordingId = localStorage.getItem(
+ "lastSelectedRecordingId"
+ );
+ if (lastRecordingId && data.recordings.length > 0) {
+ const recordingToSelect = data.recordings.find(
+ (r) => r.id == lastRecordingId
+ );
+ if (recordingToSelect) {
+ selectRecording(recordingToSelect);
+ }
+ }
+ }
+
+ // Handle incomplete recordings for processing queue
+ const incompleteRecordings = data.recordings.filter((r) =>
+ ["PENDING", "PROCESSING", "SUMMARIZING"].includes(r.status)
+ );
+ if (incompleteRecordings.length > 0 && !isProcessingActive.value) {
+ console.warn(
+ `Found ${incompleteRecordings.length} incomplete recording(s) on load.`
+ );
+ for (const recording of incompleteRecordings) {
+ let queueItem = uploadQueue.value.find(
+ (item) => item.recordingId === recording.id
+ );
+ if (!queueItem) {
+ queueItem = {
+ file: {
+ name: recording.title || `Recording ${recording.id}`,
+ size: recording.file_size,
+ },
+ status: "queued",
+ recordingId: recording.id,
+ clientId: `reload-${recording.id}`,
+ error: null,
+ };
+ uploadQueue.value.unshift(queueItem);
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Load Recordings Error:", error);
+ setGlobalError(`Failed to load recordings: ${error.message}`);
+ if (!append) {
+ recordings.value = [];
+ }
+ } finally {
+ isLoadingRecordings.value = false;
+ isLoadingMore.value = false;
+ }
+ };
+
+ // Load more recordings (infinite scroll)
+ const loadMoreRecordings = async () => {
+ if (!hasNextPage.value || isLoadingMore.value) return;
+ await loadRecordings(currentPage.value + 1, true, searchQuery.value);
+ };
+
+ // Search with debouncing
+ const performSearch = async (query = "") => {
+ currentPage.value = 1;
+ await loadRecordings(1, false, query);
+ };
+
+ // Debounced search function
+ const debouncedSearch = (query) => {
+ if (searchDebounceTimer.value) {
+ clearTimeout(searchDebounceTimer.value);
+ }
+ searchDebounceTimer.value = setTimeout(() => {
+ performSearch(query);
+ }, 300); // 300ms debounce
+ };
+
+ const loadTags = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/tags");
+ if (response.ok) {
+ availableTags.value = await response.json();
+ } else {
+ console.warn("Failed to load tags:", response.status);
+ availableTags.value = [];
+ }
+ } catch (error) {
+ console.warn("Error loading tags:", error);
+ availableTags.value = [];
+ }
+ };
+
+ const addTagToSelection = (tagId) => {
+ if (!selectedTagIds.value.includes(tagId)) {
+ selectedTagIds.value.push(tagId);
+ applyTagDefaults();
+ }
+ };
+
+ const removeTagFromSelection = (tagId) => {
+ const index = selectedTagIds.value.indexOf(tagId);
+ if (index > -1) {
+ selectedTagIds.value.splice(index, 1);
+ applyTagDefaults();
+ }
+ };
+
+ const applyTagDefaults = () => {
+ // Apply defaults from the first selected tag (highest priority)
+ const firstTag = selectedTags.value[0];
+ if (firstTag && useAsrEndpoint.value) {
+ if (firstTag.default_language) {
+ uploadLanguage.value = firstTag.default_language;
+ }
+ if (firstTag.default_min_speakers) {
+ uploadMinSpeakers.value = firstTag.default_min_speakers;
+ }
+ if (firstTag.default_max_speakers) {
+ uploadMaxSpeakers.value = firstTag.default_max_speakers;
+ }
+ }
+ };
+
+ // Legacy function for backward compatibility
+ const onTagSelected = applyTagDefaults;
+
+ // Tag helper functions
+ const getRecordingTags = (recording) => {
+ if (!recording || !recording.tags) return [];
+ return recording.tags || [];
+ };
+
+ const getAvailableTagsForRecording = (recording) => {
+ if (!recording || !availableTags.value) return [];
+ const recordingTagIds = getRecordingTags(recording).map(
+ (tag) => tag.id
+ );
+ return availableTags.value.filter(
+ (tag) => !recordingTagIds.includes(tag.id)
+ );
+ };
+
+ // Computed property for filtered available tags in the modal
+ const filteredAvailableTagsForModal = computed(() => {
+ if (!editingRecording.value) return [];
+ const availableTags = getAvailableTagsForRecording(
+ editingRecording.value
+ );
+ if (!tagSearchFilter.value) return availableTags;
+
+ const filter = tagSearchFilter.value.toLowerCase();
+ return availableTags.filter((tag) =>
+ tag.name.toLowerCase().includes(filter)
+ );
+ });
+ const filterByTag = (tag) => {
+ // Use advanced filter instead of text-based
+ filterTags.value = [tag.id];
+ applyAdvancedFilters();
+ };
+ const clearTagFilter = () => {
+ searchQuery.value = "";
+ clearAllFilters();
+ };
+
+ // Build search query from advanced filters
+ const buildSearchQuery = () => {
+ let query = [];
+
+ // Add text search
+ if (filterTextQuery.value.trim()) {
+ query.push(filterTextQuery.value.trim());
+ }
+
+ // Add tag filters
+ if (filterTags.value.length > 0) {
+ const tagNames = filterTags.value
+ .map((tagId) => {
+ const tag = availableTags.value.find((t) => t.id === tagId);
+ return tag ? `tag:${tag.name.replace(/\s+/g, "_")}` : "";
+ })
+ .filter(Boolean);
+ query.push(...tagNames);
+ }
+
+ // Add date filter
+ if (filterDatePreset.value) {
+ query.push(`date:${filterDatePreset.value}`);
+ } else if (filterDateRange.value.start || filterDateRange.value.end) {
+ // Custom date range - send as separate parameters
+ // Will be handled differently in the backend
+ if (filterDateRange.value.start) {
+ query.push(`date_from:${filterDateRange.value.start}`);
+ }
+ if (filterDateRange.value.end) {
+ query.push(`date_to:${filterDateRange.value.end}`);
+ }
+ }
+
+ return query.join(" ");
+ };
+
+ const applyAdvancedFilters = () => {
+ searchQuery.value = buildSearchQuery();
+ };
+
+ const clearAllFilters = () => {
+ filterTags.value = [];
+ filterDateRange.value = { start: "", end: "" };
+ filterDatePreset.value = "";
+ filterTextQuery.value = "";
+ searchQuery.value = "";
+ };
+
+ const editRecordingTags = (recording) => {
+ editingRecording.value = recording;
+ selectedNewTagId.value = "";
+ showEditTagsModal.value = true;
+ };
+
+ const closeEditTagsModal = () => {
+ showEditTagsModal.value = false;
+ editingRecording.value = null;
+ selectedNewTagId.value = "";
+ tagSearchFilter.value = ""; // Clear the filter when closing
+ };
+
+ const addTagToRecording = async (tagId = null) => {
+ // Use provided tagId or fall back to selectedNewTagId
+ const tagToAddId = tagId || selectedNewTagId.value;
+ if (!tagToAddId || !editingRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/api/recordings/${editingRecording.value.id}/tags`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken,
+ },
+ body: JSON.stringify({ tag_id: tagToAddId }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to add tag");
+ }
+
+ // Update local recording data
+ const tagToAdd = availableTags.value.find(
+ (tag) => tag.id == tagToAddId
+ );
+ if (tagToAdd) {
+ if (!editingRecording.value.tags) {
+ editingRecording.value.tags = [];
+ }
+ editingRecording.value.tags.push(tagToAdd);
+
+ // Also update in recordings list if it's a different object
+ const recordingInList = recordings.value.find(
+ (r) => r.id === editingRecording.value.id
+ );
+ if (recordingInList && recordingInList !== editingRecording.value) {
+ if (!recordingInList.tags) {
+ recordingInList.tags = [];
+ }
+ recordingInList.tags.push(tagToAdd);
+ }
+ }
+
+ selectedNewTagId.value = "";
+ } catch (error) {
+ console.error("Error adding tag to recording:", error);
+ setGlobalError(`Failed to add tag: ${error.message}`);
+ }
+ };
+
+ const removeTagFromRecording = async (tagId) => {
+ if (!editingRecording.value) return;
+
+ try {
+ const csrfToken = document
+ .querySelector('meta[name="csrf-token"]')
+ ?.getAttribute("content");
+
+ const response = await fetch(
+ `/tool/speakr/api/recordings/${editingRecording.value.id}/tags/${tagId}`,
+ {
+ method: "DELETE",
+ headers: {
+ "X-CSRFToken": csrfToken,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to remove tag");
+ }
+
+ // Update local recording data
+ editingRecording.value.tags = editingRecording.value.tags.filter(
+ (tag) => tag.id !== tagId
+ );
+
+ // Also update in recordings list if it's a different object
+ const recordingInList = recordings.value.find(
+ (r) => r.id === editingRecording.value.id
+ );
+ if (
+ recordingInList &&
+ recordingInList !== editingRecording.value &&
+ recordingInList.tags
+ ) {
+ recordingInList.tags = recordingInList.tags.filter(
+ (tag) => tag.id !== tagId
+ );
+ }
+ } catch (error) {
+ console.error("Error removing tag from recording:", error);
+ setGlobalError(`Failed to remove tag: ${error.message}`);
+ }
+ };
+
+ // --- Audio Recording ---
+ const startRecordingWithDisclaimer = async (mode = "microphone") => {
+ // Check if disclaimer needs to be shown
+ if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) {
+ pendingRecordingMode.value = mode;
+ showRecordingDisclaimerModal.value = true;
+ } else {
+ // No disclaimer configured, proceed directly
+ await startRecordingActual(mode);
+ }
+ };
+
+ const acceptRecordingDisclaimer = async () => {
+ showRecordingDisclaimerModal.value = false;
+ if (pendingRecordingMode.value) {
+ await startRecordingActual(pendingRecordingMode.value);
+ pendingRecordingMode.value = null;
+ }
+ };
+
+ const cancelRecordingDisclaimer = () => {
+ showRecordingDisclaimerModal.value = false;
+ pendingRecordingMode.value = null;
+ };
+
+ const startRecording = startRecordingWithDisclaimer; // Maintain backward compatibility
+ let systemStream = null;
+ let micStream = null;
+
+ const startRecordingActual = async (mode = "microphone") => {
+ recordingMode.value = mode;
+
+ try {
+ // Load tags if not already loaded
+ if (availableTags.value.length === 0) {
+ await loadTags();
+ }
+
+ // Reset state
+ audioChunks.value = [];
+ audioBlobURL.value = null;
+ recordingNotes.value = "";
+ activeStreams.value = [];
+ // Clear previous tag selection and ASR options for fresh recording
+ selectedTags.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+
+ // Reset draft-related state for fresh recording (Bug fix: prevents new recordings from being merged into old drafts)
+ currentDraftId.value = null;
+ isPaused.value = false;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ asrResult.value = '';
+ asrResultOnline.value = '';
+ asrResultOffline.value = '';
+ recordingTime.value = 0;
+ isStoppingRecording.value = false;
+ pendingCorrectionCount = 0;
+ isEditingTranscription.value = false;
+ startNewRealtimeEpoch({ clearTimestampBarrier: true });
+
+ let combinedStream = null;
+
+ // Get microphone stream if needed
+ if (mode === "microphone" || mode === "both") {
+ if (!canRecordAudio.value) {
+ throw new Error(
+ "Microphone recording is not supported by your browser or permission was denied."
+ );
+ }
+ micStream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+ activeStreams.value.push(micStream);
+ showToast("Microphone access granted", "fa-microphone");
+ }
+
+ // Get system audio stream if needed
+ if (mode === "system" || mode === "both") {
+ if (!canRecordSystemAudio.value) {
+ throw new Error(
+ "System audio recording is not supported by your browser."
+ );
+ }
+ try {
+ systemStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: true,
+ video: true, // Request video to enable system audio sharing prompt
+ });
+
+ // Check if the user actually granted audio permission
+ if (systemStream.getAudioTracks().length === 0) {
+ // Stop the video track if it exists, since we didn't get audio
+ systemStream.getVideoTracks().forEach((track) => track.stop());
+ throw new Error(
+ 'System audio permission was not granted. Please ensure you check the "Share system audio" box.'
+ );
+ }
+
+ activeStreams.value.push(systemStream);
+ showToast("System audio access granted", "fa-desktop");
+ } catch (err) {
+ if (mode === "system") {
+ throw err; // Re-throw the original error to be caught by the outer handler
+ } else {
+ // For 'both' mode, fall back to microphone only
+ showToast(
+ "System audio denied, using microphone only",
+ "fa-exclamation-triangle"
+ );
+ mode = "microphone";
+ systemStream = null; // Make sure systemStream is null so it's not used later
+ }
+ }
+ }
+
+ // Combine streams if we have both
+ if (micStream && systemStream) {
+ try {
+ audioContext.value = new (window.AudioContext ||
+ window.webkitAudioContext)();
+
+ const micSource =
+ audioContext.value.createMediaStreamSource(micStream);
+ const systemSource =
+ audioContext.value.createMediaStreamSource(systemStream);
+ const destination =
+ audioContext.value.createMediaStreamDestination();
+
+ micSource.connect(destination);
+ systemSource.connect(destination);
+
+ // Create a new MediaStream with only the audio track from the destination
+ const mixedAudioTrack = destination.stream.getAudioTracks()[0];
+ if (!mixedAudioTrack) {
+ throw new Error("Failed to create mixed audio track");
+ }
+
+ combinedStream = new MediaStream([mixedAudioTrack]);
+
+ // Verify the stream has audio tracks
+ if (combinedStream.getAudioTracks().length === 0) {
+ throw new Error("Combined stream has no audio tracks");
+ }
+
+ console.log(
+ "Successfully created combined audio stream with",
+ combinedStream.getAudioTracks().length,
+ "audio tracks"
+ );
+ showToast(
+ "Recording both microphone and system audio",
+ "fa-microphone"
+ );
+ } catch (error) {
+ console.error("Failed to combine audio streams:", error);
+ // Fallback to system audio only
+ if (audioContext.value) {
+ audioContext.value
+ .close()
+ .catch((e) =>
+ console.error("Error closing AudioContext:", e)
+ );
+ audioContext.value = null;
+ }
+ combinedStream = systemStream;
+ showToast(
+ "Failed to combine audio, using system audio only",
+ "fa-exclamation-triangle"
+ );
+ }
+ } else if (systemStream) {
+ // For system audio only, create a new stream with just the audio tracks
+ const audioTracks = systemStream.getAudioTracks();
+ if (audioTracks.length > 0) {
+ combinedStream = new MediaStream(audioTracks);
+ console.log(
+ "Created system audio stream with",
+ audioTracks.length,
+ "audio tracks"
+ );
+ showToast("Recording system audio only", "fa-desktop");
+ } else {
+ throw new Error("System stream has no audio tracks");
+ }
+ } else if (micStream) {
+ combinedStream = micStream;
+ showToast("Recording microphone only", "fa-microphone");
+ } else {
+ throw new Error("No audio streams available for recording.");
+ }
+
+ // Setup MediaRecorder with optimized settings for transcription
+ const getOptimizedRecorderOptions = () => {
+ // Define transcription-optimized options in order of preference
+ const optionsList = [
+ // Best option: Opus codec at 32kbps (excellent compression for speech)
+ {
+ mimeType: "audio/webm;codecs=opus",
+ audioBitsPerSecond: 32000,
+ description: "Optimized (32kbps Opus)",
+ },
+ // Good option: Opus at 64kbps (slightly higher quality)
+ {
+ mimeType: "audio/webm;codecs=opus",
+ audioBitsPerSecond: 64000,
+ description: "Good quality (64kbps Opus)",
+ },
+ // Fallback 1: WebM with reduced bitrate
+ {
+ mimeType: "audio/webm",
+ audioBitsPerSecond: 64000,
+ description: "Standard WebM (64kbps)",
+ },
+ // Fallback 2: MP4 with reduced bitrate
+ {
+ mimeType: "audio/mp4",
+ audioBitsPerSecond: 64000,
+ description: "Standard MP4 (64kbps)",
+ },
+ // Fallback 3: Just the codec without bitrate
+ {
+ mimeType: "audio/webm;codecs=opus",
+ description: "Opus codec (default bitrate)",
+ },
+ // Fallback 4: Just WebM without bitrate
+ {
+ mimeType: "audio/webm",
+ description: "WebM (default bitrate)",
+ },
+ ];
+
+ // Test each option to find the first supported one
+ for (const options of optionsList) {
+ if (MediaRecorder.isTypeSupported(options.mimeType)) {
+ console.log(
+ `Testing audio recording option: ${options.description} - ${options.mimeType}`
+ );
+ return options;
+ }
+ }
+
+ // Final fallback: no options (browser default)
+ console.log("Using browser default audio recording settings");
+ return null;
+ };
+
+ // Try to create MediaRecorder with progressive fallbacks
+ let mediaRecorderCreated = false;
+ let recorderOptions = getOptimizedRecorderOptions();
+ let attemptCount = 0;
+
+ while (!mediaRecorderCreated && attemptCount < 5) {
+ try {
+ attemptCount++;
+
+ if (recorderOptions && attemptCount === 1) {
+ // First attempt: try with full options
+ console.log(
+ `Attempt ${attemptCount}: Trying ${recorderOptions.description}`
+ );
+ mediaRecorder.value = new MediaRecorder(
+ combinedStream,
+ recorderOptions
+ );
+ actualBitrate.value =
+ recorderOptions.audioBitsPerSecond || 64000;
+ showToast(
+ `Recording: ${recorderOptions.description}`,
+ "fa-compress-alt",
+ 3000
+ );
+ } else if (
+ recorderOptions &&
+ attemptCount === 2 &&
+ recorderOptions.audioBitsPerSecond
+ ) {
+ // Second attempt: try same mime type without bitrate constraint
+ console.log(
+ `Attempt ${attemptCount}: Trying ${recorderOptions.mimeType} without bitrate`
+ );
+ mediaRecorder.value = new MediaRecorder(combinedStream, {
+ mimeType: recorderOptions.mimeType,
+ });
+ actualBitrate.value = 128000; // Estimate
+ showToast(
+ `Recording: ${recorderOptions.mimeType} (default bitrate)`,
+ "fa-compress-alt",
+ 3000
+ );
+ } else {
+ // Final attempt: browser default
+ console.log(`Attempt ${attemptCount}: Using browser default`);
+ mediaRecorder.value = new MediaRecorder(combinedStream);
+ actualBitrate.value = 128000; // Estimate browser default
+ showToast(
+ "Recording with browser default settings",
+ "fa-microphone",
+ 3000
+ );
+ }
+
+ mediaRecorderCreated = true;
+ console.log(
+ `MediaRecorder created successfully on attempt ${attemptCount}`
+ );
+ } catch (error) {
+ console.warn(
+ `MediaRecorder creation attempt ${attemptCount} failed:`,
+ error
+ );
+ mediaRecorder.value = null;
+
+ if (attemptCount >= 5) {
+ throw new Error(
+ `Failed to create MediaRecorder after ${attemptCount} attempts. Last error: ${error.message}`
+ );
+ }
+ }
+ }
+
+ if (!mediaRecorder.value) {
+ throw new Error(
+ "Failed to create MediaRecorder with any configuration"
+ );
+ }
+
+ console.log(
+ `Recording with estimated bitrate: ${actualBitrate.value} bps`
+ );
+
+ mediaRecorder.value.ondataavailable = (event) =>
+ audioChunks.value.push(event.data);
+ mediaRecorder.value.onstop = () => {
+ const audioBlob = new Blob(audioChunks.value, {
+ type: "audio/webm",
+ });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+
+ // Stop size monitoring
+ stopSizeMonitoring();
+
+ // Stop all active streams
+ activeStreams.value.forEach((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ });
+ activeStreams.value = [];
+
+ if (audioContext.value) {
+ audioContext.value
+ .close()
+ .catch((e) => console.error("Error closing AudioContext:", e));
+ audioContext.value = null;
+ }
+ cancelAnimationFrame(animationFrameId.value);
+ clearInterval(recordingInterval.value);
+ };
+
+ // --- Visualizer Setup ---
+ if (!audioContext.value) {
+ audioContext.value = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ }
+
+ if (mode === "both" && micStream && systemStream) {
+ // Dual visualizer setup
+ micAnalyser.value = audioContext.value.createAnalyser();
+ micAnalyser.value.fftSize = 256;
+ const micSource =
+ audioContext.value.createMediaStreamSource(micStream);
+ micSource.connect(micAnalyser.value);
+
+ systemAnalyser.value = audioContext.value.createAnalyser();
+ systemAnalyser.value.fftSize = 256;
+ const systemSource =
+ audioContext.value.createMediaStreamSource(systemStream);
+ systemSource.connect(systemAnalyser.value);
+ } else {
+ // Single visualizer setup
+ const visualizerStream = micStream || systemStream;
+ if (visualizerStream) {
+ analyser.value = audioContext.value.createAnalyser();
+ analyser.value.fftSize = 256;
+ const source =
+ audioContext.value.createMediaStreamSource(visualizerStream);
+ source.connect(analyser.value);
+ }
+ }
+
+ // Start recording and timer
+ mediaRecorder.value.start();
+ isRecording.value = true;
+ recordingTime.value = 0;
+ recordingInterval.value = setInterval(
+ () => recordingTime.value++,
+ 1000
+ );
+
+ // Start size monitoring
+ startSizeMonitoring();
+
+ // Switch to recording view
+ currentView.value = "recording";
+
+ // Start visualizer(s)
+ drawVisualizers();
+
+ setGlobalError(null);
+ } catch (err) {
+ console.error("Error starting recording:", err);
+ setGlobalError(`Could not start recording: ${err.message}`);
+ isRecording.value = false;
+
+ // Clean up any streams that were created
+ activeStreams.value.forEach((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ });
+ activeStreams.value = [];
+ }
+ };
+
+ const stopRecording = async () => {
+ if (isStoppingRecording.value) {
+ return;
+ }
+
+ const hasRecordingSession =
+ isRecording.value ||
+ isPaused.value ||
+ audioChunks.value.length > 0 ||
+ !!currentDraftId.value ||
+ activeStreams.value.length > 0;
+
+ if (!hasRecordingSession) {
+ return;
+ }
+
+ isStoppingRecording.value = true;
+ try {
+ isRec = false;
+
+ if (mediaRecorder.value && isRecording.value) {
+ is_final.value = true;
+ mediaRecorder.value.stop();
+ isRecording.value = false;
+ stopSizeMonitoring();
+ cancelAnimationFrame(animationFrameId.value);
+ animationFrameId.value = null;
+ if (recordingInterval.value) {
+ clearInterval(recordingInterval.value);
+ recordingInterval.value = null;
+ }
+ }
+
+ const finishPromises = [];
+ if (webReader && typeof webReader.wsFinish === "function") {
+ finishPromises.push(webReader.wsFinish(3500));
+ } else if (webReader) {
+ webReader.wsStop();
+ }
+ if (webReader2 && typeof webReader2.wsFinish === "function") {
+ finishPromises.push(webReader2.wsFinish(3500));
+ } else if (webReader2) {
+ webReader2.wsStop();
+ }
+ if (finishPromises.length > 0) {
+ await Promise.allSettled(finishPromises);
+ }
+
+ // Wait for MediaRecorder to emit the final blob.
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ if (currentDraftId.value) {
+ try {
+ // 上传最后一个片段 (only if not already paused/uploaded)
+ if (!isPaused.value && audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ const formData = new FormData();
+ formData.append('audio', audioBlob);
+ formData.append('duration', recordingTime.value - pausedDuration.value);
+ formData.append('transcription', asrResult.value + asrResultOffline.value + asrResultOnline.value || '');
+
+ await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
+ method: 'POST',
+ body: formData
+ });
+
+ // 清理 audioChunks 防止重复
+ audioChunks.value = [];
+ }
+
+ // 合并音频片段并获取预览 URL (不 finalize,只是下载合并后的音频用于预览)
+ const audioResp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/download`);
+ if (audioResp.ok) {
+ const audioBlob = await audioResp.blob();
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ }
+
+ // 保持 currentDraftId 状态,以便用户选择上传或放弃
+ isPaused.value = false;
+
+ } catch (err) {
+ console.error('Error saving final segment:', err);
+ setGlobalError(`保存片段失败: ${err.message}`);
+ }
+ } else {
+ // 非草稿模式(直接录音),创建 audioBlobURL
+ if (audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ }
+ }
+
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+ } finally {
+ isStoppingRecording.value = false;
+ }
+ };
+
+ // 暂停录音
+ const pauseRecording = async () => {
+ if (!isRecording.value || isPaused.value) return;
+
+ try {
+ // 首先停止音频数据发送,防止向已关闭的 WebSocket 发送数据
+ isRec = false;
+
+ // 停止 WebSocket 转录
+ if (webReader) webReader.wsStop();
+ if (webReader2) webReader2.wsStop();
+
+ // 停止当前 MediaRecorder
+ if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
+ mediaRecorder.value.stop();
+ }
+
+ // 等待 MediaRecorder 生成最终数据
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // 如果处于编辑模式,先退出编辑模式
+ if (isEditingTranscription.value) {
+ exitEditMode();
+ }
+
+ // 等待所有进行中的LLM矫正完成(最多8秒超时)
+ if (pendingCorrectionCount > 0) {
+ console.log(`等待 ${pendingCorrectionCount} 个LLM矫正完成...`);
+ const waitStart = Date.now();
+ while (pendingCorrectionCount > 0 && Date.now() - waitStart < 8000) {
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+ if (pendingCorrectionCount > 0) {
+ console.warn(`超时:仍有 ${pendingCorrectionCount} 个LLM矫正未完成`);
+ }
+ }
+
+ // 创建草稿(如果是第一次暂停)
+ if (!currentDraftId.value) {
+ const resp = await fetch('/tool/speakr/api/draft/create', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ recording_mode: recordingMode.value,
+ notes: recordingNotes.value
+ })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ currentDraftId.value = data.draft.id;
+ } else {
+ throw new Error(data.error || '创建草稿失败');
+ }
+ }
+
+ // 上传当前片段
+ if (audioChunks.value.length > 0) {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ const formData = new FormData();
+ formData.append('audio', audioBlob);
+ formData.append('duration', recordingTime.value - pausedDuration.value);
+ // 发送完整转录文本(历史 + 离线修正 + 当前在线结果)以防止覆盖历史记录
+ const fullTranscription = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
+ formData.append('transcription', fullTranscription);
+
+ await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, {
+ method: 'POST',
+ body: formData
+ });
+
+ // Clear chunks after upload to prevent duplication
+ audioChunks.value = [];
+ }
+
+ // 更新状态
+ isPaused.value = true;
+ pausedDuration.value = recordingTime.value;
+ pausedTranscription.value = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || '');
+ isRecording.value = false;
+
+ // 停止计时器和动画
+ clearInterval(recordingInterval.value);
+ cancelAnimationFrame(animationFrameId.value);
+ stopSizeMonitoring();
+
+ // 停止所有媒体流
+ activeStreams.value.forEach(stream => {
+ stream.getTracks().forEach(track => track.stop());
+ });
+ activeStreams.value = [];
+ micStream = null;
+ systemStream = null;
+
+ showToast('录音已暂停', 'fa-pause');
+
+ // 刷新草稿列表
+ loadDrafts();
+ } catch (err) {
+ console.error('Error pausing recording:', err);
+ setGlobalError(`暂停录音失败: ${err.message}`);
+ }
+ };
+
+ // 恢复录音
+ const resumeRecording = async () => {
+ if (!isPaused.value || !currentDraftId.value) return;
+
+ try {
+ // 重置音频块
+ audioChunks.value = [];
+
+ // Bug fix: 清空segmentList,防止旧segment与pausedTranscription(历史文本)重复渲染
+ // pausedTranscription已经包含了历史转录,segmentList应该只包含恢复后的新转录
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ pendingCorrectionCount = 0;
+ isEditingTranscription.value = false;
+ startNewRealtimeEpoch({ clearTimestampBarrier: true });
+
+ let combinedStream = null;
+ const mode = recordingMode.value;
+
+ // 重新获取媒体流
+ if (mode === 'microphone' || mode === 'both') {
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ activeStreams.value.push(micStream);
+ }
+
+ if (mode === 'system' || mode === 'both') {
+ try {
+ systemStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: true,
+ video: true
+ });
+ if (systemStream.getAudioTracks().length === 0) {
+ systemStream.getVideoTracks().forEach(track => track.stop());
+ throw new Error('未获取系统音频权限');
+ }
+ activeStreams.value.push(systemStream);
+ } catch (err) {
+ if (mode === 'system') throw err;
+ showToast('系统音频权限被拒绝,仅使用麦克风', 'fa-exclamation-triangle');
+ recordingMode.value = 'microphone';
+ }
+ }
+
+ // 组合音频流
+ if (micStream && systemStream) {
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
+ const micSource = audioContext.value.createMediaStreamSource(micStream);
+ const systemSource = audioContext.value.createMediaStreamSource(systemStream);
+ const destination = audioContext.value.createMediaStreamDestination();
+ micSource.connect(destination);
+ systemSource.connect(destination);
+ combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]);
+ } else if (systemStream) {
+ combinedStream = new MediaStream(systemStream.getAudioTracks());
+ } else if (micStream) {
+ combinedStream = micStream;
+ } else {
+ throw new Error('无可用音频流');
+ }
+
+ // 创建新的 MediaRecorder
+ mediaRecorder.value = new MediaRecorder(combinedStream, { mimeType: 'audio/webm;codecs=opus' });
+ mediaRecorder.value.ondataavailable = (event) => audioChunks.value.push(event.data);
+ mediaRecorder.value.onstop = () => {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' });
+ audioBlobURL.value = URL.createObjectURL(audioBlob);
+ };
+
+ // 重新设置可视化
+ if (!audioContext.value) {
+ audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ if (mode === 'both' && micStream && systemStream) {
+ // Dual visualizer setup
+ micAnalyser.value = audioContext.value.createAnalyser();
+ micAnalyser.value.fftSize = 256;
+ const micSource = audioContext.value.createMediaStreamSource(micStream);
+ micSource.connect(micAnalyser.value);
+
+ systemAnalyser.value = audioContext.value.createAnalyser();
+ systemAnalyser.value.fftSize = 256;
+ const systemSource = audioContext.value.createMediaStreamSource(systemStream);
+ systemSource.connect(systemAnalyser.value);
+ } else {
+ const visualizerStream = micStream || systemStream;
+ if (visualizerStream) {
+ analyser.value = audioContext.value.createAnalyser();
+ analyser.value.fftSize = 256;
+ const source = audioContext.value.createMediaStreamSource(visualizerStream);
+ source.connect(analyser.value);
+ }
+ }
+
+ // 开始录音
+ mediaRecorder.value.start();
+ isPaused.value = false;
+ isRecording.value = true;
+
+ // 恢复计时器(从暂停时间继续)
+ recordingInterval.value = setInterval(() => recordingTime.value++, 1000);
+
+ // 恢复可视化
+ drawVisualizers();
+ startSizeMonitoring();
+
+ // 恢复 WebSocket 转录
+ console.log("Resuming WebSocket connections...");
+ // 设置重置标志,清空实时转录显示
+ resetTranscriptionFlag = true;
+ asrResultOnline.value = '';
+ if (webReader) {
+ const ret = webReader.wsStart();
+ if (ret === 1) {
+ isRec = true;
+ }
+ }
+ // offline/2pass
+ if (webReader2) {
+ try {
+ webReader2.wsStart("offline");
+ isRec = true;
+ } catch(e) {
+ console.warn("Failed to restart offline WS:", e);
+ }
+ }
+
+ showToast('录音已恢复', 'fa-play');
+ } catch (err) {
+ console.error('Error resuming recording:', err);
+ setGlobalError(`恢复录音失败: ${err.message}`);
+ }
+ };
+
+ // 加载草稿列表
+ const loadDrafts = async () => {
+ try {
+ const resp = await fetch('/tool/speakr/api/draft/list');
+ const data = await resp.json();
+ draftRecordings.value = Array.isArray(data) ? data : [];
+ } catch (err) {
+ console.error('Error loading drafts:', err);
+ }
+ };
+
+ // 继续草稿录音
+ const continueDraftRecording = async (draftId) => {
+ try {
+ // 获取草稿详情
+ const resp = await fetch(`/tool/speakr/api/draft/${draftId}`);
+ const draft = await resp.json();
+
+ if (draft.error) {
+ throw new Error(draft.error);
+ }
+
+ // 恢复状态
+ currentDraftId.value = draftId;
+ recordingMode.value = draft.recording_mode || 'microphone';
+ recordingTime.value = Math.round(draft.total_duration || 0);
+ pausedDuration.value = recordingTime.value;
+ recordingNotes.value = draft.notes || '';
+ // Load historical transcription into asrResult
+ console.log('Loading draft transcription:', draft.combined_transcription ? draft.combined_transcription.length : 0, 'chars');
+ asrResult.value = draft.combined_transcription || '';
+ asrResultOnline.value = '';
+ asrResultOffline.value = '';
+ pausedTranscription.value = draft.combined_transcription || '';
+ segmentRenderStartIndex = 0;
+
+ // 设置暂停状态
+ isPaused.value = true;
+ isRecording.value = false;
+
+ // 切换到录音视图
+ currentView.value = 'recording';
+
+ showToast('草稿已加载,点击恢复继续录音', 'fa-folder-open');
+ } catch (err) {
+ console.error('Error continuing draft:', err);
+ setGlobalError(`加载草稿失败: ${err.message}`);
+ }
+ };
+
+ // 删除草稿
+ const deleteDraft = async (draftId) => {
+ if (!confirm('确定要删除这个草稿吗?此操作不可撤销。')) return false;
+
+ try {
+ const resp = await fetch(`/tool/speakr/api/draft/${draftId}`, {
+ method: 'DELETE'
+ });
+ const data = await resp.json();
+
+ if (data.success) {
+ showToast('草稿已删除', 'fa-trash');
+ loadDrafts();
+
+ // 如果删除的是当前草稿,重置状态
+ if (currentDraftId.value === draftId) {
+ currentDraftId.value = null;
+ isPaused.value = false;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentRenderStartIndex = 0;
+ }
+ return true;
+ } else {
+ throw new Error(data.error);
+ }
+ } catch (err) {
+ console.error('Error deleting draft:', err);
+ setGlobalError(`删除草稿失败: ${err.message}`);
+ return false;
+ }
+ };
+
+ // 重命名草稿
+ const startDraftRename = (draft) => {
+ editingDraftId.value = draft.id;
+ editingDraftName.value = draft.name;
+ // 等待 DOM 更新后聚焦输入框
+ nextTick(() => {
+ if (draftNameInput.value && draftNameInput.value[0]) {
+ draftNameInput.value[0].focus();
+ } else if (draftNameInput.value) {
+ draftNameInput.value.focus();
+ }
+ });
+ };
+
+ const saveDraftRename = async (draft) => {
+ if (!editingDraftId.value) return;
+
+ const newName = editingDraftName.value.trim();
+ editingDraftId.value = null; // 退出编辑模式
+
+ if (!newName || newName === draft.name) return;
+
+ try {
+ const resp = await fetch(`/tool/speakr/api/draft/${draft.id}/rename`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newName })
+ });
+ const data = await resp.json();
+
+ if (data.success) {
+ draft.name = newName;
+ showToast('重命名成功', 'fa-check');
+ } else {
+ throw new Error(data.error);
+ }
+ } catch (err) {
+ console.error('Error renaming draft:', err);
+ showToast(`重命名失败: ${err.message}`, 'fa-exclamation-circle');
+ }
+ };
+
+ const uploadRecordedAudio = async () => {
+ // 如果有草稿,先获取合并后的音频文件,然后走统一上传流程
+ if (currentDraftId.value) {
+ try {
+ showToast('Finalizing draft...', 'fa-spinner fa-spin', 0);
+
+ const savedNotes = recordingNotes.value;
+ const savedTags = [...selectedTags.value];
+ const savedAsrOptions = {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ };
+
+ const resp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/finalize`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ notes: savedNotes,
+ tag_ids: savedTags.map((tag) => tag.id || tag),
+ ...savedAsrOptions,
+ }),
+ });
+
+ const data = await resp.json();
+ if (!resp.ok || !data.success || !data.recording) {
+ throw new Error(data.error || 'Finalize draft failed');
+ }
+
+ const recording = data.recording;
+ const existingIndex = recordings.value.findIndex((r) => r.id === recording.id);
+ if (existingIndex === -1) {
+ recordings.value.unshift(recording);
+ totalRecordings.value += 1;
+ } else {
+ recordings.value[existingIndex] = recording;
+ }
+
+ const queueItem = {
+ file: {
+ name: recording.original_filename || recording.title || `draft-${recording.id}.webm`,
+ size: recording.file_size || 0,
+ },
+ notes: savedNotes,
+ tags: savedTags,
+ asrOptions: savedAsrOptions,
+ status: 'queued',
+ recordingId: recording.id,
+ clientId: `reload-draft-${recording.id}`,
+ error: null,
+ willAutoSummarize: true,
+ };
+ uploadQueue.value.push(queueItem);
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ currentDraftId.value = null;
+ pausedDuration.value = 0;
+ pausedTranscription.value = '';
+ segmentRenderStartIndex = 0;
+ loadDrafts();
+
+ if (audioBlobURL.value) {
+ URL.revokeObjectURL(audioBlobURL.value);
+ }
+ audioBlobURL.value = null;
+ audioChunks.value = [];
+ isRecording.value = false;
+ recordingNotes.value = '';
+ selectedTagIds.value = [];
+ asrLanguage.value = '';
+ asrMinSpeakers.value = '';
+ asrMaxSpeakers.value = '';
+ recordingTime.value = 0;
+
+ currentView.value = 'upload';
+ selectedRecording.value = null;
+
+ if (!isProcessingActive.value) {
+ startProcessingQueue();
+ }
+
+ showToast('Draft submitted for processing', 'fa-check-circle');
+ } catch (err) {
+ console.error('Error finalizing draft:', err);
+ setGlobalError(`Upload failed: ${err.message}`);
+ }
+ return;
+ }
+
+ if (!audioBlobURL.value) {
+ setGlobalError("No recorded audio to upload.");
+ return;
+ }
+
+ const previousQueueLength = uploadQueue.value.length;
+
+ const recordedFile = new File(
+ audioChunks.value,
+ createLocalizedRecordingFilename(),
+ { type: "audio/webm" }
+ );
+
+
+ addFilesToQueue([
+ {
+ file: recordedFile,
+ notes: recordingNotes.value,
+ tags: selectedTags.value,
+ asrOptions: {
+ language: asrLanguage.value,
+ min_speakers: asrMinSpeakers.value,
+ max_speakers: asrMaxSpeakers.value,
+ },
+ },
+ ]);
+
+
+ if (uploadQueue.value.length === previousQueueLength) {
+ return;
+ }
+
+ const queueItem = uploadQueue.value[uploadQueue.value.length - 1];
+
+ showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0);
+
+ const waitForServerResponse = () => {
+ return new Promise((resolve, reject) => {
+ const checkInterval = setInterval(() => {
+
+ if (!queueItem || queueItem.status === 'failed') {
+ clearInterval(checkInterval);
+ reject(new Error(queueItem?.error || "Upload failed"));
+ return;
+ }
+
+ const successStates = ['pending', 'processing', 'transcribing', 'summarizing', 'completed'];
+ if (successStates.includes(queueItem.status)) {
+ clearInterval(checkInterval);
+ resolve(true);
+ }
+
+ }, 200);
+ });
+ };
+ try {
+
+ await waitForServerResponse();
+
+ discardRecording();
+
+ if (isRec === true) {
+ if (typeof stopRecording === 'function') stopRecording();
+ }
+
+ currentView.value = "upload";
+ selectedRecording.value = null;
+ if (isMobileScreen.value) {
+ isSidebarCollapsed.value = true;
+ }
+
+ showToast("上传成功,正在处理中...", "fa-check-circle");
+
+ } catch (error) {
+ console.error("Server upload failed:", error);
+ showToast(`上传失败: ${error.message || '请检查网络'}`, "fa-exclamation-triangle", 4000);
+ }
+ };
+const downloadRecordedAudio = () => {
+ if (!audioBlobURL.value) {
+ showToast("No recording available to download", "fa-exclamation-circle");
+ return;
+ }
+
+ const filename = createLocalizedRecordingFilename();
+
+ const a = document.createElement('a');
+ a.style.display = 'none';
+ a.href = audioBlobURL.value;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+
+ // 清理
+ setTimeout(() => {
+ document.body.removeChild(a);
+ }, 100);
+
+ showToast("录音已开始下载", "fa-download");
+ };
+ const discardRecording = async () => {
+ if (currentDraftId.value) {
+ const deleted = await deleteDraft(currentDraftId.value);
+ if (!deleted) return;
+ }
+
+ if (audioBlobURL.value) {
+ URL.revokeObjectURL(audioBlobURL.value);
+ }
+ audioBlobURL.value = null;
+ audioChunks.value = [];
+ isRecording.value = false;
+ recordingTime.value = 0;
+ if (recordingInterval.value) clearInterval(recordingInterval.value);
+ recordingNotes.value = "";
+ // Clear tags and ASR options for fresh start
+ selectedTagIds.value = [];
+ asrLanguage.value = "";
+ asrMinSpeakers.value = "";
+ asrMaxSpeakers.value = "";
+ };
+
+ const drawSingleVisualizer = (analyserNode, canvasElement) => {
+ if (!analyserNode || !canvasElement) return;
+
+ const bufferLength = analyserNode.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+ analyserNode.getByteFrequencyData(dataArray);
+
+ const canvasCtx = canvasElement.getContext("2d");
+ const WIDTH = canvasElement.width;
+ const HEIGHT = canvasElement.height;
+
+ canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
+
+ const barWidth = (WIDTH / bufferLength) * 1.5;
+ let barHeight;
+ let x = 0;
+
+ // Use theme-specific colors that work with all color schemes
+ const buttonColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--bg-button")
+ .trim();
+ const buttonHoverColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--bg-button-hover")
+ .trim();
+
+ // Create gradient that works in both light and dark modes
+ const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
+ if (isDarkMode.value) {
+ // Dark mode: use button colors with transparency
+ gradient.addColorStop(0, buttonColor);
+ gradient.addColorStop(0.6, buttonHoverColor);
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0.2)");
+ } else {
+ // Light mode: use more saturated colors for visibility
+ gradient.addColorStop(0, buttonColor);
+ gradient.addColorStop(0.5, buttonHoverColor);
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0.1)");
+ }
+
+ for (let i = 0; i < bufferLength; i++) {
+ barHeight = dataArray[i] / 2.5;
+ canvasCtx.fillStyle = gradient;
+ canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
+ x += barWidth + 2;
+ }
+ };
+
+ const drawVisualizers = () => {
+ if (!isRecording.value) {
+ if (animationFrameId.value) {
+ cancelAnimationFrame(animationFrameId.value);
+ animationFrameId.value = null;
+ }
+ return;
+ }
+
+ animationFrameId.value = requestAnimationFrame(drawVisualizers);
+
+ if (recordingMode.value === "both") {
+ drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
+ drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
+ } else {
+ drawSingleVisualizer(analyser.value, visualizer.value);
+ }
+ };
+
+ // --- Recording Management ---
+ const saveMetadata = async (recordingDataToSave) => {
+ globalError.value = null;
+ if (!recordingDataToSave || !recordingDataToSave.id) return null;
+ console.log("Saving metadata for:", recordingDataToSave.id);
+ try {
+ const payload = {
+ id: recordingDataToSave.id,
+ title: recordingDataToSave.title,
+ participants: recordingDataToSave.participants,
+ notes: recordingDataToSave.notes,
+ summary: recordingDataToSave.summary,
+ meeting_date: recordingDataToSave.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to save metadata");
+
+ console.log("Save successful:", data.recording.id);
+ const index = recordings.value.findIndex(
+ (r) => r.id === data.recording.id
+ );
+ if (index !== -1) {
+ recordings.value[index].title = payload.title;
+ recordings.value[index].participants = payload.participants;
+ recordings.value[index].notes = payload.notes;
+ recordings.value[index].notes_html = data.recording.notes_html;
+ recordings.value[index].summary = payload.summary;
+ recordings.value[index].summary_html = data.recording.summary_html;
+ recordings.value[index].meeting_date = payload.meeting_date;
+ }
+ if (selectedRecording.value?.id === data.recording.id) {
+ selectedRecording.value.title = payload.title;
+ selectedRecording.value.participants = payload.participants;
+ selectedRecording.value.notes = payload.notes;
+ selectedRecording.value.notes_html = data.recording.notes_html;
+ selectedRecording.value.summary = payload.summary;
+ selectedRecording.value.summary_html = data.recording.summary_html;
+ selectedRecording.value.meeting_date = payload.meeting_date;
+ }
+ return data.recording;
+ } catch (error) {
+ console.error("Save Metadata Error:", error);
+ setGlobalError(`Save failed: ${error.message}`);
+ return null;
+ }
+ };
+
+ const editRecording = (recording) => {
+ editingRecording.value = JSON.parse(JSON.stringify(recording));
+ showEditModal.value = true;
+ };
+
+ const cancelEdit = () => {
+ showEditModal.value = false;
+ editingRecording.value = null;
+ };
+
+ const saveEdit = async () => {
+ const success = await saveMetadata(editingRecording.value);
+ if (success) {
+ cancelEdit();
+ }
+ };
+
+ const confirmDelete = (recording) => {
+ recordingToDelete.value = recording;
+ showDeleteModal.value = true;
+ };
+
+ const cancelDelete = () => {
+ showDeleteModal.value = false;
+ recordingToDelete.value = null;
+ };
+
+ const deleteRecording = async () => {
+ globalError.value = null;
+ if (!recordingToDelete.value) return;
+ const idToDelete = recordingToDelete.value.id;
+ const titleToDelete = recordingToDelete.value.title;
+ try {
+ const response = await fetch(`/tool/speakr/recording/${idToDelete}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to delete recording");
+
+ recordings.value = recordings.value.filter(
+ (r) => r.id !== idToDelete
+ );
+ totalRecordings.value--; // Update total count
+
+ const queueIndex = uploadQueue.value.findIndex(
+ (item) => item.recordingId === idToDelete
+ );
+ if (queueIndex !== -1) {
+ const deletedItem = uploadQueue.value.splice(queueIndex, 1)[0];
+ console.log(`Removed item ${deletedItem.clientId} from queue.`);
+ if (
+ currentlyProcessingFile.value?.clientId === deletedItem.clientId
+ ) {
+ console.log(
+ `Deleting currently processing file: ${titleToDelete}. Stopping poll and moving to next.`
+ );
+ clearInterval(pollInterval.value);
+ pollInterval.value = null;
+ resetCurrentFileProcessingState();
+ isProcessingActive.value = false;
+ await nextTick();
+ startProcessingQueue();
+ }
+ }
+
+ if (selectedRecording.value?.id === idToDelete)
+ selectedRecording.value = null;
+ cancelDelete();
+ console.log(
+ `Successfully deleted recording ${idToDelete} (${titleToDelete})`
+ );
+ } catch (error) {
+ console.error("Delete Error:", error);
+ setGlobalError(
+ `Failed to delete recording "${titleToDelete}": ${error.message}`
+ );
+ cancelDelete();
+ }
+ };
+
+ // --- Inline Editing ---
+ const toggleEditParticipants = () => {
+ editingParticipants.value = !editingParticipants.value;
+ if (!editingParticipants.value) {
+ saveInlineEdit("participants");
+ }
+ };
+
+ const toggleEditMeetingDate = () => {
+ editingMeetingDate.value = !editingMeetingDate.value;
+ if (!editingMeetingDate.value) {
+ saveInlineEdit("meeting_date");
+ }
+ };
+
+ const toggleEditSummary = () => {
+ editingSummary.value = !editingSummary.value;
+ if (editingSummary.value) {
+ // Store current content in temp variable
+ tempSummaryContent.value = selectedRecording.value.summary || "";
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ };
+
+ const cancelEditSummary = () => {
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ editingSummary.value = false;
+ // Restore original content
+ if (selectedRecording.value) {
+ selectedRecording.value.summary = tempSummaryContent.value;
+ }
+ };
+
+ const saveEditSummary = async () => {
+ if (summaryMarkdownEditorInstance.value) {
+ selectedRecording.value.summary =
+ summaryMarkdownEditorInstance.value.value();
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ editingSummary.value = false;
+ await saveInlineEdit("summary");
+ };
+
+ const toggleEditNotes = () => {
+ editingNotes.value = !editingNotes.value;
+ if (editingNotes.value) {
+ // Store current content in temp variable
+ tempNotesContent.value = selectedRecording.value.notes || "";
+ // Initialize markdown editor when entering edit mode
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ };
+
+ const cancelEditNotes = () => {
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ editingNotes.value = false;
+ // Restore original content
+ if (selectedRecording.value) {
+ selectedRecording.value.notes = tempNotesContent.value;
+ }
+ };
+
+ const saveEditNotes = async () => {
+ if (markdownEditorInstance.value) {
+ // Get the markdown content from the editor
+ selectedRecording.value.notes = markdownEditorInstance.value.value();
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ editingNotes.value = false;
+ await saveInlineEdit("notes");
+ };
+
+ const clickToEditNotes = () => {
+ // Allow clicking on empty notes area to start editing
+ if (
+ !editingNotes.value &&
+ (!selectedRecording.value?.notes ||
+ selectedRecording.value.notes.trim() === "")
+ ) {
+ toggleEditNotes();
+ }
+ };
+
+ const clickToEditSummary = () => {
+ // Allow clicking on empty summary area to start editing
+ if (
+ !editingSummary.value &&
+ (!selectedRecording.value?.summary ||
+ selectedRecording.value.summary.trim() === "")
+ ) {
+ toggleEditSummary();
+ }
+ };
+
+ const autoSaveNotes = async () => {
+ if (markdownEditorInstance.value && editingNotes.value) {
+ // Just save the content to the model, don't exit edit mode
+ selectedRecording.value.notes = markdownEditorInstance.value.value();
+ // Silently save to backend without changing UI state
+ try {
+ const payload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (response.ok && data.recording) {
+ // Update the HTML rendered versions if they exist
+ if (data.recording.notes_html) {
+ selectedRecording.value.notes_html = data.recording.notes_html;
+ }
+ } else {
+ console.error("Failed to auto-save notes");
+ }
+ } catch (error) {
+ console.error("Error auto-saving notes:", error);
+ }
+ }
+ };
+
+ const autoSaveSummary = async () => {
+ if (summaryMarkdownEditorInstance.value && editingSummary.value) {
+ // Just save the content to the model, don't exit edit mode
+ selectedRecording.value.summary =
+ summaryMarkdownEditorInstance.value.value();
+ // Silently save to backend without changing UI state
+ try {
+ const payload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+ const response = await fetch("/tool/speakr/save", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (response.ok && data.recording) {
+ // Update the HTML rendered versions if they exist
+ if (data.recording.summary_html) {
+ selectedRecording.value.summary_html =
+ data.recording.summary_html;
+ }
+ } else {
+ console.error("Failed to auto-save summary");
+ }
+ } catch (error) {
+ console.error("Error auto-saving summary:", error);
+ }
+ }
+ };
+
+ const initializeMarkdownEditor = () => {
+ if (!notesMarkdownEditor.value) return;
+
+ try {
+ markdownEditorInstance.value = new EasyMDE({
+ element: notesMarkdownEditor.value,
+ spellChecker: false,
+ autofocus: true,
+ placeholder: "以Markdown格式输入笔记...",
+ initialValue: selectedRecording.value?.notes || "",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "image",
+ "|",
+ "preview",
+ "side-by-side",
+ "fullscreen",
+ "|",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ });
+
+ // Add auto-save functionality
+ markdownEditorInstance.value.codemirror.on("change", () => {
+ if (autoSaveTimer.value) {
+ clearTimeout(autoSaveTimer.value);
+ }
+ autoSaveTimer.value = setTimeout(() => {
+ autoSaveNotes();
+ }, autoSaveDelay);
+ });
+ } catch (error) {
+ console.error("Failed to initialize markdown editor:", error);
+ // Fallback to regular textarea editing
+ editingNotes.value = true;
+ }
+ };
+ const getBaseInfo = async () => {
+ try {
+ const response = await fetch(`getUserConfig`, {
+ method: "GET",
+ });
+ const data = await response.json();
+ if (data.LEADER_SPEECH_BUTTON) {
+ baseInfo.value = {
+ name: data.LEADER_SPEECH_BUTTON,
+ fontSize: data.LEADER_SPEECH_TEXT_SIZE,
+ };
+ }
+ configwords = data.END_PHRASE_PATTERNS || "";
+ } catch (error) {
+ console.error("获取基础配置出错:", error);
+ configwords = "";
+ }
+ };
+ const initializeRecordingMarkdownEditor = () => {
+ if (!recordingNotesEditor.value) {
+ console.log("Recording notes editor ref not found");
+ return;
+ }
+
+ // Check if EasyMDE is available
+ if (typeof EasyMDE === "undefined") {
+ console.error("EasyMDE is not loaded");
+ return;
+ }
+
+ // Clean up existing instance if any
+ if (recordingMarkdownEditorInstance.value) {
+ recordingMarkdownEditorInstance.value.toTextArea();
+ recordingMarkdownEditorInstance.value = null;
+ }
+
+ try {
+ console.log("Initializing recording markdown editor");
+ recordingMarkdownEditorInstance.value = new EasyMDE({
+ element: recordingNotesEditor.value,
+ spellChecker: false,
+ autofocus: false,
+ placeholder: "Type your notes in Markdown format...",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "|",
+ "preview",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ initialValue: recordingNotes.value || "",
+ maxHeight: "300px", // Add height constraint to prevent unlimited growth
+ minHeight: "150px", // Minimum height for usability
+ });
+
+ // Sync changes back to the reactive variable
+ recordingMarkdownEditorInstance.value.codemirror.on("change", () => {
+ recordingNotes.value =
+ recordingMarkdownEditorInstance.value.value();
+ });
+
+ console.log("Recording markdown editor initialized successfully");
+ } catch (error) {
+ console.error(
+ "Failed to initialize recording markdown editor:",
+ error
+ );
+ // Keep as regular textarea if EasyMDE fails
+ }
+ };
+ // 识别启动、停止、清空操作
+
+ const toggleTranscriptionViewMode = () => {
+ transcriptionViewMode.value =
+ transcriptionViewMode.value === "simple" ? "bubble" : "simple";
+ localStorage.setItem(
+ "transcriptionViewMode",
+ transcriptionViewMode.value
+ );
+ };
+
+ const toggleChatMaximize = () => {
+ if (isChatMaximized.value) {
+ // If maximized, restore to normal state
+ isChatMaximized.value = false;
+ } else {
+ // If not maximized, maximize and ensure chat is open
+ isChatMaximized.value = true;
+ if (!showChat.value) {
+ showChat.value = true;
+ }
+ }
+ };
+
+ const saveInlineEdit = async (field) => {
+ if (!selectedRecording.value) return;
+
+ const fullPayload = {
+ id: selectedRecording.value.id,
+ title: selectedRecording.value.title,
+ participants: selectedRecording.value.participants,
+ notes: selectedRecording.value.notes,
+ summary: selectedRecording.value.summary,
+ meeting_date: selectedRecording.value.meeting_date,
+ };
+
+ try {
+ const updatedRecording = await saveMetadata(fullPayload);
+ if (updatedRecording) {
+ if (field === "notes") {
+ selectedRecording.value.notes_html = updatedRecording.notes_html;
+ } else if (field === "summary") {
+ selectedRecording.value.summary_html =
+ updatedRecording.summary_html;
+ }
+
+ switch (field) {
+ case "participants":
+ editingParticipants.value = false;
+ break;
+ case "meeting_date":
+ editingMeetingDate.value = false;
+ break;
+ case "summary":
+ editingSummary.value = false;
+ break;
+ case "notes":
+ editingNotes.value = false;
+ break;
+ }
+ showToast(
+ `${
+ field.charAt(0).toUpperCase() + field.slice(1).replace("_", " ")
+ } updated successfully`
+ );
+ }
+ } catch (error) {
+ console.error(`Save ${field} Error:`, error);
+ setGlobalError(`Failed to save ${field}: ${error.message}`);
+ }
+ };
+
+ // --- Chat Functionality ---
+ // Helper function to check if chat is scrolled to bottom (within bottom 5%)
+ const isChatScrolledToBottom = () => {
+ if (!chatMessagesRef.value) return true;
+ const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
+ const scrollableHeight = scrollHeight - clientHeight;
+ if (scrollableHeight <= 0) return true; // No scrolling possible
+ const scrollPercentage = scrollTop / scrollableHeight;
+ return scrollPercentage >= 0.95; // Within bottom 5%
+ };
+
+ // Helper function to scroll chat to bottom with smooth behavior
+ const scrollChatToBottom = () => {
+ if (chatMessagesRef.value) {
+ requestAnimationFrame(() => {
+ if (chatMessagesRef.value) {
+ chatMessagesRef.value.scrollTop =
+ chatMessagesRef.value.scrollHeight;
+ }
+ });
+ }
+ };
+
+ const sendChatMessage = async () => {
+ if (
+ !chatInput.value.trim() ||
+ isChatLoading.value ||
+ !selectedRecording.value ||
+ selectedRecording.value.status !== "COMPLETED"
+ ) {
+ return;
+ }
+
+ const message = chatInput.value.trim();
+
+ if (!Array.isArray(chatMessages.value)) {
+ chatMessages.value = [];
+ }
+
+ chatMessages.value.push({ role: "user", content: message });
+ chatInput.value = "";
+ isChatLoading.value = true;
+
+ await nextTick();
+ // Always scroll to bottom when user sends a new message
+ scrollChatToBottom();
+
+ let assistantMessage = null;
+
+ try {
+ const messageHistory = chatMessages.value
+ .slice(0, -1)
+ .map((msg) => ({ role: msg.role, content: msg.content }));
+
+ const response = await fetch("/tool/speakr/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ recording_id: selectedRecording.value.id,
+ message: message,
+ message_history: messageHistory,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to get chat response");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ const processStream = async () => {
+ let isFirstChunk = true;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop();
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ const jsonStr = line.substring(6);
+ if (jsonStr) {
+ try {
+ const data = JSON.parse(jsonStr);
+ if (data.thinking) {
+ // Check scroll position BEFORE updating content
+ const shouldScroll = isChatScrolledToBottom();
+
+ if (isFirstChunk) {
+ isChatLoading.value = false;
+ assistantMessage = reactive({
+ role: "assistant",
+ content: "",
+ html: "",
+ thinking: data.thinking,
+ thinkingExpanded: false,
+ });
+ chatMessages.value.push(assistantMessage);
+ isFirstChunk = false;
+ } else if (assistantMessage) {
+ // Append to existing thinking content
+ if (assistantMessage.thinking) {
+ assistantMessage.thinking += "\n\n" + data.thinking;
+ } else {
+ assistantMessage.thinking = data.thinking;
+ }
+ }
+
+ // Scroll if we were at bottom before the update
+ if (shouldScroll) {
+ await nextTick();
+ scrollChatToBottom();
+ }
+ }
+ if (data.delta) {
+ // Check scroll position BEFORE updating content
+ const shouldScroll = isChatScrolledToBottom();
+
+ if (isFirstChunk) {
+ isChatLoading.value = false;
+ assistantMessage = reactive({
+ role: "assistant",
+ content: "",
+ html: "",
+ thinking: "",
+ thinkingExpanded: false,
+ });
+ chatMessages.value.push(assistantMessage);
+ isFirstChunk = false;
+ }
+
+ assistantMessage.content += data.delta;
+ assistantMessage.html = marked.parse(
+ assistantMessage.content
+ );
+
+ // Scroll if we were at bottom before the update
+ if (shouldScroll) {
+ await nextTick();
+ scrollChatToBottom();
+ }
+ }
+ if (data.end_of_stream) {
+ return;
+ }
+ if (data.error) {
+ throw new Error(data.error);
+ }
+ } catch (e) {
+ console.error("Error parsing stream data:", e);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ await processStream();
+ } catch (error) {
+ console.error("Chat Error:", error);
+ if (assistantMessage) {
+ assistantMessage.content = `Error: ${error.message}`;
+ assistantMessage.html = `Error: ${error.message} `;
+ } else {
+ chatMessages.value.push({
+ role: "assistant",
+ content: `Error: ${error.message}`,
+ html: `Error: ${error.message} `,
+ });
+ }
+ } finally {
+ isChatLoading.value = false;
+ await nextTick();
+ // Final scroll only if user is at bottom
+ if (isChatScrolledToBottom()) {
+ scrollChatToBottom();
+ }
+ }
+ };
+
+ // --- Column Resizing ---
+ const startColumnResize = (event) => {
+ isResizing.value = true;
+ const startX = event.clientX;
+ const startLeftWidth = leftColumnWidth.value;
+
+ const handleMouseMove = (e) => {
+ if (!isResizing.value) return;
+
+ const container = document.getElementById("mainContentColumns");
+ if (!container) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const deltaX = e.clientX - startX;
+ const containerWidth = containerRect.width;
+ const deltaPercent = (deltaX / containerWidth) * 100;
+
+ let newLeftWidth = startLeftWidth + deltaPercent;
+ newLeftWidth = Math.max(20, Math.min(80, newLeftWidth)); // Constrain between 20% and 80%
+
+ leftColumnWidth.value = newLeftWidth;
+ rightColumnWidth.value = 100 - newLeftWidth;
+ };
+
+ const handleMouseUp = () => {
+ isResizing.value = false;
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+
+ // Save to localStorage
+ localStorage.setItem("transcriptColumnWidth", leftColumnWidth.value);
+ localStorage.setItem("summaryColumnWidth", rightColumnWidth.value);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ event.preventDefault();
+ };
+
+ // --- Chat Input Handling ---
+ const handleChatKeydown = (event) => {
+ if (event.key === "Enter") {
+ if (event.ctrlKey || event.shiftKey) {
+ // Ctrl+Enter or Shift+Enter: add new line (default behavior)
+ return;
+ } else {
+ // Enter: send message
+ event.preventDefault();
+ sendChatMessage();
+ }
+ }
+ };
+
+ // --- Audio Player ---
+ const seekAudio = (time, context = "main") => {
+ let audioPlayer = null;
+ if (context === "modal") {
+ // The audio player in the modal has the class directly on the audio element
+ audioPlayer = document.querySelector(
+ "audio.speaker-modal-transcript"
+ );
+ } else {
+ audioPlayer = document.querySelector(".main-content-area audio");
+ }
+
+ if (audioPlayer) {
+ const wasPlaying = !audioPlayer.paused;
+ audioPlayer.currentTime = time;
+ if (wasPlaying) {
+ audioPlayer.play();
+ }
+ } else {
+ console.warn(`Audio player not found for context: ${context}`);
+ // Fallback to old method if new one fails
+ const oldPlayer = document.querySelector("audio");
+ if (oldPlayer) {
+ const wasPlaying = !oldPlayer.paused;
+ oldPlayer.currentTime = time;
+ if (wasPlaying) {
+ oldPlayer.play();
+ }
+ }
+ }
+ };
+
+ const seekAudioFromEvent = (event) => {
+ const segmentElement = event.target.closest("[data-start-time]");
+ if (!segmentElement) return;
+
+ const time = parseFloat(segmentElement.dataset.startTime);
+ if (isNaN(time)) return;
+
+ // Determine context by checking if we're inside the speaker modal
+ const isInSpeakerModal =
+ event.target.closest(".speaker-modal-transcript") !== null;
+ const context = isInSpeakerModal ? "modal" : "main";
+
+ seekAudio(time, context);
+ };
+
+ const onPlayerVolumeChange = (event) => {
+ const newVolume = event.target.volume;
+ playerVolume.value = newVolume;
+ localStorage.setItem("playerVolume", newVolume);
+ };
+
+ // --- i18n Helper Functions ---
+ // Use the same safeT function that's globally available
+ const t = safeT;
+
+ const tc = (key, count, params = {}) => {
+ return window.i18n ? window.i18n.tc(key, count, params) : key;
+ };
+
+ const changeLanguage = async (langCode) => {
+ if (window.i18n) {
+ await window.i18n.setLocale(langCode);
+ currentLanguage.value = langCode;
+ const lang = availableLanguages.value.find(
+ (l) => l.code === langCode
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+ showLanguageMenu.value = false;
+ isUserMenuOpen.value = false;
+
+ // Save preference to backend
+ try {
+ await fetch("/tool/speakr/api/user/preferences", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrfToken.value,
+ },
+ body: JSON.stringify({ language: langCode }),
+ });
+ } catch (error) {
+ console.error("Failed to save language preference:", error);
+ }
+ }
+ };
+
+ // --- Toast Notifications ---
+ const showToast = (
+ message,
+ icon = "fa-check-circle",
+ duration = 2000
+ ) => {
+ const toastContainer = document.getElementById("toastContainer");
+
+ const toast = document.createElement("div");
+ toast.className = "toast";
+ toast.innerHTML = ` ${message}`;
+
+ toastContainer.appendChild(toast);
+
+ setTimeout(() => {
+ toast.classList.add("show");
+ }, 10);
+
+ setTimeout(() => {
+ toast.classList.remove("show");
+ setTimeout(() => {
+ toastContainer.removeChild(toast);
+ }, 300);
+ }, duration);
+ };
+
+ const animateCopyButton = (button) => {
+ button.classList.add("copy-success");
+
+ const originalContent = button.innerHTML;
+ button.innerHTML = ' ';
+
+ setTimeout(() => {
+ button.classList.remove("copy-success");
+ button.innerHTML = originalContent;
+ }, 1500);
+ };
+
+ const copyMessage = (text, event) => {
+ const button = event.currentTarget;
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ showToast("Copied to clipboard!");
+ animateCopyButton(button);
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(text, button);
+ });
+ } else {
+ fallbackCopyTextToClipboard(text, button);
+ }
+ };
+
+ const fallbackCopyTextToClipboard = (text, button = null) => {
+ try {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+
+ textArea.focus();
+ textArea.select();
+ const successful = document.execCommand("copy");
+
+ document.body.removeChild(textArea);
+
+ if (successful) {
+ showToast("Copied to clipboard!");
+ if (button) animateCopyButton(button);
+ } else {
+ showToast(
+ "Copy failed. Your browser may not support this feature.",
+ "fa-exclamation-circle"
+ );
+ }
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ showToast("Unable to copy: " + err.message, "fa-exclamation-circle");
+ }
+ };
+
+ const copyTranscription = (event) => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.transcription
+ ) {
+ showToast(
+ "No transcription available to copy.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ const button = event.currentTarget;
+ let textToCopy = "";
+
+ try {
+ const transcriptionData = JSON.parse(
+ selectedRecording.value.transcription
+ );
+ if (Array.isArray(transcriptionData)) {
+ const wasDiarized = transcriptionData.some(
+ (segment) => segment.speaker
+ );
+ if (wasDiarized) {
+ textToCopy = transcriptionData
+ .map((segment) => {
+ const speakerName = segment.speaker;
+ return `[${speakerName}]: ${segment.sentence}`;
+ })
+ .join("\n");
+ } else {
+ textToCopy = transcriptionData
+ .map((segment) => segment.sentence)
+ .join("\n");
+ }
+ } else {
+ textToCopy = selectedRecording.value.transcription;
+ }
+ } catch (e) {
+ textToCopy = selectedRecording.value.transcription;
+ }
+
+ animateCopyButton(button);
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("转录内容已复制到剪贴板!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const copySummary = (event) => {
+ if (!selectedRecording.value || !selectedRecording.value.summary) {
+ showToast("No summary available to copy.", "fa-exclamation-circle");
+ return;
+ }
+ const button = event.currentTarget;
+ const textToCopy = selectedRecording.value.summary;
+ animateCopyButton(button);
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("摘要已复制到剪贴板!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const copyNotes = (event) => {
+ if (!selectedRecording.value || !selectedRecording.value.notes) {
+ showToast("No notes available to copy.", "fa-exclamation-circle");
+ return;
+ }
+ const button = event.currentTarget;
+ const textToCopy = selectedRecording.value.notes;
+ animateCopyButton(button);
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ showToast("Notes copied to clipboard!");
+ })
+ .catch((err) => {
+ console.error("Copy failed:", err);
+ showToast(
+ "Failed to copy: " + err.message,
+ "fa-exclamation-circle"
+ );
+ fallbackCopyTextToClipboard(textToCopy);
+ });
+ } else {
+ fallbackCopyTextToClipboard(textToCopy);
+ }
+ };
+
+ const downloadSummary = async () => {
+ if (!selectedRecording.value || !selectedRecording.value.summary) {
+ showToast(
+ "No summary available to download.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/summary`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download summary",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "摘要.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Summary downloaded successfully!");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download summary", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadTranscript = async () => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.transcription
+ ) {
+ showToast(
+ "No transcription available to download.",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ try {
+ // First, fetch available templates
+ const templatesResponse = await fetch(
+ "/tool/speakr/api/transcript-templates"
+ );
+ let templates = [];
+ if (templatesResponse.ok) {
+ templates = await templatesResponse.json();
+ }
+
+ // If there are templates, show a selection dialog
+ let templateId = null;
+ if (templates.length > 0) {
+ // Create a simple modal for template selection
+ const modal = document.createElement("div");
+ modal.className =
+ "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50";
+ modal.innerHTML = `
+
+
Select Template
+
+ ${templates
+ .map(
+ (t) => `
+
+ ${
+ t.name
+ }
+ ${
+ t.description
+ ? `${t.description}
`
+ : ""
+ }
+ ${
+ t.is_default
+ ? ' Default
'
+ : ""
+ }
+
+ `
+ )
+ .join("")}
+
+
+ 取消
+ Download without template
+
+
+ `;
+ document.body.appendChild(modal);
+
+ // Wait for user selection
+ await new Promise((resolve) => {
+ modal.querySelectorAll(".template-option").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ templateId = btn.dataset.templateId;
+ modal.remove();
+ resolve();
+ });
+ });
+
+ modal
+ .querySelector(".cancel-btn")
+ .addEventListener("click", () => {
+ templateId = "cancelled"; // Mark as cancelled
+ modal.remove();
+ resolve();
+ });
+
+ modal
+ .querySelector(".download-without-template-btn")
+ .addEventListener("click", () => {
+ templateId = "none"; // Use 'none' to indicate no template
+ modal.remove();
+ resolve();
+ });
+
+ modal.addEventListener("click", (e) => {
+ if (e.target === modal) {
+ templateId = "cancelled"; // Mark as cancelled when clicking outside
+ modal.remove();
+ resolve();
+ }
+ });
+ });
+
+ if (
+ templateId === null ||
+ templateId === undefined ||
+ templateId === "cancelled"
+ ) {
+ // User cancelled
+ return;
+ }
+ }
+
+ // Download the transcript with the selected template
+ const url = templateId && templateId !== "none"
+ ? `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}`
+ : `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=none`;
+
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error("Failed to download transcript");
+ }
+
+ const blob = await response.blob();
+ const contentDisposition = response.headers.get(
+ "content-disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "转录稿.docx"
+ );
+
+ // Create download link
+ const downloadUrl = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = downloadUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(downloadUrl);
+
+ showToast("Transcript downloaded successfully!");
+ } catch (error) {
+ console.error("Error downloading transcript:", error);
+ showToast("Failed to download transcript", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadNotes = async () => {
+ if (!selectedRecording.value || !selectedRecording.value.notes) {
+ showToast("No notes available to download.", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/notes`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download notes",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "笔记.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Notes downloaded successfully!", "fa-check-circle");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download notes", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadEventICS = async (event) => {
+ if (!event || !event.id) {
+ showToast("Invalid event data", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/event/${event.id}/ics`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download event",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `${event.title || "event"}.ics`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast(
+ `Event "${event.title}" downloaded. Open the file to add to your calendar.`,
+ "fa-calendar-check",
+ 3000
+ );
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download event", "fa-exclamation-circle");
+ }
+ };
+
+ const downloadICS = async () => {
+ if (
+ !selectedRecording.value ||
+ !selectedRecording.value.events ||
+ selectedRecording.value.events.length === 0
+ ) {
+ showToast("No events to export", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${selectedRecording.value.id}/events/ics`
+ );
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to export events",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = `events-${
+ selectedRecording.value.title || selectedRecording.value.id
+ }.ics`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast(
+ `Exported ${selectedRecording.value.events.length} events`,
+ "fa-calendar-check"
+ );
+ } catch (error) {
+ console.error("Download all events ICS error:", error);
+ showToast("Failed to export events", "fa-exclamation-circle");
+ }
+ };
+
+ const formatEventDateTime = (dateTimeStr) => {
+ if (!dateTimeStr) return "";
+ try {
+ const date = new Date(dateTimeStr);
+ const options = {
+ weekday: "short",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ };
+ return date.toLocaleString(undefined, options);
+ } catch (e) {
+ return dateTimeStr;
+ }
+ };
+
+ const downloadChat = async () => {
+ if (!selectedRecording.value || chatMessages.value.length === 0) {
+ showToast("No chat messages to download.", "fa-exclamation-circle");
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/tool/speakr/recording/${selectedRecording.value.id}/download/chat`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": csrfToken.value,
+ },
+ body: JSON.stringify({
+ messages: chatMessages.value,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+ showToast(
+ error.error || "Failed to download chat",
+ "fa-exclamation-circle"
+ );
+ return;
+ }
+
+ // Create blob and download
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+
+ // Get filename from response headers or use default
+ const contentDisposition = response.headers.get(
+ "Content-Disposition"
+ );
+ const filename = getDownloadFilenameFromHeaders(
+ contentDisposition,
+ "对话记录.docx"
+ );
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ showToast("Notes downloaded successfully!");
+ } catch (error) {
+ console.error("Download failed:", error);
+ showToast("Failed to download notes", "fa-exclamation-circle");
+ }
+ };
+
+ const clearChat = () => {
+ if (chatMessages.value.length > 0) {
+ chatMessages.value = [];
+ showToast("Chat cleared", "fa-broom");
+ }
+ };
+
+ const openShareModal = async (recording) => {
+ recordingToShare.value = recording;
+ shareOptions.share_summary = true;
+ shareOptions.share_notes = true;
+ generatedShareLink.value = "";
+ existingShareDetected.value = false;
+ showShareModal.value = true;
+
+ // Check for existing share
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${recording.id}/share`,
+ {
+ method: "GET",
+ }
+ );
+ const data = await response.json();
+ if (response.ok && data.exists) {
+ generatedShareLink.value = data.share_url;
+ existingShareDetected.value = true;
+ shareOptions.share_summary = data.share.share_summary;
+ shareOptions.share_notes = data.share.share_notes;
+ }
+ } catch (error) {
+ console.error("Error checking for existing share:", error);
+ }
+ };
+
+ const closeShareModal = () => {
+ showShareModal.value = false;
+ recordingToShare.value = null;
+ existingShareDetected.value = false;
+ };
+
+ const createShare = async (forceNew = false) => {
+ if (!recordingToShare.value) return;
+ try {
+ const response = await fetch(
+ `/tool/speakr/api/recording/${recordingToShare.value.id}/share`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...shareOptions,
+ force_new: forceNew,
+ }),
+ }
+ );
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to create share link");
+
+ generatedShareLink.value = data.share_url;
+ existingShareDetected.value = data.existing && !forceNew;
+
+ if (data.existing && !forceNew) {
+ // Show that we're using an existing share
+ showToast("Using existing share link", "fa-link");
+ } else {
+ showToast("Share link created successfully!", "fa-check-circle");
+ }
+ } catch (error) {
+ setGlobalError(`Failed to create share link: ${error.message}`);
+ }
+ };
+
+ const copyShareLink = () => {
+ if (!generatedShareLink.value) return;
+ navigator.clipboard.writeText(generatedShareLink.value).then(() => {
+ showToast("Share link copied to clipboard!");
+ });
+ };
+
+ const openSharesList = async () => {
+ isLoadingShares.value = true;
+ showSharesListModal.value = true;
+ try {
+ const response = await fetch("/tool/speakr/api/shares");
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to load shared items");
+ userShares.value = data;
+ } catch (error) {
+ setGlobalError(`Failed to load shared items: ${error.message}`);
+ } finally {
+ isLoadingShares.value = false;
+ }
+ };
+
+ const closeSharesList = () => {
+ showSharesListModal.value = false;
+ userShares.value = [];
+ };
+
+ const updateShare = async (share) => {
+ try {
+ const response = await fetch(`/tool/speakr/api/share/${share.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ share_summary: share.share_summary,
+ share_notes: share.share_notes,
+ }),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to update share");
+ showToast("Share permissions updated.", "fa-check-circle");
+ } catch (error) {
+ setGlobalError(`Failed to update share: ${error.message}`);
+ }
+ };
+
+ const confirmDeleteShare = (share) => {
+ shareToDelete.value = share;
+ showShareDeleteModal.value = true;
+ };
+
+ const cancelDeleteShare = () => {
+ shareToDelete.value = null;
+ showShareDeleteModal.value = false;
+ };
+
+ const deleteShare = async () => {
+ if (!shareToDelete.value) return;
+ const shareId = shareToDelete.value.id;
+ try {
+ const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to delete share");
+ userShares.value = userShares.value.filter((s) => s.id !== shareId);
+ showToast("Share link deleted successfully.", "fa-check-circle");
+ showShareDeleteModal.value = false;
+ shareToDelete.value = null;
+ } catch (error) {
+ setGlobalError(`Failed to delete share: ${error.message}`);
+ }
+ };
+
+ // --- Watchers ---
+ watch(
+ uploadQueue,
+ (newQueue, oldQueue) => {
+ if (
+ newQueue.length === 0 &&
+ oldQueue.length > 0 &&
+ !isProcessingActive.value
+ ) {
+ console.log("Upload queue processing finished.");
+ setTimeout(() => (progressPopupMinimized.value = true), 500);
+ setTimeout(() => {
+ if (
+ completedInQueue.value === totalInQueue.value &&
+ !isProcessingActive.value
+ ) {
+ progressPopupClosed.value = true;
+ }
+ }, 2000);
+ }
+ },
+ { deep: true }
+ );
+
+ // Watch for changes to speakerMap to handle "This is Me" functionality
+ watch(
+ speakerMap,
+ (newSpeakerMap) => {
+ if (!newSpeakerMap) return;
+
+ Object.keys(newSpeakerMap).forEach((speakerId) => {
+ const speakerData = newSpeakerMap[speakerId];
+ if (
+ speakerData.isMe &&
+ currentUserName.value &&
+ !speakerData.name
+ ) {
+ // Automatically fill in the user's name when "This is Me" is checked
+ speakerData.name = currentUserName.value;
+ } else if (
+ !speakerData.isMe &&
+ speakerData.name === currentUserName.value
+ ) {
+ // Clear the name if "This is Me" is unchecked and the name matches the current user
+ speakerData.name = "";
+ }
+ });
+ },
+ { deep: true }
+ );
+
+ // Watch for tab changes to save content properly
+ watch(selectedTab, (newTab, oldTab) => {
+ // Close maximized chat when switching tabs
+ if (isChatMaximized.value) {
+ isChatMaximized.value = false;
+ }
+
+ // Save content when switching away from notes tab but keep editor open
+ if (
+ oldTab === "notes" &&
+ editingNotes.value &&
+ markdownEditorInstance.value
+ ) {
+ // Save the current content from the editor
+ const currentContent = markdownEditorInstance.value.value();
+ selectedRecording.value.notes = currentContent;
+ // Call auto-save instead of saveInlineEdit to keep editor open
+ autoSaveNotes();
+ // Store that we need to recreate the editor when coming back
+ tempNotesContent.value = currentContent;
+ }
+ // Save content when switching away from summary tab but keep editor open
+ if (
+ oldTab === "summary" &&
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value
+ ) {
+ // Save the current content from the editor
+ const currentContent = summaryMarkdownEditorInstance.value.value();
+ selectedRecording.value.summary = currentContent;
+ // Call auto-save instead of saveInlineEdit to keep editor open
+ autoSaveSummary();
+ // Store that we need to recreate the editor when coming back
+ tempSummaryContent.value = currentContent;
+ }
+
+ // Re-initialize editors when switching back to tabs
+ if (newTab === "notes" && editingNotes.value) {
+ // Destroy old instance if exists
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ // Re-initialize the editor in next tick
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ if (newTab === "summary" && editingSummary.value) {
+ // Destroy old instance if exists
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ // Re-initialize the editor in next tick
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ });
+
+ // Watch for mobile tab changes similarly
+ watch(mobileTab, (newTab, oldTab) => {
+ // Save content when switching away from notes tab but keep editor open
+ if (
+ oldTab === "notes" &&
+ editingNotes.value &&
+ markdownEditorInstance.value
+ ) {
+ const currentContent = markdownEditorInstance.value.value();
+ selectedRecording.value.notes = currentContent;
+ autoSaveNotes(); // Use auto-save instead
+ tempNotesContent.value = currentContent;
+ }
+ // Save content when switching away from summary tab but keep editor open
+ if (
+ oldTab === "summary" &&
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value
+ ) {
+ const currentContent = summaryMarkdownEditorInstance.value.value();
+ selectedRecording.value.summary = currentContent;
+ autoSaveSummary(); // Use auto-save instead
+ tempSummaryContent.value = currentContent;
+ }
+
+ // Re-initialize editors when switching back to tabs on mobile
+ if (newTab === "notes" && editingNotes.value) {
+ if (markdownEditorInstance.value) {
+ markdownEditorInstance.value.toTextArea();
+ markdownEditorInstance.value = null;
+ }
+ nextTick(() => {
+ initializeMarkdownEditor();
+ });
+ }
+ if (newTab === "summary" && editingSummary.value) {
+ if (summaryMarkdownEditorInstance.value) {
+ summaryMarkdownEditorInstance.value.toTextArea();
+ summaryMarkdownEditorInstance.value = null;
+ }
+ nextTick(() => {
+ initializeSummaryMarkdownEditor();
+ });
+ }
+ });
+
+ watch(selectedRecording, (newVal, oldVal) => {
+ if (newVal?.id !== oldVal?.id) {
+ // Save any pending edits before switching recordings
+ if (
+ editingNotes.value &&
+ markdownEditorInstance.value &&
+ oldVal?.id
+ ) {
+ selectedRecording.value = oldVal; // Temporarily restore old recording
+ saveEditNotes(); // This will save and cleanup
+ selectedRecording.value = newVal; // Switch back to new recording
+ }
+ if (
+ editingSummary.value &&
+ summaryMarkdownEditorInstance.value &&
+ oldVal?.id
+ ) {
+ selectedRecording.value = oldVal; // Temporarily restore old recording
+ saveEditSummary(); // This will save and cleanup
+ selectedRecording.value = newVal; // Switch back to new recording
+ }
+
+ chatMessages.value = [];
+ showChat.value = false;
+ selectedTab.value = "summary";
+
+ editingParticipants.value = false;
+ editingMeetingDate.value = false;
+ editingSummary.value = false;
+ editingNotes.value = false;
+
+ // Fix WebM duration issue by forcing metadata load
+ if (newVal?.id) {
+ nextTick(() => {
+ const audioElements = document.querySelectorAll("audio");
+ audioElements.forEach((audio) => {
+ if (
+ audio.src &&
+ audio.src.includes(`tool/speakr/audio/${newVal.id}`)
+ ) {
+ // For WebM files, we need to seek to end to get duration
+ const fixDuration = () => {
+ if (!isFinite(audio.duration) || audio.duration === 0) {
+ audio.currentTime = 1e101; // Seek to "infinity" to load duration
+ audio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ audio.currentTime = 0;
+ audio.removeEventListener("timeupdate", resetTime);
+ },
+ { once: true }
+ );
+ }
+ };
+
+ // Try to fix duration when metadata loads
+ audio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+
+ // Also try immediately in case metadata is already loaded
+ if (audio.readyState >= 1) {
+ fixDuration();
+ }
+ }
+ });
+ });
+ }
+ }
+ });
+ // 会议记录页面
+ watch(currentView, (newView) => {
+ if (newView === "recording") {
+ // Initialize recording markdown editor when switching to recording view
+ nextTick(() => {
+ initializeRecordingMarkdownEditor();
+ getBaseInfo();
+ is_final.value = false;
+ // 清空上一次的实时转录文本
+ asrResultOnline.value = "";
+ // ===== 清理旧的 Recorder 实例 =====
+ // 根本原因修复:旧 rec 的 onProcess 回调在旧闭包中持续活跃,
+ // 向已停止的旧 wsconnecter 发送数据,导致缓冲区溢出和 WebSocket 未连接刷屏
+ if (rec) {
+ try { rec.close(); } catch(e) { console.warn('关闭旧rec失败:', e); }
+ rec = null;
+ }
+ if (rec2) {
+ try { rec2.close(); } catch(e) { console.warn('关闭旧rec2失败:', e); }
+ rec2 = null;
+ }
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ asrAudioContext = null;
+ }
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ asrAudioContext2 = null;
+ }
+ //语音实时转换相关
+ //online的ws示例
+ var wsconnecter = new WebSocketConnectMethod({
+ msgHandle: getJsonMessage,
+ stateHandle: getConnState,
+ });
+ //offline的ws示例
+ var wsconnecter2 = new WebSocketConnectMethod({
+ msgHandle: getJsonMessage2,
+ stateHandle: getConnState2,
+ });
+ webReader = wsconnecter;
+ webReader2 = wsconnecter2;
+ var audioBlob;
+ var sampleBuf = new Int16Array();
+ var sampleBuf2 = new Int16Array();
+ const asrChunkSize = 960;
+ const asrBufferWarnSamples = 20000;
+ const asrBufferHardLimitSamples = 16000 * 5;
+ var rec_text = "";
+ var offline_text = "";
+ var rec_textOnline = "";
+ var offlineSentenceHandoffText = "";
+ var rec_textoffline = "";
+ var pendingSentenceQueue = [];
+ let pendingSentenceSequence = 0;
+ var lastOnlineTimestamp = null; // 记录上次在线转录的时间戳
+ function extractTimestampMs(timestampPayload) {
+ if (!timestampPayload) {
+ return null;
+ }
+
+ try {
+ const timestampData =
+ typeof timestampPayload === "string"
+ ? JSON.parse(timestampPayload)
+ : timestampPayload;
+
+ if (!Array.isArray(timestampData) || timestampData.length === 0) {
+ return null;
+ }
+
+ const lastItem = timestampData[timestampData.length - 1];
+ if (!Array.isArray(lastItem) || lastItem.length === 0) {
+ return null;
+ }
+
+ const endOrStart =
+ lastItem.length > 1 ? Number(lastItem[1]) : Number(lastItem[0]);
+ return Number.isFinite(endOrStart) ? endOrStart : null;
+ } catch (error) {
+ return null;
+ }
+ }
+ function getLatestRealtimeTimestamp() {
+ const latestSegmentTimestamp = segmentList.reduce(
+ (latest, segment) =>
+ Number.isFinite(segment?.timestampMs)
+ ? Math.max(latest, segment.timestampMs)
+ : latest,
+ -1
+ );
+ const latestPendingTimestamp = pendingSentenceQueue.reduce(
+ (latest, item) =>
+ Number.isFinite(item.timestampMs)
+ ? Math.max(latest, item.timestampMs)
+ : latest,
+ -1
+ );
+ const latestOnlineTimestamp = Number.isFinite(lastOnlineTimestamp)
+ ? lastOnlineTimestamp
+ : -1;
+
+ return Math.max(
+ latestSegmentTimestamp,
+ latestPendingTimestamp,
+ latestOnlineTimestamp
+ );
+ }
+ function getRealtimeCarryoverTail(text, maxLength = 160) {
+ return (text || "").slice(-maxLength);
+ }
+ function getSharedRealtimeSuffix(
+ originalText,
+ editedText,
+ maxLength = 80
+ ) {
+ const original = originalText || "";
+ const edited = editedText || "";
+ const maxSuffixLength = Math.min(
+ maxLength,
+ original.length,
+ edited.length
+ );
+
+ for (let size = maxSuffixLength; size >= 1; size--) {
+ const suffix = edited.slice(-size);
+ if (original.endsWith(suffix)) {
+ return suffix;
+ }
+ }
+
+ return "";
+ }
+ function buildRealtimeEditCarryover(baseText) {
+ const tailText = getRealtimeCarryoverTail(baseText);
+ if (tailText.length < 4) {
+ return null;
+ }
+
+ return {
+ tailText,
+ };
+ }
+ function trimOfflineTextWithCarryover(rawText) {
+ const incomingText = rawText || "";
+ if (!realtimeEditCarryover || !incomingText) {
+ return incomingText;
+ }
+
+ const tailText = realtimeEditCarryover.tailText || "";
+ const minOverlapLength = 4;
+ const maxOverlapLength = Math.min(
+ 80,
+ tailText.length,
+ incomingText.length
+ );
+
+ for (
+ let size = maxOverlapLength;
+ size >= minOverlapLength;
+ size--
+ ) {
+ const overlapText = tailText.slice(-size);
+ const matchIndex = incomingText.lastIndexOf(overlapText);
+ if (matchIndex === -1) {
+ continue;
+ }
+
+ const remainingText = incomingText.slice(
+ matchIndex + overlapText.length
+ );
+ realtimeEditCarryover.tailText = getRealtimeCarryoverTail(
+ tailText + remainingText
+ );
+ return remainingText;
+ }
+
+ realtimeEditCarryover = null;
+ return incomingText;
+ }
+ function clearRealtimePipeline({
+ preserveTimestampBarrier = false,
+ } = {}) {
+ sampleBuf = new Int16Array();
+ sampleBuf2 = new Int16Array();
+ rec_text = "";
+ offline_text = "";
+ rec_textOnline = "";
+ offlineSentenceHandoffText = "";
+ rec_textoffline = "";
+ pendingSentenceQueue = [];
+ pendingSentenceSequence = 0;
+ lastOnlineTimestamp = null;
+ asrResultOnline.value = "";
+ asrResultOffline.value = "";
+
+ if (!preserveTimestampBarrier) {
+ realtimeTimestampBarrierMs = -1;
+ }
+
+ syncRealtimePreview();
+ }
+ function isStaleRealtimeMessage(
+ messageTimestampMs,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ if (messageEpoch !== liveTranscriptionEpoch) {
+ return true;
+ }
+
+ return (
+ Number.isFinite(messageTimestampMs) &&
+ realtimeTimestampBarrierMs >= 0 &&
+ messageTimestampMs <= realtimeTimestampBarrierMs
+ );
+ }
+ clearRealtimePipelineRef = clearRealtimePipeline;
+ getLatestRealtimeTimestampRef = getLatestRealtimeTimestamp;
+ buildRealtimeEditCarryoverRef = buildRealtimeEditCarryover;
+ renderFullTextRef = renderFullText;
+ function getPendingSentencePreviewText() {
+ return pendingSentenceQueue
+ .filter((item) => item.epoch === liveTranscriptionEpoch)
+ .map((item) => item.correctedText || item.rawText)
+ .join("");
+ }
+ function syncRealtimePreview() {
+ asrResultOnline.value =
+ getPendingSentencePreviewText() +
+ offlineSentenceHandoffText +
+ rec_textOnline;
+ }
+ function findPendingSentenceBySource(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ const normalizedSourceText = sourceText || "";
+ const normalizedTimestamp = pendingTimestamp || "";
+ return (
+ pendingSentenceQueue.find(
+ (item) =>
+ item.epoch === messageEpoch &&
+ item.sourceText === normalizedSourceText &&
+ item.timestamp === normalizedTimestamp
+ ) ||
+ segmentList.find(
+ (item) =>
+ item.epoch === messageEpoch &&
+ item.sourceText === normalizedSourceText &&
+ item.timestamp === normalizedTimestamp
+ ) ||
+ null
+ );
+ }
+ function ensurePendingSentence(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ const timestampMs = extractTimestampMs(pendingTimestamp);
+ if (isStaleRealtimeMessage(timestampMs, messageEpoch)) {
+ return null;
+ }
+
+ const existingItem = findPendingSentenceBySource(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch
+ );
+ if (existingItem) {
+ return existingItem;
+ }
+
+ const trimmedText = trimOfflineTextWithCarryover(sourceText || "");
+ if (!trimmedText) {
+ syncRealtimePreview();
+ return null;
+ }
+
+ const pendingItem = {
+ id: pendingSentenceSequence++,
+ sourceText: sourceText || "",
+ rawText: trimmedText,
+ timestamp: pendingTimestamp || "",
+ timestampMs,
+ correctedText: "",
+ processed: false,
+ correctionRequested: false,
+ epoch: messageEpoch,
+ };
+ pendingSentenceQueue.push(pendingItem);
+ syncRealtimePreview();
+ return pendingItem;
+ }
+ function flushProcessedPendingSentences() {
+ let didFlush = false;
+ while (pendingSentenceQueue.length > 0) {
+ if (pendingSentenceQueue[0].epoch !== liveTranscriptionEpoch) {
+ pendingSentenceQueue.shift();
+ continue;
+ }
+ if (!pendingSentenceQueue[0].processed) {
+ break;
+ }
+
+ const item = pendingSentenceQueue.shift();
+ segmentList.push({
+ text: item.correctedText || item.rawText,
+ sourceText: item.sourceText,
+ timestamp: item.timestamp,
+ timestampMs: item.timestampMs,
+ speaker: null,
+ processed: true,
+ epoch: item.epoch,
+ });
+ didFlush = true;
+ }
+
+ if (didFlush) {
+ renderFullText();
+ } else {
+ syncRealtimePreview();
+ }
+ }
+ function finalizePendingSentence(
+ pendingSentenceId,
+ finalText,
+ messageEpoch = liveTranscriptionEpoch
+ ) {
+ const target = pendingSentenceQueue.find(
+ (item) =>
+ item.id === pendingSentenceId && item.epoch === messageEpoch
+ );
+ if (!target) {
+ return;
+ }
+
+ target.correctedText = finalText || target.rawText || "";
+ target.processed = true;
+ target.correctionRequested = false;
+ flushProcessedPendingSentences();
+ }
+ async function record() {
+ // rec.open(function () {
+ // rec.start();
+ // });
+ // await startRecording();
+ await openMixedStream();
+ }
+ async function record2() {
+ // rec2.open(function () {
+ // rec2.start();
+ // });
+ // await startRecording();
+ await openMixedStream2();
+ }
+ function recProcess(
+ buffer,
+
+ powerLevel,
+ bufferDuration,
+ bufferSampleRate,
+ newBufferIdx,
+ asyncEnd
+ ) {
+ if (isRec === true) {
+ var data_48k = buffer[buffer.length - 1];
+
+ var array_48k = new Array(data_48k);
+ var data_16k = Recorder.SampleData(
+ array_48k,
+ bufferSampleRate,
+ 16000
+ ).data;
+
+ sampleBuf = Int16Array.from([...sampleBuf, ...data_16k]);
+
+ if (sampleBuf.length > asrBufferHardLimitSamples) {
+ const overflowSamples =
+ sampleBuf.length - asrBufferHardLimitSamples;
+ console.warn(
+ `⚠️ [Online] 丢弃过量本地音频缓冲: ${overflowSamples} samples`
+ );
+ sampleBuf = sampleBuf.slice(-asrBufferHardLimitSamples);
+ }
+
+ while (sampleBuf.length >= asrChunkSize) {
+ const sendBuf = sampleBuf.slice(0, asrChunkSize);
+ const sendAccepted = wsconnecter.wsSend(sendBuf);
+ if (!sendAccepted) {
+ break;
+ }
+ sampleBuf = sampleBuf.slice(asrChunkSize);
+ }
+
+ if (sampleBuf.length > asrBufferWarnSamples) {
+ console.warn(
+ `⚠️ [Online] 音频缓冲区溢出: ${sampleBuf.length} samples (${(
+ sampleBuf.length / 16000
+ ).toFixed(2)}s)`
+ );
+ }
+ }
+ }
+ function recProcess2(
+ buffer,
+ powerLevel,
+ bufferDuration,
+ bufferSampleRate,
+ newBufferIdx,
+ asyncEnd
+ ) {
+ if (isRec === true) {
+ var data_48k = buffer[buffer.length - 1];
+
+ var array_48k = new Array(data_48k);
+ var data_16k = Recorder.SampleData(
+ array_48k,
+ bufferSampleRate,
+ 16000
+ ).data;
+
+ sampleBuf2 = Int16Array.from([...sampleBuf2, ...data_16k]);
+
+ if (sampleBuf2.length > asrBufferHardLimitSamples) {
+ const overflowSamples =
+ sampleBuf2.length - asrBufferHardLimitSamples;
+ console.warn(
+ `⚠️ [Offline] 丢弃过量本地音频缓冲: ${overflowSamples} samples`
+ );
+ sampleBuf2 = sampleBuf2.slice(-asrBufferHardLimitSamples);
+ }
+
+ while (sampleBuf2.length >= asrChunkSize) {
+ const sendBuf2 = sampleBuf2.slice(0, asrChunkSize);
+ const sendAccepted = wsconnecter2.wsSend(sendBuf2);
+ if (!sendAccepted) {
+ break;
+ }
+ sampleBuf2 = sampleBuf2.slice(asrChunkSize);
+ }
+
+ if (sampleBuf2.length > asrBufferWarnSamples) {
+ console.warn(
+ `⚠️ [Offline] 音频缓冲区溢出: ${sampleBuf2.length} samples (${(
+ sampleBuf2.length / 16000
+ ).toFixed(2)}s)`
+ );
+ }
+ }
+ }
+ function addNewlineAfterPeriod(str) {
+ return str.replace(/[。?!]/g, "$&\n");
+ }
+
+ function handleWithTimestamp(
+ tmptext,
+ tmptime,
+ keywordStr,
+ timeThreshold
+ ) {
+ if (!tmptime || tmptext.length <= 0) return tmptext;
+
+ const keywordList = keywordStr
+ .split(",")
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0);
+
+ const keywordsAsRegex = keywordList.map((k) => new RegExp(k));
+ const segments = tmptext.match(/[^。!?]+[。!?]?/g) || [];
+ // console.log(segments, "segments");
+
+ const jsontime = JSON.parse(tmptime);
+ let char_index = 0;
+ let text_withtime = "";
+
+ // 辅助函数:去除新行开头的标点符号
+ function removeLeadingPunctuation(text) {
+ // 匹配开头的标点符号(中文和英文标点)
+ return text.replace(/^[,。!?、;:,\.!?;:]+/, "");
+ }
+
+ for (let i = 0; i < segments.length; i++) {
+ let seg = segments[i];
+ if (!seg || seg === "undefined") continue;
+
+ let nowTime = jsontime[char_index][0] / 1000;
+
+ if (
+ lastTimer.value > 0 &&
+ nowTime - lastTimer.value > timeThreshold
+ ) {
+ const lastChar = text_withtime.slice(-1);
+ const endsWithPunc = /[。!?]/.test(lastChar);
+ // 换行后,如果新行开头是标点,去除第一个标点
+ let processedSeg = removeLeadingPunctuation(seg);
+ text_withtime +=
+ (endsWithPunc ? "\n" : "。\n") + processedSeg;
+ } else {
+ let hitKeyword = keywordsAsRegex.some((reg) => reg.test(seg));
+ if (hitKeyword) {
+ // 关键词后换行,如果下一行开头是标点,去除第一个标点
+ let processedSeg = removeLeadingPunctuation(seg);
+ // text_withtime += processedSeg + "。" + "\n";
+ } else {
+ text_withtime += seg;
+ }
+ }
+
+ lastTimer.value = nowTime;
+ char_index++;
+ }
+
+ return text_withtime;
+ }
+ function getSpeakerBeforeIndex(endIndex) {
+ let previousSpeaker = null;
+ for (let i = 0; i < endIndex; i++) {
+ if (segmentList[i] && segmentList[i].speaker) {
+ previousSpeaker = segmentList[i].speaker;
+ }
+ }
+ return previousSpeaker;
+ }
+
+ function buildSegmentText(segments, initialSpeaker = null) {
+ let displayText = "";
+ let lastRenderedSpeaker = initialSpeaker;
+
+ segments.forEach((seg) => {
+ if (showSpeaker.value) {
+ if (
+ seg.speaker &&
+ seg.speaker !== lastRenderedSpeaker &&
+ !seg.speaker.includes("SPK")
+ ) {
+ displayText +=
+ (displayText ? "\n" : "") + seg.speaker + ":\n";
+ lastRenderedSpeaker = seg.speaker;
+ } else if (
+ seg.speaker &&
+ seg.speaker !== lastRenderedSpeaker
+ ) {
+ displayText +=
+ (displayText ? "\n" : "") + seg.speaker + ":\n";
+ lastRenderedSpeaker = seg.speaker;
+ }
+ }
+
+ let textContent = handleWithTimestamp(
+ seg.text,
+ seg.timestamp,
+ configwords,
+ 6
+ );
+ displayText += textContent;
+ });
+
+ return displayText;
+ }
+
+ function renderFullText() {
+ const initialSpeaker =
+ segmentRenderStartIndex > 0
+ ? getSpeakerBeforeIndex(segmentRenderStartIndex)
+ : null;
+ const displayText = buildSegmentText(
+ segmentList.slice(segmentRenderStartIndex),
+ initialSpeaker
+ );
+
+ rec_text = displayText;
+ // 保留历史转录内容(来自暂停/继续草稿)
+ asrResult.value = pausedTranscription.value + rec_text; // 更新主显示区域
+
+ // 更新实时显示,展示剩余的待处理句子 + 当前正在识别的内容
+ syncRealtimePreview();
+ // 移除旧的清空逻辑
+ // asrResultOnline.value = "";
+ // rec_textOnline = "";
+ }
+ // Legacy implementation kept temporarily for reference; the active
+ // realtime path uses the clean getJsonMessage defined below.
+ function getJsonMessageLegacy(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var rectxt = "" + parsedMessage["text"];
+ var asrmodel = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+
+ // 检查是否需要重置转录状态(从暂停/恢复时)
+ if (resetTranscriptionFlag) {
+ console.log("🔄 重置转录状态(暂停/恢复后)");
+ clearRealtimePipeline();
+ resetTranscriptionFlag = false;
+ }
+
+ const messageEpoch = liveTranscriptionEpoch;
+
+ if (asrmodel == "2pass-online") {
+ // ✅ 实时转录换行逻辑
+ // 规则1:凡是换行的,末尾必须是句号(。)
+ // 规则2:不修改标点符号
+ // 规则3:停顿3秒以上才换行
+
+ // 解析时间戳(获取最后一个字符的时间)
+ const currentTimestamp = extractTimestampMs(timestamp);
+ if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
+ syncRealtimePreview();
+ return;
+ }
+
+ // 检查是否需要换行
+ let shouldBreak = false;
+ if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
+ // 计算时间差(单位:秒)
+ const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
+ // 停顿超过3秒,且当前文本以句号结尾
+ if (timeDiff >= 3 && /。\s*$/.test(rectxt)) {
+ shouldBreak = true;
+ }
+ }
+
+ // 拼接文本(不修改标点)
+ if (shouldBreak) {
+ rec_textOnline = rec_textOnline + rectxt + "\n";
+ } else {
+ rec_textOnline = rec_textOnline + rectxt;
+ }
+
+ // 更新时间戳
+ lastOnlineTimestamp = currentTimestamp;
+
+ } else if (asrmodel == "2pass-offline") {
+ // Bug fix: 使用offline返回的完整文本替代可能不完整的online累积文本
+ // rectxt包含了完整的转录结果,比rec_textOnline更准确
+ ensurePendingSentence(rectxt, timestamp, messageEpoch);
+ rec_textOnline = "";
+ lastOnlineTimestamp = null; // 重置时间戳
+ } else{
+ }
+ // 更新显示:队列中的内容 + 当前实时内容
+ syncRealtimePreview();
+
+ }
+ function getJsonMessage(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var rectxt = "" + parsedMessage["text"];
+ var asrmodel = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+
+ if (resetTranscriptionFlag) {
+ console.log("重置实时转录状态(暂停/恢复后)");
+ clearRealtimePipeline();
+ resetTranscriptionFlag = false;
+ }
+
+ const messageEpoch = liveTranscriptionEpoch;
+
+ if (asrmodel == "2pass-online") {
+ const currentTimestamp = extractTimestampMs(timestamp);
+ if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
+ syncRealtimePreview();
+ return;
+ }
+
+ let shouldBreak = false;
+ if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
+ const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
+ if (timeDiff >= 3 && /。\s*$/.test(rectxt)) {
+ shouldBreak = true;
+ }
+ }
+
+ if (shouldBreak) {
+ rec_textOnline = rec_textOnline + rectxt + "\n";
+ } else {
+ rec_textOnline = rec_textOnline + rectxt;
+ }
+
+ lastOnlineTimestamp = currentTimestamp;
+ } else if (asrmodel == "2pass-offline") {
+ // 离线句子的正式入队只走 getJsonMessage2,避免双通路重复落句。
+ rec_textOnline = "";
+ offlineSentenceHandoffText = rectxt || rec_textOnline || "";
+ rec_textOnline = "";
+ lastOnlineTimestamp = null;
+ }
+
+ syncRealtimePreview();
+
+ }
+ async function getJsonMessage2(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ var asrmodeltype = parsedMessage["mode"];
+ var timestamp = parsedMessage["timestamp"];
+ const messageEpoch = liveTranscriptionEpoch;
+ const messageTimestampMs = extractTimestampMs(timestamp);
+
+ // ============================
+ // 场景 A: 2pass-offline (文本先到)
+ // ============================
+ if (asrmodeltype == "2pass-offline") {
+ if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
+ return;
+ }
+
+ var rectxt = "" + parsedMessage["text"];
+ rec_textOnline = "";
+ lastOnlineTimestamp = null;
+ // 3. 准备进行 LLM 矫正
+ // 使用当前数组里已有的文本 (就是 2pass-offline 的文本)
+ const pendingSentence = ensurePendingSentence(
+ rectxt,
+ timestamp,
+ messageEpoch
+ );
+ offlineSentenceHandoffText = "";
+ syncRealtimePreview();
+ if (
+ !pendingSentence ||
+ pendingSentence.processed ||
+ pendingSentence.correctionRequested
+ ) {
+ return;
+ }
+
+ pendingSentence.correctionRequested = true;
+ pendingCorrectionCount++;
+ try {
+ const response = await fetch(`/tool/speakr/api/asr/correct`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: pendingSentence.rawText }), // 发送当前新增文本去矫正
+ });
+
+ if (!response.ok) {
+ throw new Error("ASR correction failed");
+ }
+
+ const data = await response.json();
+ let correctedText =
+ data.corrected_text || pendingSentence.rawText;
+
+ // 4. LLM 回来后,直接更新文本内容(自动应用)
+ finalizePendingSentence(
+ pendingSentence.id,
+ correctedText,
+ messageEpoch
+ );
+ console.log("LLM矫正完成");
+ } catch (error) {
+ finalizePendingSentence(
+ pendingSentence.id,
+ pendingSentence.rawText,
+ messageEpoch
+ );
+ console.error("LLM矫正出错:", error);
+ } finally {
+ pendingCorrectionCount = Math.max(
+ 0,
+ pendingCorrectionCount - 1
+ );
+ }
+ }
+
+ // ============================
+ // 场景 B: offline-speaker (说话人后到)
+ // ============================
+ else if (asrmodeltype == "offline-speaker") {
+ if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
+ return;
+ }
+ // 【修改点】:这里不再获取 full_text 用于替换
+ // var rectxt = "" + JSON.parse(jsonMsg.data)["full_text"]; // 这行删掉或注释
+
+ let nowSpeaker =
+ (parsedMessage["segments"] &&
+ parsedMessage["segments"][0] &&
+ parsedMessage["segments"][0].speaker) ||
+ "未命名演讲者";
+
+ // 找到最后一个还没认领说话人的句子
+ let targetIndex = -1;
+ for (let i = segmentList.length - 1; i >= 0; i--) {
+ const segment = segmentList[i];
+ if (!segment || segment.epoch !== messageEpoch) {
+ continue;
+ }
+ if (
+ segment.speaker === null &&
+ Number.isFinite(messageTimestampMs) &&
+ Number.isFinite(segment.timestampMs) &&
+ segment.timestampMs === messageTimestampMs
+ ) {
+ targetIndex = i;
+ break;
+ }
+ if (targetIndex === -1 && segment.speaker === null) {
+ targetIndex = i;
+ }
+ }
+
+ if (targetIndex !== -1) {
+ // 1. 只更新说话人
+ segmentList[targetIndex].speaker = nowSpeaker;
+
+ // 【关键】:这里绝对不更新 text,保持 2pass-offline 时的原样
+ // segmentList[targetIndex].text = ...; // 不要这行
+
+ // 2. 立即渲染
+ // 效果:文本没变(不会跳变),只是头上多了个名字
+ renderFullText();
+ }
+ }
+
+ }
+
+ // 连接状态响应
+ function getConnState(connState) {
+ if (connState === 0) {
+ record();
+ } else if (connState === 1) {
+ } else if (connState === 2) {
+ stop();
+ }
+ }
+ // 连接状态响应
+ function getConnState2(connState) {
+ if (connState === 0) {
+ record2();
+ } else if (connState === 1) {
+ } else if (connState === 2) {
+ stop();
+ }
+ }
+ async function getMicrophoneInfo() {
+ if (
+ !navigator.mediaDevices ||
+ !navigator.mediaDevices.enumerateDevices
+ ) {
+ console.warn("当前浏览器不支持获取设备信息");
+ return null;
+ }
+
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const mics = devices.filter((d) => d.kind === "audioinput");
+
+ // 当前默认麦克风(大多数浏览器)
+ const defaultMic =
+ mics.find((d) => d.deviceId === "default") || mics[0];
+
+ return {
+ name: defaultMic?.label || "未知麦克风",
+ deviceId: defaultMic?.deviceId || "",
+ all: mics.map((d) => ({
+ name: d.label,
+ deviceId: d.deviceId,
+ })),
+ };
+ }
+ async function openMixedStream() {
+ try {
+ const micInfo = await getMicrophoneInfo();
+ if (micInfo) {
+ console.log("🎤 当前麦克风:", micInfo.name, micInfo);
+ }
+ // 获取麦克风音频流
+ const micStreams = micStream;
+
+ // 尝试获取系统音频流
+ let systemStreams;
+ try {
+ systemStreams = systemStream;
+ } catch (err) {
+ console.warn("未能获取系统音频,仅使用麦克风录音:", err);
+ }
+
+ // 创建音频上下文和混音目标
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ }
+ asrAudioContext = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ const audioContext = asrAudioContext;
+ const destination = audioContext.createMediaStreamDestination();
+
+ // 把麦克风接入混音输出
+ const micSource =
+ audioContext.createMediaStreamSource(micStreams);
+ micSource.connect(destination);
+
+ // 如果有系统音频,混入
+ if (systemStream && systemStream.getAudioTracks().length > 0) {
+ const sysSource =
+ audioContext.createMediaStreamSource(systemStream);
+ sysSource.connect(destination);
+ } else {
+ console.log("⚠️ 未检测到系统音频,使用麦克风录音");
+ }
+
+ // 打开 recorder 并传入混合后的流
+ rec.open(
+ () => {
+ rec.start(destination.stream);
+ },
+ (msg) => {
+ console.error("❌ 录音权限被拒绝:", msg);
+ }
+ );
+ } catch (err) {
+ console.error("初始化录音失败:", err);
+ }
+ }
+ async function openMixedStream2() {
+ try {
+ // 获取麦克风音频流
+ const micStreams = micStream;
+
+ // 尝试获取系统音频流(Chrome 需要用户手动勾选“共享系统音频”)
+ let systemStreams;
+ try {
+ systemStreams = systemStream;
+ } catch (err) {
+ console.warn("未能获取系统音频,仅使用麦克风录音:", err);
+ }
+
+ // 创建音频上下文和混音目标
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ }
+ asrAudioContext2 = new (window.AudioContext ||
+ window.webkitAudioContext)();
+ const audioContext = asrAudioContext2;
+ const destination = audioContext.createMediaStreamDestination();
+
+ // 把麦克风接入混音输出
+ const micSource =
+ audioContext.createMediaStreamSource(micStreams);
+ micSource.connect(destination);
+
+ // 如果有系统音频,混入
+ if (systemStream && systemStream.getAudioTracks().length > 0) {
+ const sysSource =
+ audioContext.createMediaStreamSource(systemStream);
+ sysSource.connect(destination);
+ } else {
+ console.log("⚠️ 未检测到系统音频,使用麦克风录音");
+ }
+
+ // 打开 recorder 并传入混合后的流
+ rec2.open(
+ () => {
+ rec2.start(destination.stream);
+ },
+ (msg) => {
+ console.error("❌ 录音权限被拒绝:", msg);
+ }
+ );
+ } catch (err) {
+ console.error("初始化录音失败:", err);
+ }
+ }
+
+ const start = () => {
+ // 清除显示
+ // clear();
+ //控件状态更新
+
+ //启动连接
+ var ret = wsconnecter.wsStart();
+ if (ret == 1) {
+ isRec = true;
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+ const start2 = () => {
+ // 清除显示
+ // clear();
+ //控件状态更新
+
+ //启动连接(offline)
+ var ret = wsconnecter2.wsStart("offline");
+ if (ret == 1) {
+ isRec = true;
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+ if (!isPaused.value) {
+ start();
+ start2();
+ }
+ // 录音; 定义录音对象,wav格式
+ rec = Recorder({
+ type: "pcm",
+ bitRate: 16,
+ sampleRate: 16000,
+ onProcess: recProcess,
+ });
+ rec2 = Recorder({
+ type: "pcm",
+ bitRate: 16,
+ sampleRate: 16000,
+ onProcess: recProcess2,
+ });
+
+ // Fix audio duration for recorded files
+ if (audioBlobURL.value) {
+ const recordingAudio = document.querySelector(
+ 'audio[src*="blob:"]'
+ );
+ if (recordingAudio) {
+ const fixDuration = () => {
+ if (
+ !isFinite(recordingAudio.duration) ||
+ recordingAudio.duration === 0
+ ) {
+
+ const originalTime = recordingAudio.currentTime;
+ recordingAudio.currentTime = 1e101;
+ recordingAudio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ recordingAudio.currentTime = originalTime;
+ recordingAudio.removeEventListener(
+ "timeupdate",
+ resetTime
+ );
+ },
+ { once: true }
+ );
+ }
+ };
+ recordingAudio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("canplay", fixDuration, {
+ once: true,
+ });
+ if (recordingAudio.readyState >= 1) {
+ setTimeout(fixDuration, 100);
+ }
+ }
+ }
+ });
+ } else {
+ liveTranscriptionEpoch += 1;
+ realtimeTimestampBarrierMs = -1;
+ clearRealtimePipelineRef = null;
+ getLatestRealtimeTimestampRef = null;
+ buildRealtimeEditCarryoverRef = null;
+ renderFullTextRef = null;
+ isRec = false;
+ analysisResult.value = "";
+ asrResult.value = "";
+ segmentList = [];
+ segmentRenderStartIndex = 0;
+ isEditingTranscription.value = false;
+ is_final.value = false;
+ if (recordingMarkdownEditorInstance.value) {
+ recordingMarkdownEditorInstance.value.toTextArea();
+ recordingMarkdownEditorInstance.value = null;
+ }
+ if (webReader) {
+ webReader.wsStop();
+ }
+ if (webReader2) {
+ webReader2.wsStop();
+ }
+ // 关闭 Recorder 实例,停止 onProcess 回调
+ if (rec) {
+ try { rec.close(); } catch(e) {}
+ rec = null;
+ }
+ if (rec2) {
+ try { rec2.close(); } catch(e) {}
+ rec2 = null;
+ }
+ if (asrAudioContext) {
+ try { asrAudioContext.close(); } catch(e) {}
+ asrAudioContext = null;
+ }
+ if (asrAudioContext2) {
+ try { asrAudioContext2.close(); } catch(e) {}
+ asrAudioContext2 = null;
+ }
+
+ // Clean up recording markdown editor when leaving recording view
+ }
+ });
+ // 监听内容变化
+ watch(asrResult, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+ if (asrTextarea.value) {
+ // console.log(asrTextarea.value.scrollHeight,'asrTextarea.value.scrollHeight');
+
+ asrTextarea.value.scrollTop = asrTextarea.value.scrollHeight;
+ }
+ if (asrResultTextarea.value) {
+ asrResultTextarea.value.scrollTop =
+ asrResultTextarea.value.scrollHeight;
+ }
+ });
+ // 监听内容变化
+ watch(asrResultOnline, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+
+ if (asrResultTextareaOnline.value) {
+ asrResultTextareaOnline.value.scrollTop =
+ asrResultTextareaOnline.value.scrollHeight;
+ }
+ });
+ watch(asrResultOffline, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+
+ if (asrResultTextareaOnline.value) {
+ asrResultTextareaOnline.value.scrollTop =
+ asrResultTextareaOnline.value.scrollHeight;
+ }
+ });
+ let realtimeAutoScrollFrame = null;
+ const scrollRealtimeTextareaToBottom = (textarea) => {
+ if (!textarea) return;
+ textarea.scrollTop = textarea.scrollHeight;
+ };
+ const scrollVisibleRealtimeTextareas = () => {
+ [
+ asrTextarea.value,
+ asrResultTextarea.value,
+ asrResultTextareaOnline.value,
+ ].forEach(scrollRealtimeTextareaToBottom);
+ };
+ const scheduleRealtimeAutoScroll = async (force = false) => {
+ if (isEditingTranscription.value && !force) {
+ return;
+ }
+
+ await nextTick();
+ scrollVisibleRealtimeTextareas();
+
+ if (
+ typeof window === "undefined" ||
+ typeof window.requestAnimationFrame !== "function"
+ ) {
+ return;
+ }
+
+ if (realtimeAutoScrollFrame !== null) {
+ window.cancelAnimationFrame(realtimeAutoScrollFrame);
+ realtimeAutoScrollFrame = null;
+ }
+
+ realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
+ scrollVisibleRealtimeTextareas();
+ realtimeAutoScrollFrame = window.requestAnimationFrame(() => {
+ scrollVisibleRealtimeTextareas();
+ realtimeAutoScrollFrame = null;
+ });
+ });
+ };
+ watch(fullRealtimeTranscription, () => {
+ scheduleRealtimeAutoScroll();
+ });
+ watch(isEditingTranscription, () => {
+ scheduleRealtimeAutoScroll(true);
+ });
+ watch(isyjDialogVisible, (visible) => {
+ if (visible) {
+ scheduleRealtimeAutoScroll(true);
+ }
+ });
+ watch(analysisResult, async () => {
+ await nextTick(); // 等 DOM 更新完再操作
+ if (sumupResultTextarea.value) {
+ sumupResultTextarea.value.scrollTop =
+ sumupResultTextarea.value.scrollHeight;
+ }
+ });
+ watch(audioBlobURL, (newURL) => {
+ if (newURL) {
+ nextTick(() => {
+ const recordingAudio = document.querySelector(
+ 'audio[src*="blob:"]'
+ );
+
+ if (recordingAudio) {
+ const fixDuration = () => {
+ if (
+ !isFinite(recordingAudio.duration) ||
+ recordingAudio.duration === 0
+ ) {
+ const originalTime = recordingAudio.currentTime;
+ // recordingAudio.currentTime = 1e101;
+ recordingAudio.currentTime = originalTime + 0.01
+
+ recordingAudio.addEventListener(
+ "timeupdate",
+ function resetTime() {
+ recordingAudio.currentTime = originalTime;
+ recordingAudio.removeEventListener(
+ "timeupdate",
+ resetTime
+ );
+ },
+ { once: true }
+ );
+ }
+ };
+
+ // Multiple events to ensure we catch the duration
+ recordingAudio.addEventListener("loadedmetadata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("loadeddata", fixDuration, {
+ once: true,
+ });
+ recordingAudio.addEventListener("canplay", fixDuration, {
+ once: true,
+ });
+
+ // Also try after a delay
+ if (recordingAudio.readyState >= 1) {
+ setTimeout(fixDuration, 100);
+ }
+ }
+ });
+ }
+ });
+
+ watch(playerVolume, (newVolume) => {
+ const audioElements = document.querySelectorAll("audio");
+ audioElements.forEach((audio) => {
+ if (audio.volume !== newVolume) {
+ audio.volume = newVolume;
+ }
+ });
+ });
+
+ // Watch for search query changes
+ watch(searchQuery, (newQuery) => {
+ debouncedSearch(newQuery);
+ });
+
+ // Auto-apply filters when they change (except text query which is debounced)
+ watch(
+ filterTags,
+ () => {
+ applyAdvancedFilters();
+ },
+ { deep: true }
+ );
+
+ watch(filterDatePreset, () => {
+ applyAdvancedFilters();
+ });
+
+ watch(
+ filterDateRange,
+ () => {
+ applyAdvancedFilters();
+ },
+ { deep: true }
+ );
+
+ // Debounce text query changes
+ watch(filterTextQuery, (newValue) => {
+ clearTimeout(searchDebounceTimer.value);
+ searchDebounceTimer.value = setTimeout(() => {
+ applyAdvancedFilters();
+ }, 300);
+ });
+
+ // --- Configuration Loading ---
+ const loadConfiguration = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/config");
+ if (response.ok) {
+ const config = await response.json();
+ maxFileSizeMB.value = config.max_file_size_mb || 250;
+ chunkingEnabled.value =
+ config.chunking_enabled !== undefined
+ ? config.chunking_enabled
+ : true;
+ chunkingMode.value = config.chunking_mode || "size";
+ chunkingLimit.value = config.chunking_limit || 20;
+ chunkingLimitDisplay.value =
+ config.chunking_limit_display || "20MB";
+ recordingDisclaimer.value = config.recording_disclaimer || "";
+ console.log(
+ `Loaded configuration: max size ${
+ maxFileSizeMB.value
+ }MB, chunking ${
+ chunkingEnabled.value ? "enabled" : "disabled"
+ } (${chunkingLimitDisplay.value})`
+ );
+ } else {
+ console.warn("Failed to load configuration, using default values");
+ }
+ } catch (error) {
+ console.error("Error loading configuration:", error);
+ // Keep default values on error
+ }
+ };
+
+ const initializeSummaryMarkdownEditor = () => {
+ if (!summaryMarkdownEditor.value) return;
+
+ try {
+ summaryMarkdownEditorInstance.value = new EasyMDE({
+ element: summaryMarkdownEditor.value,
+ spellChecker: false,
+ autofocus: true,
+ placeholder: "Enter summary in Markdown format...",
+ initialValue: selectedRecording.value?.summary || "",
+ status: false,
+ toolbar: [
+ "bold",
+ "italic",
+ "heading",
+ "|",
+ "quote",
+ "unordered-list",
+ "ordered-list",
+ "|",
+ "link",
+ "image",
+ "|",
+ "preview",
+ "side-by-side",
+ "fullscreen",
+ "|",
+ "guide",
+ ],
+ previewClass: ["editor-preview", "notes-preview"],
+ theme: isDarkMode.value ? "dark" : "light",
+ });
+
+ // Add auto-save functionality
+ summaryMarkdownEditorInstance.value.codemirror.on("change", () => {
+ if (autoSaveTimer.value) {
+ clearTimeout(autoSaveTimer.value);
+ }
+ autoSaveTimer.value = setTimeout(() => {
+ autoSaveSummary();
+ }, autoSaveDelay);
+ });
+ } catch (error) {
+ console.error("Failed to initialize summary markdown editor:", error);
+ editingSummary.value = true;
+ }
+ };
+
+ const pollInboxRecordings = async () => {
+ try {
+ const response = await fetch("/tool/speakr/api/inbox_recordings");
+ if (!response.ok) {
+ // Silently fail, as this is a background task
+ return;
+ }
+ const inboxRecordings = await response.json();
+
+ if (inboxRecordings && inboxRecordings.length > 0) {
+ inboxRecordings.forEach((recording) => {
+ // Add to main recordings list if it's not there already
+ const existingRecording = recordings.value.find(
+ (r) => r.id === recording.id
+ );
+ if (!existingRecording) {
+ recordings.value.unshift(recording);
+ totalRecordings.value++; // Update total count
+ }
+
+ // Check if this recording is already in the upload queue or currently being processed
+ const existingItem = uploadQueue.value.find(
+ (item) => item.recordingId === recording.id
+ );
+ const isCurrentlyProcessing =
+ currentlyProcessingFile.value &&
+ currentlyProcessingFile.value.recordingId === recording.id;
+
+ // Don't add if it's already in queue or being processed
+ if (existingItem || isCurrentlyProcessing) {
+ // Update status if the existing item status has changed
+ if (existingItem && existingItem.status !== recording.status) {
+ console.log(
+ `Updating status for existing recording ${recording.original_filename}: ${existingItem.status} -> ${recording.status}`
+ );
+ }
+ return;
+ }
+
+ // Only add recordings that are still processing (not completed)
+ if (recording.status === "COMPLETED") {
+ console.log(
+ `Skipping completed inbox recording: ${recording.original_filename}`
+ );
+ return;
+ }
+
+ console.log(
+ `Found new inbox recording: ${recording.original_filename} (status: ${recording.status})`
+ );
+
+ // Don't add recordings that are already being handled by main processing
+ if (
+ recording.status === "SUMMARIZING" &&
+ isProcessingActive.value
+ ) {
+ console.log(
+ `Skipping ${recording.original_filename} - already in summarization phase of main processing`
+ );
+ return;
+ }
+
+ // Add it to the queue to be displayed in the progress modal
+ const inboxItem = {
+ file: {
+ name:
+ recording.original_filename || `Recording ${recording.id}`,
+ size: recording.file_size || 0,
+ },
+ status: "pending", // It's already being processed on backend
+ recordingId: recording.id,
+ clientId: `inbox-${recording.id}-${Date.now()}`,
+ error: null,
+ isReprocessing: true, // Use reprocessing logic to poll for status
+ reprocessType: "transcription",
+ };
+ uploadQueue.value.unshift(inboxItem);
+
+ // If nothing is currently being processed, make this the active item for the main progress bar
+ if (!isProcessingActive.value && !currentlyProcessingFile.value) {
+ currentlyProcessingFile.value = inboxItem;
+ }
+
+ // Show progress modal if it's hidden
+ progressPopupMinimized.value = false;
+ progressPopupClosed.value = false;
+
+ // Start polling its status
+ startReprocessingPoll(recording.id);
+ });
+ }
+ } catch (error) {
+ console.error("Error polling for inbox recordings:", error);
+ }
+ };
+
+ // --- Audio Format Detection ---
+ const detectSupportedAudioFormats = () => {
+ const formats = [
+ "audio/webm;codecs=opus",
+ "audio/webm;codecs=vp9",
+ "audio/webm",
+ "audio/mp4;codecs=mp4a.40.2",
+ "audio/mp4",
+ "audio/ogg;codecs=opus",
+ "audio/wav",
+ ];
+
+ const supportedFormats = formats.filter((format) =>
+ MediaRecorder.isTypeSupported(format)
+ );
+ console.log("Supported audio recording formats:", supportedFormats);
+
+ if (supportedFormats.length === 0) {
+ console.warn(
+ "No optimized audio formats supported, will use browser default"
+ );
+ }
+
+ return supportedFormats;
+ };
+
+ // --- System Audio Detection ---
+ const detectSystemAudioCapabilities = async () => {
+ systemAudioSupported.value = false;
+ canRecordSystemAudio.value = false;
+ systemAudioError.value = "";
+
+ // Check if getDisplayMedia is available
+ if (
+ !navigator.mediaDevices ||
+ !navigator.mediaDevices.getDisplayMedia
+ ) {
+ systemAudioError.value = "getDisplayMedia API not supported";
+ return;
+ }
+
+ try {
+ // Test if we can request system audio (this will prompt user)
+ // We'll do this only when user actually tries to record
+ systemAudioSupported.value = true;
+ canRecordSystemAudio.value = true;
+ systemAudioError.value = "";
+ } catch (error) {
+ systemAudioError.value = error.message;
+ console.warn("System audio detection failed:", error);
+ }
+ };
+
+ const detectBrowser = () => {
+ const userAgent = navigator.userAgent;
+ if (userAgent.includes("Firefox")) {
+ browser.value = "firefox";
+ } else if (userAgent.includes("Chrome")) {
+ browser.value = "chrome";
+ } else if (userAgent.includes("Safari")) {
+ browser.value = "safari";
+ } else if (
+ userAgent.includes("MSIE") ||
+ userAgent.includes("Trident/")
+ ) {
+ browser.value = "ie";
+ } else {
+ browser.value = "unknown";
+ }
+ };
+
+ // --- Lifecycle ---
+ onMounted(async () => {
+ const loader = document.getElementById("loader");
+ const app = document.getElementById("app");
+ if (loader) {
+ loader.style.opacity = "0";
+ setTimeout(() => {
+ loader.style.display = "none";
+ }, 500);
+ }
+ if (app) {
+ app.style.opacity = "1";
+ }
+ const appDiv = document.getElementById("app");
+ if (appDiv) {
+ const asrFlag = appDiv.dataset.useAsrEndpoint;
+ useAsrEndpoint.value = asrFlag === "True" || asrFlag === "true";
+ currentUserName.value = appDiv.dataset.currentUserName || "";
+ }
+
+ // Load configuration first
+ await loadConfiguration();
+
+ // Detect system audio capabilities
+ await detectSystemAudioCapabilities();
+
+ detectBrowser();
+
+ // Check if language was changed in account settings
+ if (localStorage.getItem("ui_language_changed") === "true") {
+ localStorage.removeItem("ui_language_changed");
+ // Force reload to apply new language
+ window.location.reload();
+ return;
+ }
+
+ // i18n is already initialized before Vue app creation
+ if (window.i18n) {
+ currentLanguage.value = window.i18n.getLocale();
+ availableLanguages.value = window.i18n.getAvailableLocales();
+ const lang = availableLanguages.value.find(
+ (l) => l.code === currentLanguage.value
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+
+ // Listen for locale changes
+ window.addEventListener("localeChanged", (event) => {
+ currentLanguage.value = event.detail.locale;
+ const lang = availableLanguages.value.find(
+ (l) => l.code === currentLanguage.value
+ );
+ currentLanguageName.value = lang ? lang.nativeName : "English";
+ });
+ }
+
+ loadRecordings();
+ loadTags();
+ loadDrafts(); // 加载草稿列表
+ initializeDarkMode();
+ initializeColorScheme();
+
+ const savedVolume = localStorage.getItem("playerVolume");
+ if (savedVolume !== null) {
+ playerVolume.value = parseFloat(savedVolume);
+ }
+
+ const savedTranscriptionViewMode = localStorage.getItem(
+ "transcriptionViewMode"
+ );
+ if (savedTranscriptionViewMode) {
+ transcriptionViewMode.value = savedTranscriptionViewMode;
+ }
+
+ // Load saved column widths
+ const savedLeftWidth = localStorage.getItem("transcriptColumnWidth");
+ const savedRightWidth = localStorage.getItem("summaryColumnWidth");
+ if (savedLeftWidth && savedRightWidth) {
+ leftColumnWidth.value = parseFloat(savedLeftWidth);
+ rightColumnWidth.value = parseFloat(savedRightWidth);
+ }
+
+ const updateMobileStatus = () => {
+ windowWidth.value = window.innerWidth;
+ };
+
+ window.addEventListener("resize", updateMobileStatus);
+ updateMobileStatus();
+
+ // Start polling for inbox recordings
+ setInterval(pollInboxRecordings, 10000);
+
+ const handleEsc = (e) => {
+ if (e.key === "Escape") {
+ if (showColorSchemeModal.value) {
+ closeColorSchemeModal();
+ }
+ if (showEditModal.value) {
+ cancelEdit();
+ }
+ if (showDeleteModal.value) {
+ cancelDelete();
+ }
+ if (showSortOptions.value) {
+ showSortOptions.value = false;
+ }
+ }
+ };
+ document.addEventListener("keydown", handleEsc);
+
+ // Click away handler for dropdowns
+ const handleClickAway = (e) => {
+ // Close user menu if clicking outside
+ if (isUserMenuOpen.value) {
+ // Check if we clicked within the user menu area (button or dropdown)
+ const userMenuButton = e.target.closest(
+ 'button[class*="flex items-center gap"]'
+ );
+ const userMenuDropdown = e.target.closest(
+ 'div[class*="absolute right-0"]'
+ );
+
+ // Check if the click was on the user menu button specifically
+ const isUserMenuButtonClick =
+ userMenuButton &&
+ userMenuButton.querySelector("i.fa-user-circle");
+
+ // If we didn't click on the user menu button or dropdown, close it
+ if (!isUserMenuButtonClick && !userMenuDropdown) {
+ isUserMenuOpen.value = false;
+ }
+ }
+
+ // Close speaker suggestions if clicking outside in the modal
+ if (showSpeakerModal.value) {
+ // If not clicking on an input field or suggestion dropdown
+ const clickedOnInput = e.target.closest('input[type="text"]');
+ const clickedOnSuggestion = e.target.closest(
+ '[class*="absolute z-10"]'
+ );
+
+ if (!clickedOnInput && !clickedOnSuggestion) {
+ // Close all suggestion dropdowns
+ Object.keys(speakerSuggestions.value).forEach((speakerId) => {
+ speakerSuggestions.value[speakerId] = [];
+ });
+ }
+ }
+ };
+ document.addEventListener("click", handleClickAway);
+ });
+
+ // --- 转录文本编辑功能 ---
+ // 用户手动编辑后,以编辑结果为准,旧的实时结果不再回灌。
+ let editModeOriginalText = "";
+
+ function enterEditMode() {
+ editModeOriginalText = fullRealtimeTranscription.value || "";
+ editableText.value = editModeOriginalText;
+ editModeBufferStartIndex = segmentList.length;
+ const barrierTimestampMs = getLatestRealtimeTimestampRef
+ ? getLatestRealtimeTimestampRef()
+ : null;
+ startNewRealtimeEpoch({
+ barrierTimestampMs,
+ clearEditCarryover: false,
+ });
+ realtimeEditCarryover = buildRealtimeEditCarryoverRef
+ ? buildRealtimeEditCarryoverRef(editModeOriginalText)
+ : null;
+ isEditingTranscription.value = true;
+ }
+
+ function exitEditMode() {
+ const nextText = editableText.value ?? "";
+
+ isEditingTranscription.value = false;
+
+ pausedTranscription.value = nextText;
+ asrResult.value = nextText;
+ asrResultOnline.value = "";
+ asrResultOffline.value = "";
+ segmentRenderStartIndex = editModeBufferStartIndex;
+ editModeOriginalText = nextText;
+
+ if (renderFullTextRef) {
+ renderFullTextRef();
+ }
+ }
+
+ return {
+ // Core State
+ currentView,
+ dragover,
+ recordings,
+ selectedRecording,
+ selectedTab,
+ searchQuery,
+ isLoadingRecordings,
+ globalError,
+ maxFileSizeMB,
+ chunkingEnabled,
+ chunkingMode,
+ chunkingLimit,
+ chunkingLimitDisplay,
+ sortBy,
+ showAdvancedFilters,
+ filterTags,
+ filterDateRange,
+ filterDatePreset,
+ filterTextQuery,
+
+ // Pagination State
+ currentPage,
+ perPage,
+ totalRecordings,
+ totalPages,
+ hasNextPage,
+ hasPrevPage,
+ isLoadingMore,
+
+ // UI State
+ browser,
+ isSidebarCollapsed,
+ searchTipsExpanded,
+ isUserMenuOpen,
+ isDarkMode,
+ currentColorScheme,
+ showColorSchemeModal,
+ windowWidth,
+ isMobileScreen,
+ mobileTab,
+ isMetadataExpanded,
+
+ // i18n State
+ currentLanguage,
+ currentLanguageName,
+ availableLanguages,
+ showLanguageMenu,
+
+ // Upload State
+ uploadQueue,
+ currentlyProcessingFile,
+ processingProgress,
+ processingMessage,
+ isProcessingActive,
+ progressPopupMinimized,
+ progressPopupClosed,
+ totalInQueue,
+ completedInQueue,
+ finishedFilesInQueue,
+ clearCompletedUploads,
+
+ // Audio Recording
+ editingDraftId,
+ editingDraftName,
+
+ isRecording,
+ canRecordAudio,
+ canRecordSystemAudio,
+ systemAudioSupported,
+ systemAudioError,
+ audioBlobURL,
+ recordingTime,
+ recordingNotes,
+ visualizer,
+ micVisualizer,
+ systemVisualizer,
+ recordingMode,
+ recordingDisclaimer,
+ showRecordingDisclaimerModal,
+ acceptRecordingDisclaimer,
+ cancelRecordingDisclaimer,
+ showAdvancedOptions,
+ uploadLanguage,
+ uploadMinSpeakers,
+ uploadMaxSpeakers,
+ asrLanguage,
+ asrMinSpeakers,
+ asrMaxSpeakers,
+ availableTags,
+ selectedTagIds,
+ selectedTags,
+ uploadTagSearchFilter,
+ filteredAvailableTagsForUpload,
+ onTagSelected,
+ addTagToSelection,
+ removeTagFromSelection,
+ showSystemAudioHelp,
+
+ // Draft/Pause Recording
+ isPaused,
+ isStoppingRecording,
+ currentDraftId,
+ showRecordingControls,
+ pausedDuration,
+ pausedTranscription,
+ draftRecordings,
+ draftsExpanded,
+ pauseRecording,
+ resumeRecording,
+ loadDrafts,
+ continueDraftRecording,
+ deleteDraft,
+
+ // Modal State
+ showEditModal,
+ showDeleteModal,
+ showResetModal,
+ editingRecording,
+ recordingToDelete,
+ showEditTagsModal,
+ selectedNewTagId,
+ tagSearchFilter,
+ filteredAvailableTagsForModal,
+ editRecordingTags,
+ closeEditTagsModal,
+ addTagToRecording,
+ removeTagFromRecording,
+ getRecordingTags,
+ getAvailableTagsForRecording,
+ filterByTag,
+ clearTagFilter,
+ applyAdvancedFilters,
+ clearAllFilters,
+ showTextEditorModal,
+ showAsrEditorModal,
+ editingTranscriptionContent,
+ editingSegments,
+ availableSpeakers,
+
+ // Inline Editing
+ editingParticipants,
+ editingMeetingDate,
+ editingSummary,
+ editingNotes,
+
+ // Markdown Editor
+ notesMarkdownEditor,
+ markdownEditorInstance,
+ summaryMarkdownEditor,
+ summaryMarkdownEditorInstance,
+ recordingNotesEditor,
+
+ // Transcription
+ transcriptionViewMode,
+ legendExpanded,
+ highlightedSpeaker,
+ processedTranscription,
+
+ // Chat
+ showChat,
+ isChatMaximized,
+ toggleChatMaximize,
+ chatMessages,
+ chatInput,
+ isChatLoading,
+ chatMessagesRef,
+
+ // Audio Player
+ playerVolume,
+
+ // Column Resizing
+ leftColumnWidth,
+ rightColumnWidth,
+ isResizing,
+
+ // App Configuration
+ useAsrEndpoint,
+ currentUserName,
+
+ // Computed
+ filteredRecordings,
+ groupedRecordings,
+ activeRecordingMetadata,
+ datePresetOptions,
+ languageOptions,
+
+ // Color Schemes
+ colorSchemes,
+ asrResult,
+ asrResultOnline,
+ asrResultOffline,
+ fullRealtimeTranscription,
+ asrTextarea,
+ asrResultTextarea,
+ asrResultTextareaOnline,
+ analysisResult,
+ sumupResultTextarea,
+ isDialogVisible,
+ isyjDialogVisible,
+ baseInfo,
+ isModalOpen,
+ urlinfo,
+ urlmsg,
+ zjloding,
+ // Methods
+ openmodel,
+ openzjmodel,
+ closeModal,
+ setGlobalError,
+ formatFileSize,
+ formatDisplayDate,
+ formatStatus,
+ getStatusClass,
+ formatTime,
+ t,
+ tc,
+ changeLanguage,
+ toggleDarkMode,
+ applyColorScheme,
+ initializeColorScheme,
+ openColorSchemeModal,
+ closeColorSchemeModal,
+ selectColorScheme,
+ resetColorScheme,
+ toggleSidebar,
+ switchToUploadView,
+ selectRecording,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ handleFileSelect,
+ addFilesToQueue,
+ startRecording,
+ stopRecording,
+ uploadRecordedAudio,
+ discardRecording,
+ downloadRecordedAudio,
+ loadRecordings,
+ loadMoreRecordings,
+ performSearch,
+ debouncedSearch,
+ saveMetadata,
+ editRecording,
+ cancelEdit,
+ saveEdit,
+ confirmDelete,
+ cancelDelete,
+ deleteRecording,
+ toggleEditParticipants,
+ toggleEditMeetingDate,
+ toggleEditSummary,
+ cancelEditSummary,
+ saveEditSummary,
+ toggleEditNotes,
+ cancelEditNotes,
+ saveEditNotes,
+ initializeMarkdownEditor,
+ saveInlineEdit,
+ clickToEditNotes,
+ clickToEditSummary,
+ autoSaveNotes,
+ autoSaveSummary,
+ sendChatMessage,
+ isChatScrolledToBottom,
+ scrollChatToBottom,
+ startColumnResize,
+ handleChatKeydown,
+ seekAudio,
+ seekAudioFromEvent,
+ onPlayerVolumeChange,
+ showToast,
+ copyMessage,
+ copyTranscription,
+ copySummary,
+ copyNotes,
+ downloadSummary,
+ downloadTranscript,
+ downloadNotes,
+ downloadChat,
+ clearChat,
+ downloadEventICS,
+ downloadICS,
+ formatEventDateTime,
+ toggleInbox,
+ toggleHighlight,
+ toggleTranscriptionViewMode,
+ reprocessTranscription,
+ reprocessSummary,
+ generateSummary,
+ resetRecordingStatus,
+ confirmReset,
+ cancelReset,
+ executeReset,
+ openTranscriptionEditor,
+ openTextEditorModal,
+ closeTextEditorModal,
+ saveTranscription,
+ openAsrEditorModal,
+ closeAsrEditorModal,
+ saveAsrTranscription,
+ adjustTime,
+ filterSpeakers,
+ openSpeakerSuggestions,
+ closeSpeakerSuggestions,
+ selectSpeaker,
+ addSegment,
+ removeSegment,
+ showReprocessModal,
+ reprocessType,
+ reprocessRecording,
+ cancelReprocess,
+ executeReprocess,
+ asrReprocessOptions,
+ showSpeakerModal,
+ showShareModal,
+ recordingToShare,
+ shareOptions,
+ generatedShareLink,
+ existingShareDetected,
+ userShares,
+ isLoadingShares,
+ showSharesListModal,
+ shareToDelete,
+ showShareDeleteModal,
+ confirmDeleteShare,
+ cancelDeleteShare,
+ speakerMap,
+ speakerDisplayMap,
+ modalSpeakers,
+ regenerateSummaryAfterSpeakerUpdate,
+ identifiedSpeakers,
+ identifiedSpeakersInOrder,
+ hasSpeakerNames,
+ openSpeakerModal,
+ closeSpeakerModal,
+ saveSpeakerNames,
+ highlightedTranscript,
+ highlightedSpeaker,
+ highlightSpeakerInTranscript,
+ focusSpeaker,
+ blurSpeaker,
+ clearSpeakerHighlight,
+ currentSpeakerGroupIndex,
+ speakerGroups,
+ navigateToNextSpeakerGroup,
+ navigateToPrevSpeakerGroup,
+ speakerSuggestions,
+ loadingSuggestions,
+ activeSpeakerInput,
+ searchSpeakers,
+ selectSpeakerSuggestion,
+ closeSpeakerSuggestionsOnClick,
+ autoIdentifySpeakers,
+ isAutoIdentifying,
+ formatDuration,
+ openShareModal,
+ closeShareModal,
+ createShare,
+ openSharesList,
+ closeSharesList,
+ updateShare,
+ deleteShare,
+ copyShareLink,
+ startDraftRename,
+ saveDraftRename,
+ editingDraftId,
+ editingDraftName,
+ draftNameInput,
+ draftRecordings,
+ deleteDraft,
+ continueDraftRecording,
+ loadDrafts,
+
+ // Recording Size Monitoring
+ estimatedFileSize,
+ fileSizeWarningShown,
+ recordingQuality,
+ actualBitrate,
+ maxRecordingMB,
+ sizeCheckInterval,
+ updateFileSizeEstimate,
+ startSizeMonitoring,
+ stopSizeMonitoring,
+ sumupResult,
+ lastTimer,
+ promptVisible,
+ promptList,
+ openPromptModal,
+ closePromptModal,
+ confirmPromptSelection,
+ selectedPromptId,
+ recommendPromptId,
+ recommendPromptName,
+ isMarkdown,
+ renderedMarkdown,
+ recommendPromptReason,
+ showSpeaker,
+ isEditingTranscription,
+ editableText,
+ enterEditMode,
+ exitEditMode,
+ };
+ },
+ delimiters: ["${", "}"],
+ });
+
+ // Add t function as a global property BEFORE mounting so it's available in templates immediately
+ app.config.globalProperties.t = safeT;
+
+ // Also add tc for pluralization
+ app.config.globalProperties.tc = (key, count, params = {}) => {
+ if (!window.i18n || !window.i18n.tc) {
+ return key;
+ }
+ return window.i18n.tc(key, count, params);
+ };
+
+ // Provide t and tc to the app
+ app.provide("t", safeT);
+ app.provide("tc", (key, count, params = {}) => {
+ if (!window.i18n || !window.i18n.tc) {
+ return key;
+ }
+ return window.i18n.tc(key, count, params);
+ });
+
+ // Mount the app
+ app.mount("#app");
+
+ // Hide loading overlay after Vue is mounted and ready
+ Vue.nextTick(() => {
+ const overlay = document.querySelector(".app-loading-overlay");
+ if (overlay) {
+ // Small delay to ensure everything is rendered
+ setTimeout(() => {
+ overlay.classList.add("fade-out");
+ setTimeout(() => {
+ overlay.remove();
+ document.body.classList.remove("app-loading");
+ }, 300);
+ }, 100);
+ } else {
+ document.body.classList.remove("app-loading");
+ }
+ });
+});
diff --git a/speakr/static/js/csrf-refresh copy.js b/speakr/static/js/csrf-refresh copy.js
new file mode 100644
index 0000000..0687dd8
--- /dev/null
+++ b/speakr/static/js/csrf-refresh copy.js
@@ -0,0 +1,189 @@
+// CSRF Token Management with Auto-Refresh
+//------------------- CSRF 管理器核心逻辑 start------------------
+class CSRFManager {
+ constructor() {
+ this.token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
+ this.refreshPromise = null;
+ this.setupFetchInterceptor();
+ }
+
+ async refreshToken() {
+ // Prevent multiple simultaneous refresh requests
+ if (this.refreshPromise) {
+ return this.refreshPromise;
+ }
+
+ this.refreshPromise = this.performTokenRefresh();
+ try {
+ const newToken = await this.refreshPromise;
+ return newToken;
+ } finally {
+ this.refreshPromise = null;
+ }
+ }
+
+ async performTokenRefresh() {
+ try {
+ console.log('Refreshing CSRF token...');
+
+ // Use the original fetch to avoid recursion
+ const originalFetch = window.originalFetch || fetch;
+ const response = await originalFetch('/api/csrf-token', {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to refresh CSRF token: ${response.status} ${response.statusText}`);
+ }
+
+ const contentType = response.headers.get('content-type');
+ if (!contentType || !contentType.includes('application/json')) {
+ const text = await response.text();
+ throw new Error(`Expected JSON response but got ${contentType}. Response: ${text.substring(0, 200)}`);
+ }
+
+ const data = await response.json();
+ if (data.csrf_token) {
+ this.token = data.csrf_token;
+ // Update the meta tag for any other code that might read it
+ const metaTag = document.querySelector('meta[name="csrf-token"]');
+ if (metaTag) {
+ metaTag.setAttribute('content', this.token);
+ }
+
+ // Update Vue.js reactive token if available
+ if (window.app && window.app.csrfToken !== undefined) {
+ window.app.csrfToken = this.token;
+ }
+
+ console.log('CSRF token refreshed successfully');
+ return this.token;
+ } else {
+ throw new Error('No CSRF token in response');
+ }
+ } catch (error) {
+ console.error('Failed to refresh CSRF token:', error);
+ throw error;
+ }
+ }
+
+ setupFetchInterceptor() {
+ // Store original fetch if not already stored
+ if (!window.originalFetch) {
+ window.originalFetch = window.fetch;
+ }
+
+ const originalFetch = window.originalFetch;
+ const self = this;
+
+ window.fetch = async function(url, options = {}) {
+ // Skip CSRF token for the token refresh endpoint to avoid recursion
+ if (url.includes('/api/csrf-token')) {
+ return originalFetch(url, options);
+ }
+
+ // Add CSRF token to headers for API requests
+ const newOptions = { ...options };
+ if (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
+ url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers')) {
+
+ newOptions.headers = {
+ 'X-CSRFToken': self.token,
+ ...newOptions.headers
+ };
+ }
+
+ // Make the request
+ let response = await originalFetch(url, newOptions);
+
+ // Check for CSRF token expiration
+ if ((response.status === 400 || response.status === 403) &&
+ (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
+ url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers'))) {
+
+ try {
+ // Try to parse as JSON first
+ const responseClone = response.clone();
+ let isCSRFError = false;
+
+ try {
+ const errorData = await responseClone.json();
+ const errorMessage = errorData.error || '';
+ isCSRFError = errorMessage.toLowerCase().includes('csrf') ||
+ errorMessage.toLowerCase().includes('token');
+ } catch (jsonError) {
+ // If JSON parsing fails, check if it's an HTML error page
+ const textResponse = await response.clone().text();
+ isCSRFError = textResponse.toLowerCase().includes('csrf') ||
+ textResponse.toLowerCase().includes('token') ||
+ textResponse.includes(' {
+ window.csrfManager = new CSRFManager();
+
+ // Set up periodic token refresh every 45 minutes (before 1-hour expiration)
+ setInterval(() => {
+ if (window.csrfManager) {
+ console.log('Performing periodic CSRF token refresh...');
+ window.csrfManager.manualRefresh();
+ }
+ }, 45 * 60 * 1000); // 45 minutes
+});
+//--------------------CSRF 管理器启动与定时刷新 end------------------
diff --git a/speakr/static/js/csrf-refresh.js b/speakr/static/js/csrf-refresh.js
new file mode 100644
index 0000000..0687dd8
--- /dev/null
+++ b/speakr/static/js/csrf-refresh.js
@@ -0,0 +1,189 @@
+// CSRF Token Management with Auto-Refresh
+//------------------- CSRF 管理器核心逻辑 start------------------
+class CSRFManager {
+ constructor() {
+ this.token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
+ this.refreshPromise = null;
+ this.setupFetchInterceptor();
+ }
+
+ async refreshToken() {
+ // Prevent multiple simultaneous refresh requests
+ if (this.refreshPromise) {
+ return this.refreshPromise;
+ }
+
+ this.refreshPromise = this.performTokenRefresh();
+ try {
+ const newToken = await this.refreshPromise;
+ return newToken;
+ } finally {
+ this.refreshPromise = null;
+ }
+ }
+
+ async performTokenRefresh() {
+ try {
+ console.log('Refreshing CSRF token...');
+
+ // Use the original fetch to avoid recursion
+ const originalFetch = window.originalFetch || fetch;
+ const response = await originalFetch('/api/csrf-token', {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to refresh CSRF token: ${response.status} ${response.statusText}`);
+ }
+
+ const contentType = response.headers.get('content-type');
+ if (!contentType || !contentType.includes('application/json')) {
+ const text = await response.text();
+ throw new Error(`Expected JSON response but got ${contentType}. Response: ${text.substring(0, 200)}`);
+ }
+
+ const data = await response.json();
+ if (data.csrf_token) {
+ this.token = data.csrf_token;
+ // Update the meta tag for any other code that might read it
+ const metaTag = document.querySelector('meta[name="csrf-token"]');
+ if (metaTag) {
+ metaTag.setAttribute('content', this.token);
+ }
+
+ // Update Vue.js reactive token if available
+ if (window.app && window.app.csrfToken !== undefined) {
+ window.app.csrfToken = this.token;
+ }
+
+ console.log('CSRF token refreshed successfully');
+ return this.token;
+ } else {
+ throw new Error('No CSRF token in response');
+ }
+ } catch (error) {
+ console.error('Failed to refresh CSRF token:', error);
+ throw error;
+ }
+ }
+
+ setupFetchInterceptor() {
+ // Store original fetch if not already stored
+ if (!window.originalFetch) {
+ window.originalFetch = window.fetch;
+ }
+
+ const originalFetch = window.originalFetch;
+ const self = this;
+
+ window.fetch = async function(url, options = {}) {
+ // Skip CSRF token for the token refresh endpoint to avoid recursion
+ if (url.includes('/api/csrf-token')) {
+ return originalFetch(url, options);
+ }
+
+ // Add CSRF token to headers for API requests
+ const newOptions = { ...options };
+ if (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
+ url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers')) {
+
+ newOptions.headers = {
+ 'X-CSRFToken': self.token,
+ ...newOptions.headers
+ };
+ }
+
+ // Make the request
+ let response = await originalFetch(url, newOptions);
+
+ // Check for CSRF token expiration
+ if ((response.status === 400 || response.status === 403) &&
+ (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
+ url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers'))) {
+
+ try {
+ // Try to parse as JSON first
+ const responseClone = response.clone();
+ let isCSRFError = false;
+
+ try {
+ const errorData = await responseClone.json();
+ const errorMessage = errorData.error || '';
+ isCSRFError = errorMessage.toLowerCase().includes('csrf') ||
+ errorMessage.toLowerCase().includes('token');
+ } catch (jsonError) {
+ // If JSON parsing fails, check if it's an HTML error page
+ const textResponse = await response.clone().text();
+ isCSRFError = textResponse.toLowerCase().includes('csrf') ||
+ textResponse.toLowerCase().includes('token') ||
+ textResponse.includes(' {
+ window.csrfManager = new CSRFManager();
+
+ // Set up periodic token refresh every 45 minutes (before 1-hour expiration)
+ setInterval(() => {
+ if (window.csrfManager) {
+ console.log('Performing periodic CSRF token refresh...');
+ window.csrfManager.manualRefresh();
+ }
+ }, 45 * 60 * 1000); // 45 minutes
+});
+//--------------------CSRF 管理器启动与定时刷新 end------------------
diff --git a/speakr/static/js/i18n.js b/speakr/static/js/i18n.js
new file mode 100644
index 0000000..c3c7952
--- /dev/null
+++ b/speakr/static/js/i18n.js
@@ -0,0 +1,296 @@
+/**
+ * Lightweight i18n (internationalization) system for Speakr
+ * Handles loading and managing translations with template variable support
+ */
+
+class I18n {
+ constructor() {
+ this.translations = {};
+ this.currentLocale = 'en';
+ this.fallbackLocale = 'en';
+ this.loadedLocales = new Set();
+ }
+
+ /**
+ * Initialize i18n with default locale
+ * @param {string} locale - Initial locale code (e.g., 'en', 'es', 'fr', 'zh')
+ */
+ async init(locale = 'en') {
+ // Get saved locale from localStorage or use browser language
+ const savedLocale = localStorage.getItem('preferredLanguage');
+ const browserLocale = navigator.language.split('-')[0];
+
+ this.currentLocale = savedLocale || locale || browserLocale || 'en';
+
+ // Load the initial locale
+ await this.loadLocale(this.currentLocale);
+
+ // Load fallback locale if different
+ if (this.currentLocale !== this.fallbackLocale) {
+ await this.loadLocale(this.fallbackLocale);
+ }
+ }
+
+ /**
+ * Load translations for a specific locale
+ * @param {string} locale - Locale code to load
+ */
+ async loadLocale(locale) {
+ if (this.loadedLocales.has(locale)) {
+ return; // Already loaded
+ }
+
+ try {
+ const response = await fetch(`/tool/speakr/static/locales/${locale}.json`);
+ if (!response.ok) {
+ throw new Error(`Failed to load locale: ${locale}`);
+ }
+
+ const translations = await response.json();
+ this.translations[locale] = translations;
+ this.loadedLocales.add(locale);
+
+ console.log(`Loaded locale: ${locale}`);
+ } catch (error) {
+ console.error(`Error loading locale ${locale}:`, error);
+
+ // If failed to load requested locale and it's not the fallback, try fallback
+ if (locale !== this.fallbackLocale) {
+ console.log(`Failed to load ${locale}, will use ${this.fallbackLocale} as fallback`);
+ // Don't change currentLocale - keep user's preference
+ // Just ensure fallback translations are available
+ await this.loadLocale(this.fallbackLocale);
+ }
+ }
+ }
+
+ /**
+ * Change the current locale
+ * @param {string} locale - New locale code
+ */
+ async setLocale(locale) {
+ await this.loadLocale(locale);
+ this.currentLocale = locale;
+ localStorage.setItem('preferredLanguage', locale);
+
+ // Dispatch custom event for locale change
+ window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } }));
+ }
+
+ /**
+ * Get the current locale
+ * @returns {string} Current locale code
+ */
+ getLocale() {
+ return this.currentLocale;
+ }
+
+ /**
+ * Get available locales
+ * @returns {Array} List of available locale codes
+ */
+ getAvailableLocales() {
+ return [
+ { code: 'en', name: 'English', nativeName: 'English' },
+ { code: 'es', name: 'Spanish', nativeName: 'Español' },
+ { code: 'fr', name: 'French', nativeName: 'Français' },
+ { code: 'zh', name: 'Chinese', nativeName: '中文' }
+ ];
+ }
+
+ /**
+ * Translate a key with optional parameters
+ * @param {string} key - Translation key (e.g., 'common.save' or 'nav.upload')
+ * @param {Object} params - Optional parameters for template replacement
+ * @param {string} locale - Optional specific locale (defaults to current)
+ * @returns {string} Translated text
+ */
+ t(key, params = {}, locale = null) {
+ const targetLocale = locale || this.currentLocale;
+
+ // Get translation from current locale or fallback
+ let translation = this.getNestedTranslation(targetLocale, key);
+
+ if (!translation && targetLocale !== this.fallbackLocale) {
+ translation = this.getNestedTranslation(this.fallbackLocale, key);
+ }
+
+ if (!translation) {
+ console.warn(`Translation not found for key: ${key}`);
+ return key; // Return the key itself as fallback
+ }
+
+ // Replace template variables
+ return this.interpolate(translation, params);
+ }
+
+ /**
+ * Get nested translation value from object
+ * @param {string} locale - Locale to search in
+ * @param {string} key - Dot-separated key path
+ * @returns {string|null} Translation value or null
+ */
+ getNestedTranslation(locale, key) {
+ if (!this.translations[locale]) {
+ return null;
+ }
+
+ const keys = key.split('.');
+ let value = this.translations[locale];
+
+ for (const k of keys) {
+ if (value && typeof value === 'object' && k in value) {
+ value = value[k];
+ } else {
+ return null;
+ }
+ }
+
+ return typeof value === 'string' ? value : null;
+ }
+
+ /**
+ * Replace template variables in translation string
+ * @param {string} text - Text with placeholders like {{variable}}
+ * @param {Object} params - Parameters to replace
+ * @returns {string} Interpolated text
+ */
+ interpolate(text, params) {
+ return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
+ return params.hasOwnProperty(key) ? params[key] : match;
+ });
+ }
+
+ /**
+ * Handle pluralization
+ * @param {string} key - Base translation key
+ * @param {number} count - Count for pluralization
+ * @param {Object} params - Additional parameters
+ * @returns {string} Translated text with proper pluralization
+ */
+ tc(key, count, params = {}) {
+ const pluralKey = count === 1 ? key : `${key}Plural`;
+ return this.t(pluralKey, { ...params, count });
+ }
+
+ /**
+ * Format date according to locale
+ * @param {Date|string} date - Date to format
+ * @param {Object} options - Intl.DateTimeFormat options
+ * @returns {string} Formatted date string
+ */
+ formatDate(date, options = {}) {
+ const d = date instanceof Date ? date : new Date(date);
+ return new Intl.DateTimeFormat(this.currentLocale, options).format(d);
+ }
+
+ /**
+ * Format number according to locale
+ * @param {number} number - Number to format
+ * @param {Object} options - Intl.NumberFormat options
+ * @returns {string} Formatted number string
+ */
+ formatNumber(number, options = {}) {
+ return new Intl.NumberFormat(this.currentLocale, options).format(number);
+ }
+
+ /**
+ * Format file size with appropriate unit
+ * @param {number} bytes - Size in bytes
+ * @returns {string} Formatted file size
+ */
+ formatFileSize(bytes) {
+ const units = ['bytes', 'kilobytes', 'megabytes', 'gigabytes'];
+ const unitValues = [1, 1024, 1048576, 1073741824];
+
+ let unitIndex = 0;
+ for (let i = unitValues.length - 1; i >= 0; i--) {
+ if (bytes >= unitValues[i]) {
+ unitIndex = i;
+ break;
+ }
+ }
+
+ const value = Math.round(bytes / unitValues[unitIndex] * 10) / 10;
+ return this.t(`fileSize.${units[unitIndex]}`, { count: value });
+ }
+
+ /**
+ * Format duration with appropriate unit
+ * @param {number} seconds - Duration in seconds
+ * @returns {string} Formatted duration
+ */
+ formatDuration(seconds) {
+ if (seconds < 60) {
+ return this.tc('duration.seconds', Math.round(seconds), { count: Math.round(seconds) });
+ } else if (seconds < 3600) {
+ const minutes = Math.round(seconds / 60);
+ return this.tc('duration.minutes', minutes, { count: minutes });
+ } else {
+ const hours = Math.round(seconds / 3600 * 10) / 10;
+ return this.tc('duration.hours', hours, { count: hours });
+ }
+ }
+
+ /**
+ * Format relative time (e.g., "2 hours ago")
+ * @param {Date|string} date - Date to format
+ * @returns {string} Formatted relative time
+ */
+ formatRelativeTime(date) {
+ const d = date instanceof Date ? date : new Date(date);
+ const now = new Date();
+ const diffSeconds = Math.floor((now - d) / 1000);
+
+ if (diffSeconds < 60) {
+ return this.t('time.justNow');
+ } else if (diffSeconds < 3600) {
+ const minutes = Math.floor(diffSeconds / 60);
+ return minutes === 1
+ ? this.t('time.minuteAgo')
+ : this.t('time.minutesAgo', { count: minutes });
+ } else if (diffSeconds < 86400) {
+ const hours = Math.floor(diffSeconds / 3600);
+ return hours === 1
+ ? this.t('time.hourAgo')
+ : this.t('time.hoursAgo', { count: hours });
+ } else if (diffSeconds < 604800) {
+ const days = Math.floor(diffSeconds / 86400);
+ return days === 1
+ ? this.t('time.dayAgo')
+ : this.t('time.daysAgo', { count: days });
+ } else if (diffSeconds < 2592000) {
+ const weeks = Math.floor(diffSeconds / 604800);
+ return weeks === 1
+ ? this.t('time.weekAgo')
+ : this.t('time.weeksAgo', { count: weeks });
+ } else if (diffSeconds < 31536000) {
+ const months = Math.floor(diffSeconds / 2592000);
+ return months === 1
+ ? this.t('time.monthAgo')
+ : this.t('time.monthsAgo', { count: months });
+ } else {
+ const years = Math.floor(diffSeconds / 31536000);
+ return years === 1
+ ? this.t('time.yearAgo')
+ : this.t('time.yearsAgo', { count: years });
+ }
+ }
+}
+
+// Create global i18n instance
+const i18n = new I18n();
+
+// Create a fallback t function immediately
+if (typeof window !== 'undefined') {
+ // Ensure window.i18n exists with at least a basic t function
+ window.i18n = i18n;
+
+ // Add a fallback t function if the class method isn't ready
+ if (!window.i18n.t) {
+ window.i18n.t = function(key, params) {
+ console.warn('i18n.t called before initialization, returning key:', key);
+ return key;
+ };
+ }
+}
\ No newline at end of file
diff --git a/speakr/static/js/loading.js b/speakr/static/js/loading.js
new file mode 100644
index 0000000..1b3a4a2
--- /dev/null
+++ b/speakr/static/js/loading.js
@@ -0,0 +1,125 @@
+/**
+ * App loading overlay management
+ * Prevents FOUC (Flash of Unstyled Content) during page initialization
+ */
+
+window.AppLoader = {
+ initialized: false,
+ readyChecks: [],
+
+ /**
+ * Initialize the loading system
+ */
+ init() {
+ if (this.initialized) return;
+ this.initialized = true;
+
+ // Add loading class to body
+ document.body.classList.add('app-loading');
+
+ // Create loading overlay if it doesn't exist
+ if (!document.querySelector('.app-loading-overlay')) {
+ this.createOverlay();
+ }
+
+ // Set up ready checks
+ this.setupReadyChecks();
+ },
+
+ /**
+ * Create the loading overlay element
+ */
+ createOverlay() {
+ const overlay = document.createElement('div');
+ overlay.className = 'app-loading-overlay';
+ overlay.innerHTML = `
+
+ `;
+ document.body.appendChild(overlay);
+ },
+
+ /**
+ * Add a ready check condition
+ */
+ addReadyCheck(checkFn) {
+ this.readyChecks.push(checkFn);
+ },
+
+ /**
+ * Setup default ready checks
+ */
+ setupReadyChecks() {
+ // Check if DOM is ready
+ this.addReadyCheck(() => document.readyState === 'complete');
+
+ // Check if styles are loaded
+ this.addReadyCheck(() => {
+ const styles = document.querySelector('link[href*="styles.css"]');
+ return !styles || styles.sheet;
+ });
+
+ // Check if theme is initialized
+ this.addReadyCheck(() => {
+ const computed = window.getComputedStyle(document.documentElement);
+ return computed.getPropertyValue('--bg-primary').trim() !== '';
+ });
+ },
+
+ /**
+ * Check if all conditions are met
+ */
+ isReady() {
+ if (this.readyChecks.length === 0) return true;
+ return this.readyChecks.every(check => {
+ try {
+ return check();
+ } catch (e) {
+ return false;
+ }
+ });
+ },
+
+ /**
+ * Hide the loading overlay
+ */
+ hide() {
+ const overlay = document.querySelector('.app-loading-overlay');
+ if (overlay) {
+ overlay.classList.add('fade-out');
+ setTimeout(() => {
+ overlay.remove();
+ document.body.classList.remove('app-loading');
+ }, 300);
+ } else {
+ document.body.classList.remove('app-loading');
+ }
+ },
+
+ /**
+ * Wait for app to be ready then hide overlay
+ */
+ async waitForReady(timeout = 5000) {
+ const startTime = Date.now();
+
+ const checkReady = () => {
+ if (this.isReady() || Date.now() - startTime > timeout) {
+ this.hide();
+ } else {
+ requestAnimationFrame(checkReady);
+ }
+ };
+
+ // Start checking after a minimum display time
+ setTimeout(checkReady, 300);
+ }
+};
+
+// Auto-initialize on script load
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => AppLoader.init());
+} else {
+ AppLoader.init();
+}
\ No newline at end of file
diff --git a/speakr/static/js/pcm.js b/speakr/static/js/pcm.js
new file mode 100644
index 0000000..8accadf
--- /dev/null
+++ b/speakr/static/js/pcm.js
@@ -0,0 +1,96 @@
+/*
+pcm编码器+编码引擎
+https://github.com/xiangyuecn/Recorder
+
+编码原理:本编码器输出的pcm格式数据其实就是Recorder中的buffers原始数据(经过了重新采样),16位时为LE小端模式(Little Endian),并未经过任何编码处理
+
+编码的代码和wav.js区别不大,pcm加上一个44字节wav头即成wav文件;所以要播放pcm就很简单了,直接转成wav文件来播放,已提供转换函数 Recorder.pcm2wav
+*/
+(function(){
+"use strict";
+
+Recorder.prototype.enc_pcm={
+ stable:true
+ ,testmsg:"pcm为未封装的原始音频数据,pcm数据文件无法直接播放;支持位数8位、16位(填在比特率里面),采样率取值无限制"
+};
+Recorder.prototype.pcm=function(res,True,False){
+ var This=this,set=This.set
+ ,size=res.length
+ ,bitRate=set.bitRate==8?8:16;
+
+ var buffer=new ArrayBuffer(size*(bitRate/8));
+ var data=new DataView(buffer);
+ var offset=0;
+
+ // 写入采样数据
+ if(bitRate==8) {
+ for(var i=0;i>8)+128;
+ data.setInt8(offset,val,true);
+ };
+ }else{
+ for (var i=0;i hardLimitSamples) {
+ const overflowSamples = sampleBuffer.length - hardLimitSamples;
+ console.warn(
+ `[${label}] Dropping stale local audio buffer: ${overflowSamples} samples`
+ );
+ sampleBuffer = sampleBuffer.slice(-hardLimitSamples);
+ }
+
+ while (sampleBuffer.length >= chunkSize) {
+ const chunk = sampleBuffer.slice(0, chunkSize);
+ const accepted = sendChunk(chunk);
+ if (!accepted) {
+ break;
+ }
+ sampleBuffer = sampleBuffer.slice(chunkSize);
+ }
+
+ if (sampleBuffer.length > warnSamples) {
+ console.warn(
+ `[${label}] Audio buffer backlog: ${sampleBuffer.length} samples (${(
+ sampleBuffer.length / outputSampleRate
+ ).toFixed(2)}s)`
+ );
+ }
+ }
+
+ return {
+ onProcess,
+ reset,
+ };
+ }
+
+ namespace.createRealtimeAsrSession = function createRealtimeAsrSession({
+ Recorder,
+ WebSocketConnectMethod,
+ getMicStream,
+ getSystemStream,
+ getPausedTranscription,
+ setMainText,
+ setPreviewText,
+ setOfflineText,
+ getLastTimer,
+ setLastTimer,
+ getConfigWords,
+ shouldShowSpeaker,
+ getSegments,
+ setSegments,
+ getSegmentRenderStartIndex,
+ getRealtimeEpoch,
+ getTimestampBarrier,
+ setTimestampBarrier,
+ getEditCarryover,
+ setEditCarryover,
+ setPendingCorrectionCount,
+ onActiveChange,
+ }) {
+ const ASR_CHUNK_SIZE = 960;
+ const ASR_BUFFER_WARN_SAMPLES = 20000;
+ const ASR_BUFFER_HARD_LIMIT_SAMPLES = 16000 * 5;
+ const ASR_SAMPLE_RATE = 16000;
+
+ let webReader = null;
+ let webReader2 = null;
+ let rec = null;
+ let rec2 = null;
+ let asrAudioContext = null;
+ let asrAudioContext2 = null;
+ let isActive = false;
+ let resetTranscriptionFlag = false;
+
+ let recText = "";
+ let offlineText = "";
+ let recTextOnline = "";
+ let offlineSentenceHandoffText = "";
+ let recTextOffline = "";
+ let pendingSentenceQueue = [];
+ let pendingSentenceSequence = 0;
+ let lastOnlineTimestamp = null;
+ let pendingCorrectionCount = 0;
+
+ let onlineStreamer = null;
+ let offlineStreamer = null;
+
+ function setActive(nextValue) {
+ isActive = !!nextValue;
+ if (typeof onActiveChange === "function") {
+ onActiveChange(isActive);
+ }
+ }
+
+ function updatePendingCorrectionCount(delta) {
+ pendingCorrectionCount = Math.max(0, pendingCorrectionCount + delta);
+ if (typeof setPendingCorrectionCount === "function") {
+ setPendingCorrectionCount(pendingCorrectionCount);
+ }
+ }
+
+ function extractTimestampMs(timestampPayload) {
+ if (!timestampPayload) {
+ return null;
+ }
+
+ try {
+ const timestampData =
+ typeof timestampPayload === "string"
+ ? JSON.parse(timestampPayload)
+ : timestampPayload;
+
+ if (!Array.isArray(timestampData) || timestampData.length === 0) {
+ return null;
+ }
+
+ const lastItem = timestampData[timestampData.length - 1];
+ if (!Array.isArray(lastItem) || lastItem.length === 0) {
+ return null;
+ }
+
+ const endOrStart =
+ lastItem.length > 1 ? Number(lastItem[1]) : Number(lastItem[0]);
+ return Number.isFinite(endOrStart) ? endOrStart : null;
+ } catch (error) {
+ return null;
+ }
+ }
+
+ function getRealtimeCarryoverTail(text, maxLength = 160) {
+ return (text || "").slice(-maxLength);
+ }
+
+ function buildRealtimeEditCarryover(baseText) {
+ const tailText = getRealtimeCarryoverTail(baseText);
+ if (tailText.length < 4) {
+ return null;
+ }
+
+ return {
+ tailText,
+ };
+ }
+
+ function trimOfflineTextWithCarryover(rawText) {
+ const incomingText = rawText || "";
+ const realtimeEditCarryover = getEditCarryover
+ ? getEditCarryover()
+ : null;
+ if (!realtimeEditCarryover || !incomingText) {
+ return incomingText;
+ }
+
+ const tailText = realtimeEditCarryover.tailText || "";
+ const minOverlapLength = 4;
+ const maxOverlapLength = Math.min(80, tailText.length, incomingText.length);
+
+ for (let size = maxOverlapLength; size >= minOverlapLength; size--) {
+ const overlapText = tailText.slice(-size);
+ const matchIndex = incomingText.lastIndexOf(overlapText);
+ if (matchIndex === -1) {
+ continue;
+ }
+
+ const remainingText = incomingText.slice(matchIndex + overlapText.length);
+ setEditCarryover({
+ tailText: getRealtimeCarryoverTail(tailText + remainingText),
+ });
+ return remainingText;
+ }
+
+ setEditCarryover(null);
+ return incomingText;
+ }
+
+ function isStaleRealtimeMessage(
+ messageTimestampMs,
+ messageEpoch = getRealtimeEpoch()
+ ) {
+ if (messageEpoch !== getRealtimeEpoch()) {
+ return true;
+ }
+
+ const realtimeTimestampBarrierMs = getTimestampBarrier();
+ return (
+ Number.isFinite(messageTimestampMs) &&
+ realtimeTimestampBarrierMs >= 0 &&
+ messageTimestampMs <= realtimeTimestampBarrierMs
+ );
+ }
+
+ function getLatestRealtimeTimestamp() {
+ const latestSegmentTimestamp = getSegments().reduce(
+ (latest, segment) =>
+ Number.isFinite(segment && segment.timestampMs)
+ ? Math.max(latest, segment.timestampMs)
+ : latest,
+ -1
+ );
+ const latestPendingTimestamp = pendingSentenceQueue.reduce(
+ (latest, item) =>
+ Number.isFinite(item.timestampMs)
+ ? Math.max(latest, item.timestampMs)
+ : latest,
+ -1
+ );
+ const latestOnlineTimestamp = Number.isFinite(lastOnlineTimestamp)
+ ? lastOnlineTimestamp
+ : -1;
+
+ return Math.max(
+ latestSegmentTimestamp,
+ latestPendingTimestamp,
+ latestOnlineTimestamp
+ );
+ }
+
+ function getPendingSentencePreviewText() {
+ return pendingSentenceQueue
+ .filter((item) => item.epoch === getRealtimeEpoch())
+ .map((item) => item.correctedText || item.rawText)
+ .join("");
+ }
+
+ function syncRealtimePreview() {
+ setPreviewText(
+ getPendingSentencePreviewText() +
+ offlineSentenceHandoffText +
+ recTextOnline
+ );
+ }
+
+ function clearRealtimePipeline({ preserveTimestampBarrier = false } = {}) {
+ if (onlineStreamer) {
+ onlineStreamer.reset();
+ }
+ if (offlineStreamer) {
+ offlineStreamer.reset();
+ }
+
+ recText = "";
+ offlineText = "";
+ recTextOnline = "";
+ offlineSentenceHandoffText = "";
+ recTextOffline = "";
+ pendingSentenceQueue = [];
+ pendingSentenceSequence = 0;
+ lastOnlineTimestamp = null;
+ setPreviewText("");
+ setOfflineText("");
+
+ if (!preserveTimestampBarrier) {
+ setTimestampBarrier(-1);
+ }
+
+ syncRealtimePreview();
+ }
+
+ function findPendingSentenceBySource(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch = getRealtimeEpoch()
+ ) {
+ const normalizedSourceText = sourceText || "";
+ const normalizedTimestamp = pendingTimestamp || "";
+ return (
+ pendingSentenceQueue.find(
+ (item) =>
+ item.epoch === messageEpoch &&
+ item.sourceText === normalizedSourceText &&
+ item.timestamp === normalizedTimestamp
+ ) ||
+ getSegments().find(
+ (item) =>
+ item.epoch === messageEpoch &&
+ item.sourceText === normalizedSourceText &&
+ item.timestamp === normalizedTimestamp
+ ) ||
+ null
+ );
+ }
+
+ function ensurePendingSentence(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch = getRealtimeEpoch()
+ ) {
+ const timestampMs = extractTimestampMs(pendingTimestamp);
+ if (isStaleRealtimeMessage(timestampMs, messageEpoch)) {
+ return null;
+ }
+
+ const existingItem = findPendingSentenceBySource(
+ sourceText,
+ pendingTimestamp,
+ messageEpoch
+ );
+ if (existingItem) {
+ return existingItem;
+ }
+
+ const trimmedText = trimOfflineTextWithCarryover(sourceText || "");
+ if (!trimmedText) {
+ syncRealtimePreview();
+ return null;
+ }
+
+ const pendingItem = {
+ id: pendingSentenceSequence++,
+ sourceText: sourceText || "",
+ rawText: trimmedText,
+ timestamp: pendingTimestamp || "",
+ timestampMs,
+ correctedText: "",
+ processed: false,
+ correctionRequested: false,
+ epoch: messageEpoch,
+ };
+ pendingSentenceQueue.push(pendingItem);
+ syncRealtimePreview();
+ return pendingItem;
+ }
+
+ function addSegment(segment) {
+ const segments = getSegments();
+ segments.push(segment);
+ setSegments(segments);
+ }
+
+ function flushProcessedPendingSentences() {
+ let didFlush = false;
+ while (pendingSentenceQueue.length > 0) {
+ if (pendingSentenceQueue[0].epoch !== getRealtimeEpoch()) {
+ pendingSentenceQueue.shift();
+ continue;
+ }
+ if (!pendingSentenceQueue[0].processed) {
+ break;
+ }
+
+ const item = pendingSentenceQueue.shift();
+ addSegment({
+ text: item.correctedText || item.rawText,
+ sourceText: item.sourceText,
+ timestamp: item.timestamp,
+ timestampMs: item.timestampMs,
+ speaker: null,
+ processed: true,
+ epoch: item.epoch,
+ });
+ didFlush = true;
+ }
+
+ if (didFlush) {
+ renderFullText();
+ } else {
+ syncRealtimePreview();
+ }
+ }
+
+ function finalizePendingSentence(
+ pendingSentenceId,
+ finalText,
+ messageEpoch = getRealtimeEpoch()
+ ) {
+ const target = pendingSentenceQueue.find(
+ (item) => item.id === pendingSentenceId && item.epoch === messageEpoch
+ );
+ if (!target) {
+ return;
+ }
+
+ target.correctedText = finalText || target.rawText || "";
+ target.processed = true;
+ target.correctionRequested = false;
+ flushProcessedPendingSentences();
+ }
+
+ function addNewlineAfterPeriod(str) {
+ return str.replace(/[。?!]/g, "$&\n");
+ }
+
+ function handleWithTimestamp(tmptext, tmptime, keywordStr, timeThreshold) {
+ if (!tmptime || tmptext.length <= 0) return tmptext;
+
+ const keywordList = keywordStr
+ .split(",")
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0);
+
+ const keywordsAsRegex = keywordList.map((k) => new RegExp(k));
+ const segments = tmptext.match(/[^。!?]+[。!?]?/g) || [];
+ const jsontime = JSON.parse(tmptime);
+ let charIndex = 0;
+ let textWithTime = "";
+
+ function removeLeadingPunctuation(text) {
+ return text.replace(/^[,。!?、;:,\.!?;:]+/, "");
+ }
+
+ for (let i = 0; i < segments.length; i++) {
+ const segment = segments[i];
+ if (!segment || segment === "undefined") continue;
+
+ const nowTime = jsontime[charIndex][0] / 1000;
+
+ if (
+ getLastTimer() > 0 &&
+ nowTime - getLastTimer() > timeThreshold
+ ) {
+ const lastChar = textWithTime.slice(-1);
+ const endsWithPunc = /[。!?]/.test(lastChar);
+ const processedSegment = removeLeadingPunctuation(segment);
+ textWithTime +=
+ (endsWithPunc ? "\n" : "。\n") + processedSegment;
+ } else {
+ const hitKeyword = keywordsAsRegex.some((reg) => reg.test(segment));
+ if (hitKeyword) {
+ removeLeadingPunctuation(segment);
+ } else {
+ textWithTime += segment;
+ }
+ }
+
+ setLastTimer(nowTime);
+ charIndex++;
+ }
+
+ return textWithTime;
+ }
+
+ function getSpeakerBeforeIndex(endIndex) {
+ let previousSpeaker = null;
+ for (let i = 0; i < endIndex; i++) {
+ const segment = getSegments()[i];
+ if (segment && segment.speaker) {
+ previousSpeaker = segment.speaker;
+ }
+ }
+ return previousSpeaker;
+ }
+
+ function buildSegmentText(segments, initialSpeaker = null) {
+ let displayText = "";
+ let lastRenderedSpeaker = initialSpeaker;
+
+ segments.forEach((segment) => {
+ if (shouldShowSpeaker()) {
+ if (
+ segment.speaker &&
+ segment.speaker !== lastRenderedSpeaker &&
+ !segment.speaker.includes("SPK")
+ ) {
+ displayText +=
+ (displayText ? "\n" : "") + segment.speaker + ":\n";
+ lastRenderedSpeaker = segment.speaker;
+ } else if (
+ segment.speaker &&
+ segment.speaker !== lastRenderedSpeaker
+ ) {
+ displayText +=
+ (displayText ? "\n" : "") + segment.speaker + ":\n";
+ lastRenderedSpeaker = segment.speaker;
+ }
+ }
+
+ const textContent = handleWithTimestamp(
+ segment.text,
+ segment.timestamp,
+ getConfigWords(),
+ 6
+ );
+ displayText += textContent;
+ });
+
+ return displayText;
+ }
+
+ function renderFullText() {
+ const segmentRenderStartIndex = getSegmentRenderStartIndex();
+ const initialSpeaker =
+ segmentRenderStartIndex > 0
+ ? getSpeakerBeforeIndex(segmentRenderStartIndex)
+ : null;
+ const displayText = buildSegmentText(
+ getSegments().slice(segmentRenderStartIndex),
+ initialSpeaker
+ );
+
+ recText = displayText;
+ setMainText(getPausedTranscription() + recText);
+ syncRealtimePreview();
+ }
+
+ function resetIfRequested() {
+ if (resetTranscriptionFlag) {
+ console.log("重置实时转录状态(暂停/恢复后)");
+ clearRealtimePipeline();
+ resetTranscriptionFlag = false;
+ }
+ }
+
+ function handleOnlineMessage(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ const text = "" + parsedMessage["text"];
+ const asrModel = parsedMessage["mode"];
+ const timestamp = parsedMessage["timestamp"];
+
+ resetIfRequested();
+
+ const messageEpoch = getRealtimeEpoch();
+
+ if (asrModel === "2pass-online") {
+ const currentTimestamp = extractTimestampMs(timestamp);
+ if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
+ syncRealtimePreview();
+ return;
+ }
+
+ let shouldBreak = false;
+ if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
+ const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
+ if (timeDiff >= 3 && /。\s*$/.test(text)) {
+ shouldBreak = true;
+ }
+ }
+
+ recTextOnline += shouldBreak ? text + "\n" : text;
+ lastOnlineTimestamp = currentTimestamp;
+ } else if (asrModel === "2pass-offline") {
+ recTextOnline = "";
+ offlineSentenceHandoffText = text || recTextOnline || "";
+ recTextOnline = "";
+ lastOnlineTimestamp = null;
+ }
+
+ syncRealtimePreview();
+ }
+
+ async function handleOfflineMessage(jsonMsg) {
+ const parsedMessage = JSON.parse(jsonMsg.data);
+ const asrModelType = parsedMessage["mode"];
+ const timestamp = parsedMessage["timestamp"];
+ const messageEpoch = getRealtimeEpoch();
+ const messageTimestampMs = extractTimestampMs(timestamp);
+
+ if (asrModelType === "2pass-offline") {
+ if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
+ return;
+ }
+
+ const text = "" + parsedMessage["text"];
+ recTextOnline = "";
+ lastOnlineTimestamp = null;
+ const pendingSentence = ensurePendingSentence(
+ text,
+ timestamp,
+ messageEpoch
+ );
+ offlineSentenceHandoffText = "";
+ syncRealtimePreview();
+ if (
+ !pendingSentence ||
+ pendingSentence.processed ||
+ pendingSentence.correctionRequested
+ ) {
+ return;
+ }
+
+ pendingSentence.correctionRequested = true;
+ updatePendingCorrectionCount(1);
+ try {
+ const response = await fetch(`/tool/speakr/api/asr/correct`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: pendingSentence.rawText }),
+ });
+
+ if (!response.ok) {
+ throw new Error("ASR correction failed");
+ }
+
+ const data = await response.json();
+ const correctedText = data.corrected_text || pendingSentence.rawText;
+ finalizePendingSentence(
+ pendingSentence.id,
+ correctedText,
+ messageEpoch
+ );
+ console.log("LLM矫正完成");
+ } catch (error) {
+ finalizePendingSentence(
+ pendingSentence.id,
+ pendingSentence.rawText,
+ messageEpoch
+ );
+ console.error("LLM矫正出错:", error);
+ } finally {
+ updatePendingCorrectionCount(-1);
+ }
+ } else if (asrModelType === "offline-speaker") {
+ if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
+ return;
+ }
+
+ const nowSpeaker =
+ (parsedMessage["segments"] &&
+ parsedMessage["segments"][0] &&
+ parsedMessage["segments"][0].speaker) ||
+ "未命名演讲者";
+
+ const segments = getSegments();
+ let targetIndex = -1;
+ for (let i = segments.length - 1; i >= 0; i--) {
+ const segment = segments[i];
+ if (!segment || segment.epoch !== messageEpoch) {
+ continue;
+ }
+ if (
+ segment.speaker === null &&
+ Number.isFinite(messageTimestampMs) &&
+ Number.isFinite(segment.timestampMs) &&
+ segment.timestampMs === messageTimestampMs
+ ) {
+ targetIndex = i;
+ break;
+ }
+ if (targetIndex === -1 && segment.speaker === null) {
+ targetIndex = i;
+ }
+ }
+
+ if (targetIndex !== -1) {
+ segments[targetIndex].speaker = nowSpeaker;
+ setSegments(segments);
+ renderFullText();
+ }
+ }
+ }
+
+ async function getMicrophoneInfo() {
+ if (
+ !navigator.mediaDevices ||
+ !navigator.mediaDevices.enumerateDevices
+ ) {
+ console.warn("当前浏览器不支持获取设备信息");
+ return null;
+ }
+
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const mics = devices.filter((device) => device.kind === "audioinput");
+ const defaultMic =
+ mics.find((device) => device.deviceId === "default") || mics[0];
+
+ return {
+ name: (defaultMic && defaultMic.label) || "未知麦克风",
+ deviceId: (defaultMic && defaultMic.deviceId) || "",
+ all: mics.map((device) => ({
+ name: device.label,
+ deviceId: device.deviceId,
+ })),
+ };
+ }
+
+ async function openMixedStream(channel) {
+ try {
+ if (channel === "online") {
+ const micInfo = await getMicrophoneInfo();
+ if (micInfo) {
+ console.log("🎤 当前麦克风:", micInfo.name, micInfo);
+ }
+ }
+
+ const micStream = getMicStream();
+ const systemStream = getSystemStream();
+
+ let contextRef = channel === "online" ? asrAudioContext : asrAudioContext2;
+ if (contextRef) {
+ try {
+ contextRef.close();
+ } catch (error) {}
+ }
+
+ contextRef = new (global.AudioContext || global.webkitAudioContext)();
+ if (channel === "online") {
+ asrAudioContext = contextRef;
+ } else {
+ asrAudioContext2 = contextRef;
+ }
+
+ const destination = contextRef.createMediaStreamDestination();
+ const micSource = contextRef.createMediaStreamSource(micStream);
+ micSource.connect(destination);
+
+ if (systemStream && systemStream.getAudioTracks().length > 0) {
+ const sysSource = contextRef.createMediaStreamSource(systemStream);
+ sysSource.connect(destination);
+ } else {
+ console.log("⚠️ 未检测到系统音频,使用麦克风录音");
+ }
+
+ const recorder = channel === "online" ? rec : rec2;
+ recorder.open(
+ () => {
+ recorder.start(destination.stream);
+ },
+ (msg) => {
+ console.error("❌ 录音权限被拒绝:", msg);
+ }
+ );
+ } catch (error) {
+ console.error("初始化录音失败:", error);
+ }
+ }
+
+ function stop() {
+ setActive(false);
+ if (webReader) {
+ webReader.wsStop();
+ }
+ if (webReader2) {
+ webReader2.wsStop();
+ }
+ }
+
+ async function finish(timeoutMs = 3500) {
+ setActive(false);
+
+ const finishPromises = [];
+ if (webReader && typeof webReader.wsFinish === "function") {
+ finishPromises.push(webReader.wsFinish(timeoutMs));
+ } else if (webReader) {
+ webReader.wsStop();
+ }
+ if (webReader2 && typeof webReader2.wsFinish === "function") {
+ finishPromises.push(webReader2.wsFinish(timeoutMs));
+ } else if (webReader2) {
+ webReader2.wsStop();
+ }
+ if (finishPromises.length > 0) {
+ await Promise.allSettled(finishPromises);
+ }
+ }
+
+ function cleanup() {
+ stop();
+ if (rec) {
+ try {
+ rec.close();
+ } catch (error) {
+ console.warn("关闭旧rec失败:", error);
+ }
+ rec = null;
+ }
+ if (rec2) {
+ try {
+ rec2.close();
+ } catch (error) {
+ console.warn("关闭旧rec2失败:", error);
+ }
+ rec2 = null;
+ }
+ if (asrAudioContext) {
+ try {
+ asrAudioContext.close();
+ } catch (error) {}
+ asrAudioContext = null;
+ }
+ if (asrAudioContext2) {
+ try {
+ asrAudioContext2.close();
+ } catch (error) {}
+ asrAudioContext2 = null;
+ }
+ }
+
+ function createRecorders() {
+ onlineStreamer = createPcmStreamer({
+ label: "Online",
+ recorder: Recorder,
+ isActive: () => isActive,
+ sendChunk: (chunk) => webReader && webReader.wsSend(chunk),
+ chunkSize: ASR_CHUNK_SIZE,
+ warnSamples: ASR_BUFFER_WARN_SAMPLES,
+ hardLimitSamples: ASR_BUFFER_HARD_LIMIT_SAMPLES,
+ outputSampleRate: ASR_SAMPLE_RATE,
+ });
+ offlineStreamer = createPcmStreamer({
+ label: "Offline",
+ recorder: Recorder,
+ isActive: () => isActive,
+ sendChunk: (chunk) => webReader2 && webReader2.wsSend(chunk),
+ chunkSize: ASR_CHUNK_SIZE,
+ warnSamples: ASR_BUFFER_WARN_SAMPLES,
+ hardLimitSamples: ASR_BUFFER_HARD_LIMIT_SAMPLES,
+ outputSampleRate: ASR_SAMPLE_RATE,
+ });
+
+ rec = Recorder({
+ type: "pcm",
+ bitRate: 16,
+ sampleRate: ASR_SAMPLE_RATE,
+ onProcess: onlineStreamer.onProcess,
+ });
+ rec2 = Recorder({
+ type: "pcm",
+ bitRate: 16,
+ sampleRate: ASR_SAMPLE_RATE,
+ onProcess: offlineStreamer.onProcess,
+ });
+ }
+
+ function setupConnections() {
+ webReader = new WebSocketConnectMethod({
+ msgHandle: handleOnlineMessage,
+ stateHandle(connState) {
+ if (connState === 0) {
+ openMixedStream("online");
+ } else if (connState === 2) {
+ stop();
+ }
+ },
+ });
+ webReader2 = new WebSocketConnectMethod({
+ msgHandle: handleOfflineMessage,
+ stateHandle(connState) {
+ if (connState === 0) {
+ openMixedStream("offline");
+ } else if (connState === 2) {
+ stop();
+ }
+ },
+ });
+ }
+
+ function start() {
+ setupConnections();
+ createRecorders();
+
+ const onlineStarted = webReader.wsStart();
+ const offlineStarted = webReader2.wsStart("offline");
+ if (onlineStarted === 1 || offlineStarted === 1) {
+ setActive(true);
+ }
+ return onlineStarted === 1 && offlineStarted === 1;
+ }
+
+ function restart() {
+ resetTranscriptionFlag = true;
+ setPreviewText("");
+
+ let started = false;
+ if (webReader) {
+ const ret = webReader.wsStart();
+ if (ret === 1) {
+ started = true;
+ }
+ }
+ if (webReader2) {
+ try {
+ const ret = webReader2.wsStart("offline");
+ if (ret === 1) {
+ started = true;
+ }
+ } catch (error) {
+ console.warn("Failed to restart offline WS:", error);
+ }
+ }
+
+ if (started) {
+ setActive(true);
+ }
+ return started;
+ }
+
+ return {
+ start,
+ restart,
+ stop,
+ finish,
+ cleanup,
+ clearRealtimePipeline,
+ getLatestRealtimeTimestamp,
+ buildRealtimeEditCarryover,
+ renderFullText,
+ getPendingCorrectionCount() {
+ return pendingCorrectionCount;
+ },
+ };
+ };
+})(window);
diff --git a/speakr/static/js/recorder-core.js b/speakr/static/js/recorder-core.js
new file mode 100644
index 0000000..23f6620
--- /dev/null
+++ b/speakr/static/js/recorder-core.js
@@ -0,0 +1,1490 @@
+/*
+录音
+https://github.com/xiangyuecn/Recorder
+*/
+(function(factory){
+ factory(window);
+ //umd returnExports.js
+ if(typeof(define)=='function' && define.amd){
+ define(function(){
+ return Recorder;
+ });
+ };
+ if(typeof(module)=='object' && module.exports){
+ module.exports=Recorder;
+ };
+}(function(window){
+"use strict";
+
+var NOOP=function(){};
+
+var Recorder=function(set){
+ return new initFn(set);
+};
+Recorder.LM="2023-02-01 18:05";
+var RecTxt="Recorder";
+var getUserMediaTxt="getUserMedia";
+var srcSampleRateTxt="srcSampleRate";
+var sampleRateTxt="sampleRate";
+var CatchTxt="catch";
+
+
+//是否已经打开了全局的麦克风录音,所有工作都已经准备好了,就等接收音频数据了
+Recorder.IsOpen=function(){
+ var stream=Recorder.Stream;
+ if(stream){
+ var tracks=stream.getTracks&&stream.getTracks()||stream.audioTracks||[];
+ var track=tracks[0];
+ if(track){
+ var state=track.readyState;
+ return state=="live"||state==track.LIVE;
+ };
+ };
+ return false;
+};
+/*H5录音时的AudioContext缓冲大小。会影响H5录音时的onProcess调用速率,相对于AudioContext.sampleRate=48000时,4096接近12帧/s,调节此参数可生成比较流畅的回调动画。
+ 取值256, 512, 1024, 2048, 4096, 8192, or 16384
+ 注意,取值不能过低,2048开始不同浏览器可能回调速率跟不上造成音质问题。
+ 一般无需调整,调整后需要先close掉已打开的录音,再open时才会生效。
+*/
+Recorder.BufferSize=4096;
+//销毁已持有的所有全局资源,当要彻底移除Recorder时需要显式的调用此方法
+Recorder.Destroy=function(){
+ CLog(RecTxt+" Destroy");
+ Disconnect();//断开可能存在的全局Stream、资源
+
+ for(var k in DestroyList){
+ DestroyList[k]();
+ };
+};
+var DestroyList={};
+//登记一个需要销毁全局资源的处理方法
+Recorder.BindDestroy=function(key,call){
+ DestroyList[key]=call;
+};
+//判断浏览器是否支持录音,随时可以调用。注意:仅仅是检测浏览器支持情况,不会判断和调起用户授权,不会判断是否支持特定格式录音。
+Recorder.Support=function(){
+ var scope=navigator.mediaDevices||{};
+ if(!scope[getUserMediaTxt]){
+ scope=navigator;
+ scope[getUserMediaTxt]||(scope[getUserMediaTxt]=scope.webkitGetUserMedia||scope.mozGetUserMedia||scope.msGetUserMedia);
+ };
+ if(!scope[getUserMediaTxt]){
+ return false;
+ };
+ Recorder.Scope=scope;
+
+ if(!Recorder.GetContext()){
+ return false;
+ };
+ return true;
+};
+//获取全局的AudioContext对象,如果浏览器不支持将返回null
+Recorder.GetContext=function(){
+ var AC=window.AudioContext;
+ if(!AC){
+ AC=window.webkitAudioContext;
+ };
+ if(!AC){
+ return null;
+ };
+
+ if(!Recorder.Ctx||Recorder.Ctx.state=="closed"){
+ //不能反复构造,低版本number of hardware contexts reached maximum (6)
+ Recorder.Ctx=new AC();
+
+ Recorder.BindDestroy("Ctx",function(){
+ var ctx=Recorder.Ctx;
+ if(ctx&&ctx.close){//能关掉就关掉,关不掉就保留着
+ ctx.close();
+ Recorder.Ctx=0;
+ };
+ });
+ };
+ return Recorder.Ctx;
+};
+
+
+/*是否启用MediaRecorder.WebM.PCM来进行音频采集连接(如果浏览器支持的话),默认启用,禁用或者不支持时将使用AudioWorklet或ScriptProcessor来连接;MediaRecorder采集到的音频数据比其他方式更好,几乎不存在丢帧现象,所以音质明显会好很多,建议保持开启*/
+var ConnectEnableWebM="ConnectEnableWebM";
+Recorder[ConnectEnableWebM]=true;
+
+/*是否启用AudioWorklet特性来进行音频采集连接(如果浏览器支持的话),默认禁用,禁用或不支持时将使用过时的ScriptProcessor来连接(如果方法还在的话),当前AudioWorklet的实现在移动端没有ScriptProcessor稳健;ConnectEnableWebM如果启用并且有效时,本参数将不起作用*/
+var ConnectEnableWorklet="ConnectEnableWorklet";
+Recorder[ConnectEnableWorklet]=false;
+
+/*初始化H5音频采集连接。如果自行提供了sourceStream将只进行一次简单的连接处理。如果是普通麦克风录音,此时的Stream是全局的,Safari上断开后就无法再次进行连接使用,表现为静音,因此使用全部使用全局处理避免调用到disconnect;全局处理也有利于屏蔽底层细节,start时无需再调用底层接口,提升兼容、可靠性。*/
+var Connect=function(streamStore,isUserMedia){
+ var bufferSize=streamStore.BufferSize||Recorder.BufferSize;
+
+ var ctx=Recorder.Ctx,stream=streamStore.Stream;
+ var mediaConn=function(node){
+ var media=stream._m=ctx.createMediaStreamSource(stream);
+ var ctxDest=ctx.destination,cmsdTxt="createMediaStreamDestination";
+ if(ctx[cmsdTxt]){
+ ctxDest=ctx[cmsdTxt]();
+ };
+ media.connect(node);
+ node.connect(ctxDest);
+ }
+ var isWebM,isWorklet,badInt,webMTips="";
+ var calls=stream._call;
+
+ //浏览器回传的音频数据处理
+ var onReceive=function(float32Arr){
+ for(var k0 in calls){//has item
+ var size=float32Arr.length;
+
+ var pcm=new Int16Array(size);
+ var sum=0;
+ for(var j=0;j=pcmSampleRate时不会进行任何处理,小于时会进行重新采样
+prevChunkInfo:{} 可选,上次调用时的返回值,用于连续转换,本次调用将从上次结束位置开始进行处理。或可自行定义一个ChunkInfo从pcmDatas指定的位置开始进行转换
+option:{ 可选,配置项
+ frameSize:123456 帧大小,每帧的PCM Int16的数量,采样率转换后的pcm长度为frameSize的整数倍,用于连续转换。目前仅在mp3格式时才有用,frameSize取值为1152,这样编码出来的mp3时长和pcm的时长完全一致,否则会因为mp3最后一帧录音不够填满时添加填充数据导致mp3的时长变长。
+ frameType:"" 帧类型,一般为rec.set.type,提供此参数时无需提供frameSize,会自动使用最佳的值给frameSize赋值,目前仅支持mp3=1152(MPEG1 Layer3的每帧采采样数),其他类型=1。
+ 以上两个参数用于连续转换时使用,最多使用一个,不提供时不进行帧的特殊处理,提供时必须同时提供prevChunkInfo才有作用。最后一段数据处理时无需提供帧大小以便输出最后一丁点残留数据。
+ }
+
+返回ChunkInfo:{
+ //可定义,从指定位置开始转换到结尾
+ index:0 pcmDatas已处理到的索引
+ offset:0.0 已处理到的index对应的pcm中的偏移的下一个位置
+
+ //仅作为返回值
+ frameNext:null||[Int16,...] 下一帧的部分数据,frameSize设置了的时候才可能会有
+ sampleRate:16000 结果的采样率,<=newSampleRate
+ data:[Int16,...] 转换后的PCM结果;如果是连续转换,并且pcmDatas中并没有新数据时,data的长度可能为0
+}
+*/
+Recorder.SampleData=function(pcmDatas,pcmSampleRate,newSampleRate,prevChunkInfo,option){
+ prevChunkInfo||(prevChunkInfo={});
+ var index=prevChunkInfo.index||0;
+ var offset=prevChunkInfo.offset||0;
+
+ var frameNext=prevChunkInfo.frameNext||[];
+ option||(option={});
+ var frameSize=option.frameSize||1;
+ if(option.frameType){
+ frameSize=option.frameType=="mp3"?1152:1;
+ };
+
+ var nLen=pcmDatas.length;
+ if(index>nLen+1){
+ CLog("SampleData似乎传入了未重置chunk "+index+">"+nLen,3);
+ };
+ var size=0;
+ for(var i=index;i1){//新采样低于录音采样,进行抽样
+ size=Math.floor(size/step);
+ }else{//新采样高于录音采样不处理,省去了插值处理
+ step=1;
+ newSampleRate=pcmSampleRate;
+ };
+
+ size+=frameNext.length;
+ var res=new Int16Array(size);
+ var idx=0;
+ //添加上一次不够一帧的剩余数据
+ for(var i=0;i0){
+ var u8Pos=(res.length-frameNextSize)*2;
+ frameNext=new Int16Array(res.buffer.slice(u8Pos));
+ res=new Int16Array(res.buffer.slice(0,u8Pos));
+ };
+
+ return {
+ index:index
+ ,offset:offset
+
+ ,frameNext:frameNext
+ ,sampleRate:newSampleRate
+ ,data:res
+ };
+};
+
+
+/*计算音量百分比的一个方法
+pcmAbsSum: pcm Int16所有采样的绝对值的和
+pcmLength: pcm长度
+返回值:0-100,主要当做百分比用
+注意:这个不是分贝,因此没用volume当做名称*/
+Recorder.PowerLevel=function(pcmAbsSum,pcmLength){
+ /*计算音量 https://blog.csdn.net/jody1989/article/details/73480259
+ 更高灵敏度算法:
+ 限定最大感应值10000
+ 线性曲线:低音量不友好
+ power/10000*100
+ 对数曲线:低音量友好,但需限定最低感应值
+ (1+Math.log10(power/10000))*100
+ */
+ var power=(pcmAbsSum/pcmLength) || 0;//NaN
+ var level;
+ if(power<1251){//1250的结果10%,更小的音量采用线性取值
+ level=Math.round(power/1250*10);
+ }else{
+ level=Math.round(Math.min(100,Math.max(0,(1+Math.log(power/10000)/Math.log(10))*100)));
+ };
+ return level;
+};
+
+/*计算音量,单位dBFS(满刻度相对电平)
+maxSample: 为16位pcm采样的绝对值中最大的一个(计算峰值音量),或者为pcm中所有采样的绝对值的平局值
+返回值:-100~0 (最大值0dB,最小值-100代替-∞)
+*/
+Recorder.PowerDBFS=function(maxSample){
+ var val=Math.max(0.1, maxSample||0),Pref=0x7FFF;
+ val=Math.min(val,Pref);
+ //https://www.logiclocmusic.com/can-you-tell-the-decibel/
+ //https://blog.csdn.net/qq_17256689/article/details/120442510
+ val=20*Math.log(val/Pref)/Math.log(10);
+ return Math.max(-100,Math.round(val));
+};
+
+
+
+
+//带时间的日志输出,可设为一个空函数来屏蔽日志输出
+//CLog(msg,errOrLogMsg, logMsg...) err为数字时代表日志类型1:error 2:log默认 3:warn,否则当做内容输出,第一个参数不能是对象因为要拼接时间,后面可以接无数个输出参数
+Recorder.CLog=function(msg,err){
+ var now=new Date();
+ var t=("0"+now.getMinutes()).substr(-2)
+ +":"+("0"+now.getSeconds()).substr(-2)
+ +"."+("00"+now.getMilliseconds()).substr(-3);
+ var recID=this&&this.envIn&&this.envCheck&&this.id;
+ var arr=["["+t+" "+RecTxt+(recID?":"+recID:"")+"]"+msg];
+ var a=arguments,console=window.console||{};
+ var i=2,fn=console.log;
+ if(typeof(err)=="number"){
+ fn=err==1?console.error:err==3?console.warn:fn;
+ }else{
+ i=1;
+ };
+ for(;i1?arr:"");
+ }else{
+ fn.apply(console,arr);
+ };
+};
+var CLog=function(){ Recorder.CLog.apply(this,arguments); };
+var IsLoser=true;try{IsLoser=!console.log.apply;}catch(e){};
+
+
+
+
+var ID=0;
+function initFn(set){
+ this.id=++ID;
+
+ var o={
+ type:"mp3" //输出类型:mp3,wav,wav输出文件尺寸超大不推荐使用,但mp3编码支持会导致js文件超大,如果不需支持mp3可以使js文件大幅减小
+ ,bitRate:16 //比特率 wav:16或8位,MP3:8kbps 1k/s,8kbps 2k/s 录音文件很小
+
+ ,sampleRate:16000 //采样率,wav格式大小=sampleRate*时间;mp3此项对低比特率有影响,高比特率几乎无影响。
+ //wav任意值,mp3取值范围:48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000
+ //采样率参考https://www.cnblogs.com/devin87/p/mp3-recorder.html
+
+ ,onProcess:NOOP //fn(buffers,powerLevel,bufferDuration,bufferSampleRate,newBufferIdx,asyncEnd) buffers=[[Int16,...],...]:缓冲的PCM数据,为从开始录音到现在的所有pcm片段;powerLevel:当前缓冲的音量级别0-100,bufferDuration:已缓冲时长,bufferSampleRate:缓冲使用的采样率(当type支持边录边转码(Worker)时,此采样率和设置的采样率相同,否则不一定相同);newBufferIdx:本次回调新增的buffer起始索引;asyncEnd:fn() 如果onProcess是异步的(返回值为true时),处理完成时需要调用此回调,如果不是异步的请忽略此参数,此方法回调时必须是真异步(不能真异步时需用setTimeout包裹)。onProcess返回值:如果返回true代表开启异步模式,在某些大量运算的场合异步是必须的,必须在异步处理完成时调用asyncEnd(不能真异步时需用setTimeout包裹),在onProcess执行后新增的buffer会全部替换成空数组,因此本回调开头应立即将newBufferIdx到本次回调结尾位置的buffer全部保存到另外一个数组内,处理完成后写回buffers中本次回调的结尾位置。
+
+ //*******高级设置******
+ //,sourceStream:MediaStream Object
+ //可选直接提供一个媒体流,从这个流中录制、实时处理音频数据(当前Recorder实例独享此流);不提供时为普通的麦克风录音,由getUserMedia提供音频流(所有Recorder实例共享同一个流)
+ //比如:audio、video标签dom节点的captureStream方法(实验特性,不同浏览器支持程度不高)返回的流;WebRTC中的remote流;自己创建的流等
+ //注意:流内必须至少存在一条音轨(Audio Track),比如audio标签必须等待到可以开始播放后才会有音轨,否则open会失败
+
+ //,audioTrackSet:{ deviceId:"",groupId:"", autoGainControl:true, echoCancellation:true, noiseSuppression:true }
+ //普通麦克风录音时getUserMedia方法的audio配置参数,比如指定设备id,回声消除、降噪开关;注意:提供的任何配置值都不一定会生效
+ //由于麦克风是全局共享的,所以新配置后需要close掉以前的再重新open
+ //更多参考: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
+
+ //,disableEnvInFix:false 内部参数,禁用设备卡顿时音频输入丢失补偿功能
+
+ //,takeoffEncodeChunk:NOOP //fn(chunkBytes) chunkBytes=[Uint8,...]:实时编码环境下接管编码器输出,当编码器实时编码出一块有效的二进制音频数据时实时回调此方法;参数为二进制的Uint8Array,就是编码出来的音频数据片段,所有的chunkBytes拼接在一起即为完整音频。本实现的想法最初由QQ2543775048提出
+ //当提供此回调方法时,将接管编码器的数据输出,编码器内部将放弃存储生成的音频数据;环境要求比较苛刻:如果当前环境不支持实时编码处理,将在open时直接走fail逻辑
+ //因此提供此回调后调用stop方法将无法获得有效的音频数据,因为编码器内没有音频数据,因此stop时返回的blob将是一个字节长度为0的blob
+ //目前只有mp3格式实现了实时编码,在支持实时处理的环境中将会实时的将编码出来的mp3片段通过此方法回调,所有的chunkBytes拼接到一起即为完整的mp3,此种拼接的结果比mock方法实时生成的音质更加,因为天然避免了首尾的静默
+ //目前除mp3外其他格式不可以提供此回调,提供了将在open时直接走fail逻辑
+ };
+
+ for(var k in set){
+ o[k]=set[k];
+ };
+ this.set=o;
+
+ this._S=9;//stop同步锁,stop可以阻止open过程中还未运行的start
+ this.Sync={O:9,C:9};//和Recorder.Sync一致,只不过这个是非全局的,仅用来简化代码逻辑,无实际作用
+};
+//同步锁,控制对Stream的竞争;用于close时中断异步的open;一个对象open如果变化了都要阻止close,Stream的控制权交个新的对象
+Recorder.Sync={/*open*/O:9,/*close*/C:9};
+
+Recorder.prototype=initFn.prototype={
+ CLog:CLog
+
+ //流相关的数据存储在哪个对象里面;如果提供了sourceStream,数据直接存储在当前对象中,否则存储在全局
+ ,_streamStore:function(){
+ if(this.set.sourceStream){
+ return this;
+ }else{
+ return Recorder;
+ }
+ }
+
+ //打开录音资源True(),False(msg,isUserNotAllow),需要调用close。注意:此方法是异步的;一般使用时打开,用完立即关闭;可重复调用,可用来测试是否能录音
+ ,open:function(True,False){
+ var This=this,streamStore=This._streamStore();
+ True=True||NOOP;
+ var failCall=function(errMsg,isUserNotAllow){
+ isUserNotAllow=!!isUserNotAllow;
+ let msg="录音open失败:"+errMsg+",isUserNotAllow:"+isUserNotAllow+",解决方案请查看:http://harryai.cc/post/realtime-funasr/#踩坑";
+ This.CLog(msg,1);
+ alert(msg);
+ False&&False(errMsg,isUserNotAllow);
+ };
+
+ var ok=function(){
+ This.CLog("open ok id:"+This.id);
+ True();
+
+ This._SO=0;//解除stop对open中的start调用的阻止
+ };
+
+
+ //同步锁
+ var Lock=streamStore.Sync;
+ var lockOpen=++Lock.O,lockClose=Lock.C;
+ This._O=This._O_=lockOpen;//记住当前的open,如果变化了要阻止close,这里假定了新对象已取代当前对象并且不再使用
+ This._SO=This._S;//记住open过程中的stop,中途任何stop调用后都不能继续open中的start
+ var lockFail=function(){
+ //允许多次open,但不允许任何一次close,或者自身已经调用了关闭
+ if(lockClose!=Lock.C || !This._O){
+ var err="open被取消";
+ if(lockOpen==Lock.O){
+ //无新的open,已经调用了close进行取消,此处应让上次的close明确生效
+ This.close();
+ }else{
+ err="open被中断";
+ };
+ failCall(err);
+ return true;
+ };
+ };
+
+ //环境配置检查
+ var checkMsg=This.envCheck({envName:"H5",canProcess:true});
+ if(checkMsg){
+ failCall("不能录音:"+checkMsg);
+ return;
+ };
+
+
+ //***********已直接提供了音频流************
+ if(This.set.sourceStream){
+ if(!Recorder.GetContext()){
+ failCall("不支持此浏览器从流中获取录音");
+ return;
+ };
+
+ Disconnect(streamStore);//可能已open过,直接先尝试断开
+ This.Stream=This.set.sourceStream;
+ This.Stream._call={};
+
+ try{
+ Connect(streamStore);
+ }catch(e){
+ failCall("从流中打开录音失败:"+e.message);
+ return;
+ }
+ ok();
+ return;
+ };
+
+
+ //***********打开麦克风得到全局的音频流************
+ var codeFail=function(code,msg){
+ try{//跨域的优先检测一下
+ window.top.a;
+ }catch(e){
+ failCall('无权录音(跨域,请尝试给iframe添加麦克风访问策略,如allow="camera;microphone")');
+ return;
+ };
+
+ if(/Permission|Allow/i.test(code)){
+ failCall("用户拒绝了录音权限",true);
+ }else if(window.isSecureContext===false){
+ failCall("浏览器禁止不安全页面录音,可开启https解决");
+ }else if(/Found/i.test(code)){//可能是非安全环境导致的没有设备
+ failCall(msg+",无可用麦克风");
+ }else{
+ failCall(msg);
+ };
+ };
+
+
+ //如果已打开并且有效就不要再打开了
+ if(Recorder.IsOpen()){
+ ok();
+ return;
+ };
+ if(!Recorder.Support()){
+ codeFail("","此浏览器不支持录音");
+ return;
+ };
+
+ //请求权限,如果从未授权,一般浏览器会弹出权限请求弹框
+ var f1=function(stream){
+ //https://github.com/xiangyuecn/Recorder/issues/14 获取到的track.readyState!="live",刚刚回调时可能是正常的,但过一下可能就被关掉了,原因不明。延迟一下保证真异步。对正常浏览器不影响
+ setTimeout(function(){
+ stream._call={};
+ var oldStream=Recorder.Stream;
+ if(oldStream){
+ Disconnect(); //直接断开已存在的,旧的Connect未完成会自动终止
+ stream._call=oldStream._call;
+ };
+ Recorder.Stream=stream;
+ if(lockFail())return;
+
+ if(Recorder.IsOpen()){
+ if(oldStream)This.CLog("发现同时多次调用open",1);
+
+ Connect(streamStore,1);
+ ok();
+ }else{
+ failCall("录音功能无效:无音频流");
+ };
+ },100);
+ };
+ var f2=function(e){
+ var code=e.name||e.message||e.code+":"+e;
+ This.CLog("请求录音权限错误",1,e);
+
+ codeFail(code,"无法录音:"+code);
+ };
+
+ var trackSet={
+ noiseSuppression:false //默认禁用降噪,原声录制,免得移动端表现怪异(包括系统播放声音变小)
+ ,echoCancellation:false //回声消除
+ };
+ var trackSet2=This.set.audioTrackSet;
+ for(var k in trackSet2)trackSet[k]=trackSet2[k];
+ trackSet.sampleRate=Recorder.Ctx.sampleRate;//必须指明采样率,不然手机上MediaRecorder采样率16k
+
+ try{
+ var pro=Recorder.Scope[getUserMediaTxt]({audio:trackSet},f1,f2);
+ }catch(e){//不能设置trackSet就算了
+ This.CLog(getUserMediaTxt,3,e);
+ pro=Recorder.Scope[getUserMediaTxt]({audio:true},f1,f2);
+ };
+ if(pro&&pro.then){
+ pro.then(f1)[CatchTxt](f2); //fix 关键字,保证catch压缩时保持字符串形式
+ };
+ }
+ //关闭释放录音资源
+ ,close:function(call){
+ call=call||NOOP;
+
+ var This=this,streamStore=This._streamStore();
+ This._stop();
+
+ var Lock=streamStore.Sync;
+ This._O=0;
+ if(This._O_!=Lock.O){
+ //唯一资源Stream的控制权已交给新对象,这里不能关闭。此处在每次都弹权限的浏览器内可能存在泄漏,新对象被拒绝权限可能不会调用close,忽略这种不处理
+ This.CLog("close被忽略(因为同时open了多个rec,只有最后一个会真正close)",3);
+ call();
+ return;
+ };
+ Lock.C++;//获得控制权
+
+ Disconnect(streamStore);
+
+ This.CLog("close");
+ call();
+ }
+
+
+
+
+
+ /*模拟一段录音数据,后面可以调用stop进行编码,需提供pcm数据[1,2,3...],pcm的采样率*/
+ ,mock:function(pcmData,pcmSampleRate){
+ var This=this;
+ This._stop();//清理掉已有的资源
+
+ This.isMock=1;
+ This.mockEnvInfo=null;
+ This.buffers=[pcmData];
+ This.recSize=pcmData.length;
+ This[srcSampleRateTxt]=pcmSampleRate;
+ return This;
+ }
+ ,envCheck:function(envInfo){//平台环境下的可用性检查,任何时候都可以调用检查,返回errMsg:""正常,"失败原因"
+ //envInfo={envName:"H5",canProcess:true}
+ var errMsg,This=this,set=This.set;
+
+ //检测CPU的数字字节序,TypedArray字节序是个迷,直接拒绝罕见的大端模式,因为找不到这种CPU进行测试
+ var tag="CPU_BE";
+ if(!errMsg && !Recorder[tag] && window.Int8Array && !new Int8Array(new Int32Array([1]).buffer)[0]){
+ errMsg="不支持"+tag+"架构";
+ };
+
+ //编码器检查环境下配置是否可用
+ if(!errMsg){
+ var type=set.type;
+ if(This[type+"_envCheck"]){//编码器已实现环境检查
+ errMsg=This[type+"_envCheck"](envInfo,set);
+ }else{//未实现检查的手动检查配置是否有效
+ if(set.takeoffEncodeChunk){
+ errMsg=type+"类型"+(This[type]?"":"(未加载编码器)")+"不支持设置takeoffEncodeChunk";
+ };
+ };
+ };
+
+ return errMsg||"";
+ }
+ ,envStart:function(mockEnvInfo,sampleRate){//平台环境相关的start调用
+ var This=this,set=This.set;
+ This.isMock=mockEnvInfo?1:0;//非H5环境需要启用mock,并提供envCheck需要的环境信息
+ This.mockEnvInfo=mockEnvInfo;
+ This.buffers=[];//数据缓冲
+ This.recSize=0;//数据大小
+
+ This.envInLast=0;//envIn接收到最后录音内容的时间
+ This.envInFirst=0;//envIn接收到的首个录音内容的录制时间
+ This.envInFix=0;//补偿的总时间
+ This.envInFixTs=[];//补偿计数列表
+
+ //engineCtx需要提前确定最终的采样率
+ var setSr=set[sampleRateTxt];
+ if(setSr>sampleRate){
+ set[sampleRateTxt]=sampleRate;
+ }else{ setSr=0 }
+ This[srcSampleRateTxt]=sampleRate;
+ This.CLog(srcSampleRateTxt+": "+sampleRate+" set."+sampleRateTxt+": "+set[sampleRateTxt]+(setSr?" 忽略"+setSr:""), setSr?3:0);
+
+ This.engineCtx=0;
+ //此类型有边录边转码(Worker)支持
+ if(This[set.type+"_start"]){
+ var engineCtx=This.engineCtx=This[set.type+"_start"](set);
+ if(engineCtx){
+ engineCtx.pcmDatas=[];
+ engineCtx.pcmSize=0;
+ };
+ };
+ }
+ ,envResume:function(){//和平台环境无关的恢复录音
+ //重新开始计数
+ this.envInFixTs=[];
+ }
+ ,envIn:function(pcm,sum){//和平台环境无关的pcm[Int16]输入
+ var This=this,set=This.set,engineCtx=This.engineCtx;
+ var bufferSampleRate=This[srcSampleRateTxt];
+ var size=pcm.length;
+ var powerLevel=Recorder.PowerLevel(sum,size);
+
+ var buffers=This.buffers;
+ var bufferFirstIdx=buffers.length;//之前的buffer都是经过onProcess处理好的,不允许再修改
+ buffers.push(pcm);
+
+ //有engineCtx时会被覆盖,这里保存一份
+ var buffersThis=buffers;
+ var bufferFirstIdxThis=bufferFirstIdx;
+
+ //卡顿丢失补偿:因为设备很卡的时候导致H5接收到的数据量不够造成播放时候变速,结果比实际的时长要短,此处保证了不会变短,但不能修复丢失的音频数据造成音质变差。当前算法采用输入时间侦测下一帧是否需要添加补偿帧,需要(6次输入||超过1秒)以上才会开始侦测,如果滑动窗口内丢失超过1/3就会进行补偿
+ var now=Date.now();
+ var pcmTime=Math.round(size/bufferSampleRate*1000);
+ This.envInLast=now;
+ if(This.buffers.length==1){//记下首个录音数据的录制时间
+ This.envInFirst=now-pcmTime;
+ };
+ var envInFixTs=This.envInFixTs;
+ envInFixTs.splice(0,0,{t:now,d:pcmTime});
+ //保留3秒的计数滑动窗口,另外超过3秒的停顿不补偿
+ var tsInStart=now,tsPcm=0;
+ for(var i=0;i3000){
+ envInFixTs.length=i;
+ break;
+ };
+ tsInStart=o.t;
+ tsPcm+=o.d;
+ };
+ //达到需要的数据量,开始侦测是否需要补偿
+ var tsInPrev=envInFixTs[1];
+ var tsIn=now-tsInStart;
+ var lost=tsIn-tsPcm;
+ if( lost>tsIn/3 && (tsInPrev&&tsIn>1000 || envInFixTs.length>=6) ){
+ //丢失过多,开始执行补偿
+ var addTime=now-tsInPrev.t-pcmTime;//距离上次输入丢失这么多ms
+ if(addTime>pcmTime/5){//丢失超过本帧的1/5
+ var fixOpen=!set.disableEnvInFix;
+ This.CLog("["+now+"]"+(fixOpen?"":"未")+"补偿"+addTime+"ms",3);
+ This.envInFix+=addTime;
+
+ //用静默进行补偿
+ if(fixOpen){
+ var addPcm=new Int16Array(addTime*bufferSampleRate/1000);
+ size+=addPcm.length;
+ buffers.push(addPcm);
+ };
+ };
+ };
+
+
+ var sizeOld=This.recSize,addSize=size;
+ var bufferSize=sizeOld+addSize;
+ This.recSize=bufferSize;//此值在onProcess后需要修正,可能新数据被修改
+
+
+ //此类型有边录边转码(Worker)支持,开启实时转码
+ if(engineCtx){
+ //转换成set的采样率
+ var chunkInfo=Recorder.SampleData(buffers,bufferSampleRate,set[sampleRateTxt],engineCtx.chunkInfo);
+ engineCtx.chunkInfo=chunkInfo;
+
+ sizeOld=engineCtx.pcmSize;
+ addSize=chunkInfo.data.length;
+ bufferSize=sizeOld+addSize;
+ engineCtx.pcmSize=bufferSize;//此值在onProcess后需要修正,可能新数据被修改
+
+ buffers=engineCtx.pcmDatas;
+ bufferFirstIdx=buffers.length;
+ buffers.push(chunkInfo.data);
+ bufferSampleRate=chunkInfo[sampleRateTxt];
+ };
+
+ var duration=Math.round(bufferSize/bufferSampleRate*1000);
+ var bufferNextIdx=buffers.length;
+ var bufferNextIdxThis=buffersThis.length;
+
+ //允许异步处理buffer数据
+ var asyncEnd=function(){
+ //重新计算size,异步的早已减去添加的,同步的需去掉本次添加的然后重新计算
+ var num=asyncBegin?0:-addSize;
+ var hasClear=buffers[0]==null;
+ for(var i=bufferFirstIdx;i10 && This.envInFirst-now>1000){ //1秒后开始onProcess性能监测
+ This.CLog(procTxt+"低性能,耗时"+slowT+"ms",3);
+ };
+
+ if(asyncBegin===true){
+ //开启了异步模式,onProcess已接管buffers新数据,立即清空,避免出现未处理的数据
+ var hasClear=0;
+ for(var i=bufferFirstIdx;i"+res.length+" 花:"+(Date.now()-t1)+"ms");
+
+ setTimeout(function(){
+ t1=Date.now();
+ This[set.type](res,function(blob){
+ ok(blob,duration);
+ },function(msg){
+ err(msg);
+ });
+ });
+ }
+
+};
+
+if(window[RecTxt]){
+ CLog("重复引入"+RecTxt,3);
+ window[RecTxt].Destroy();
+};
+window[RecTxt]=Recorder;
+
+
+
+
+//=======从WebM字节流中提取pcm数据,提取成功返回Float32Array,失败返回null||-1=====
+var WebM_Extract=function(inBytes, scope){
+ if(!scope.pos){
+ scope.pos=[0]; scope.tracks={}; scope.bytes=[];
+ };
+ var tracks=scope.tracks, position=[scope.pos[0]];
+ var endPos=function(){ scope.pos[0]=position[0] };
+
+ var sBL=scope.bytes.length;
+ var bytes=new Uint8Array(sBL+inBytes.length);
+ bytes.set(scope.bytes); bytes.set(inBytes,sBL);
+ scope.bytes=bytes;
+
+ //先读取文件头和Track信息
+ if(!scope._ht){
+ readMatroskaVInt(bytes, position);//EBML Header
+ readMatroskaBlock(bytes, position);//跳过EBML Header内容
+ if(!BytesEq(readMatroskaVInt(bytes, position), [0x18,0x53,0x80,0x67])){
+ return;//未识别到Segment
+ }
+ readMatroskaVInt(bytes, position);//跳过Segment长度值
+ while(position[0]1){//多声道,提取一个声道
+ var arr2=[];
+ for(var i=0;i=arr.length)return;
+ var b0=arr[i],b2=("0000000"+b0.toString(2)).substr(-8);
+ var m=/^(0*1)(\d*)$/.exec(b2);
+ if(!m)return;
+ var len=m[1].length, val=[];
+ if(i+len>arr.length)return;
+ for(var i2=0;i2arr.length)return;
+ for(var i2=0;i2 {
+ isDarkMode.value = !isDarkMode.value;
+ if (isDarkMode.value) {
+ document.documentElement.classList.add('dark');
+ localStorage.setItem('darkMode', 'true');
+ } else {
+ document.documentElement.classList.remove('dark');
+ localStorage.setItem('darkMode', 'false');
+ }
+ };
+
+ const initializeDarkMode = () => {
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ const savedMode = localStorage.getItem('darkMode');
+ if (savedMode === 'true' || (savedMode === null && prefersDark)) {
+ isDarkMode.value = true;
+ document.documentElement.classList.add('dark');
+ } else {
+ isDarkMode.value = false;
+ document.documentElement.classList.remove('dark');
+ }
+ };
+
+ return {
+ isDarkMode,
+ toggleDarkMode,
+ initializeDarkMode
+ };
+}
+
+// Color Scheme Composition
+function useColorScheme() {
+ const showColorSchemeModal = Vue.ref(false);
+ const currentColorScheme = Vue.ref('blue');
+ const isDarkMode = Vue.ref(false);
+
+ const colorSchemes = {
+ light: [
+ { id: 'blue', name: 'Ocean Blue', description: 'Classic blue theme with professional appeal', accent: '#3b82f6', hover: '#2563eb' },
+ { id: 'emerald', name: 'Forest Emerald', description: 'Fresh green theme for a natural feel', accent: '#10b981', hover: '#059669' },
+ { id: 'purple', name: 'Royal Purple', description: 'Elegant purple theme with sophistication', accent: '#8b5cf6', hover: '#7c3aed' },
+ { id: 'rose', name: 'Sunset Rose', description: 'Warm pink theme with gentle energy', accent: '#f43f5e', hover: '#e11d48' },
+ { id: 'amber', name: 'Golden Amber', description: 'Warm yellow theme for brightness', accent: '#f59e0b', hover: '#d97706' },
+ { id: 'teal', name: 'Ocean Teal', description: 'Cool teal theme for tranquility', accent: '#06b6d4', hover: '#0891b2' }
+ ],
+ dark: [
+ { id: 'blue', name: 'Midnight Blue', description: 'Deep blue for focused night work', accent: '#60a5fa', hover: '#3b82f6' },
+ { id: 'emerald', name: 'Emerald Night', description: 'Rich green for comfortable viewing', accent: '#34d399', hover: '#10b981' },
+ { id: 'purple', name: 'Deep Purple', description: 'Luxurious purple for creative sessions', accent: '#a78bfa', hover: '#8b5cf6' },
+ { id: 'rose', name: 'Crimson', description: 'Bold red-pink for energetic work', accent: '#fb7185', hover: '#f43f5e' },
+ { id: 'amber', name: 'Golden Hour', description: 'Warm amber for reduced eye strain', accent: '#fbbf24', hover: '#f59e0b' },
+ { id: 'teal', name: 'Electric Cyan', description: 'Vibrant cyan for modern aesthetics', accent: '#22d3ee', hover: '#06b6d4' }
+ ]
+ };
+
+ const applyColorScheme = (schemeId) => {
+ const schemes = isDarkMode.value ? colorSchemes.dark : colorSchemes.light;
+ const scheme = schemes.find(s => s.id === schemeId);
+ if (scheme) {
+ // Remove all theme classes
+ const allThemeClasses = [
+ ...colorSchemes.light.map(s => `theme-light-${s.id}`),
+ ...colorSchemes.dark.map(s => `theme-dark-${s.id}`)
+ ].filter(c => !c.includes('blue')); // blue is the default, no class needed
+
+ document.documentElement.classList.remove(...allThemeClasses);
+
+ // Apply new theme class if not blue (default)
+ if (schemeId !== 'blue') {
+ const themeClass = `theme-${isDarkMode.value ? 'dark' : 'light'}-${schemeId}`;
+ document.documentElement.classList.add(themeClass);
+ }
+
+ // Don't set CSS variables - let the theme classes handle all colors
+ localStorage.setItem('colorScheme', schemeId);
+ currentColorScheme.value = schemeId;
+ }
+ };
+
+ const initializeColorScheme = (darkMode) => {
+ isDarkMode.value = darkMode;
+ const savedScheme = localStorage.getItem('colorScheme') || 'blue';
+ currentColorScheme.value = savedScheme;
+ applyColorScheme(savedScheme);
+ };
+
+ // Watch for dark mode changes and reapply color scheme
+ Vue.watch(() => isDarkMode.value, (newValue) => {
+ applyColorScheme(currentColorScheme.value);
+ });
+
+ const openColorSchemeModal = () => {
+ showColorSchemeModal.value = true;
+ };
+
+ const closeColorSchemeModal = () => {
+ showColorSchemeModal.value = false;
+ };
+
+ const selectColorScheme = (schemeId) => {
+ applyColorScheme(schemeId);
+ const scheme = colorSchemes[isDarkMode.value ? 'dark' : 'light'].find(s => s.id === schemeId);
+ if (window.showToast && scheme) {
+ window.showToast(`Applied ${scheme.name} theme`, 'fa-palette');
+ }
+ };
+
+ const resetColorScheme = () => {
+ applyColorScheme('blue');
+ if (window.showToast) {
+ const defaultScheme = colorSchemes[isDarkMode.value ? 'dark' : 'light'].find(s => s.id === 'blue');
+ window.showToast(`Reset to default ${defaultScheme?.name || 'Ocean Blue'} theme`, 'fa-undo');
+ }
+ };
+
+ return {
+ showColorSchemeModal,
+ currentColorScheme,
+ colorSchemes,
+ openColorSchemeModal,
+ closeColorSchemeModal,
+ selectColorScheme,
+ resetColorScheme,
+ applyColorScheme,
+ initializeColorScheme
+ };
+}
+
+// Shared Transcripts Modal Composition
+function useSharesModal() {
+ const showSharesListModal = Vue.ref(false);
+ const userShares = Vue.ref([]);
+ const isLoadingShares = Vue.ref(false);
+
+ const openSharesList = async () => {
+ isLoadingShares.value = true;
+ showSharesListModal.value = true;
+ try {
+ const response = await fetch('/tool/speakr/api/shares');
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || 'Failed to load shared items');
+ userShares.value = data;
+ } catch (error) {
+ if (window.setGlobalError) {
+ window.setGlobalError(`Failed to load shared items: ${error.message}`);
+ } else {
+ console.error('Failed to load shared items:', error);
+ }
+ } finally {
+ isLoadingShares.value = false;
+ }
+ };
+
+ const closeSharesList = () => {
+ showSharesListModal.value = false;
+ };
+
+ const copyShareLink = async (shareId) => {
+ const url = `${window.location.origin}/tool/speakr/share/${shareId}`;
+ try {
+ await navigator.clipboard.writeText(url);
+ if (window.showToast) {
+ window.showToast('Share link copied to clipboard', 'fa-link');
+ }
+ } catch (err) {
+ if (window.setGlobalError) {
+ window.setGlobalError('Failed to copy link to clipboard');
+ }
+ }
+ };
+
+ const deleteShare = async (shareId) => {
+ if (!confirm('Are you sure you want to delete this share?')) return;
+
+ try {
+ const response = await fetch(`/tool/speakr/api/shares/${shareId}`, {
+ method: 'DELETE',
+ headers: {
+ 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
+ }
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to delete share');
+ }
+
+ userShares.value = userShares.value.filter(share => share.id !== shareId);
+ if (window.showToast) {
+ window.showToast('Share deleted successfully', 'fa-trash');
+ }
+ } catch (error) {
+ if (window.setGlobalError) {
+ window.setGlobalError(`Failed to delete share: ${error.message}`);
+ }
+ }
+ };
+
+ return {
+ showSharesListModal,
+ userShares,
+ isLoadingShares,
+ openSharesList,
+ closeSharesList,
+ copyShareLink,
+ deleteShare
+ };
+}
+
+// User Menu Composition
+function useUserMenu() {
+ const isUserMenuOpen = Vue.ref(false);
+
+ const toggleUserMenu = () => {
+ isUserMenuOpen.value = !isUserMenuOpen.value;
+ };
+
+ const closeUserMenu = () => {
+ isUserMenuOpen.value = false;
+ };
+
+ // Close menu when clicking outside
+ Vue.onMounted(() => {
+ const handleClickOutside = (e) => {
+ const userMenuButton = e.target.closest('button[class*="flex items-center gap"]');
+ const userMenuDropdown = e.target.closest('div[class*="absolute right-0"]');
+ const isUserMenuButtonClick = userMenuButton && userMenuButton.querySelector('i.fa-user-circle');
+
+ if (!isUserMenuButtonClick && !userMenuDropdown) {
+ isUserMenuOpen.value = false;
+ }
+ };
+
+ document.addEventListener('click', handleClickOutside);
+
+ Vue.onUnmounted(() => {
+ document.removeEventListener('click', handleClickOutside);
+ });
+ });
+
+ return {
+ isUserMenuOpen,
+ toggleUserMenu,
+ closeUserMenu
+ };
+}
+
+// Export for use in Vue components
+window.SharedComponents = {
+ useDarkMode,
+ useColorScheme,
+ useSharesModal,
+ useUserMenu
+};
\ No newline at end of file
diff --git a/speakr/static/js/wav.js b/speakr/static/js/wav.js
new file mode 100644
index 0000000..cdc7c57
--- /dev/null
+++ b/speakr/static/js/wav.js
@@ -0,0 +1,86 @@
+/*
+wav编码器+编码引擎
+https://github.com/xiangyuecn/Recorder
+
+当然最佳推荐使用mp3、wav格式,代码也是优先照顾这两种格式
+浏览器支持情况
+https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
+
+编码原理:给pcm数据加上一个44直接的wav头即成wav文件;pcm数据就是Recorder中的buffers原始数据(重新采样),16位时为LE小端模式(Little Endian),实质上是未经过任何编码处理
+*/
+(function(){
+"use strict";
+
+Recorder.prototype.enc_wav={
+ stable:true
+ ,testmsg:"支持位数8位、16位(填在比特率里面),采样率取值无限制"
+};
+Recorder.prototype.wav=function(res,True,False){
+ var This=this,set=This.set
+ ,size=res.length
+ ,sampleRate=set.sampleRate
+ ,bitRate=set.bitRate==8?8:16;
+
+ //编码数据 https://github.com/mattdiamond/Recorderjs https://www.cnblogs.com/blqw/p/3782420.html https://www.cnblogs.com/xiaoqi/p/6993912.html
+ var dataLength=size*(bitRate/8);
+ var buffer=new ArrayBuffer(44+dataLength);
+ var data=new DataView(buffer);
+
+ var offset=0;
+ var writeString=function(str){
+ for (var i=0;i>8)+128;
+ data.setInt8(offset,val,true);
+ };
+ }else{
+ for (var i=0;i queueWarningThreshold) {
+ console.warn(`⚠️ WebSocket发送队列堆积: ${sendQueue.length} 个数据包待发送`);
+ }
+ }
+
+ this.wsStart = function (e) {
+ if (speechSokt) {
+ speechSokt.close();
+ }
+ //------------------- FunASR 连接模式选择 start------------------
+ // app.js 中 rec 和 rec2 可能连接同一个 /websocket_offline 地址。
+ // online/offline 的区别不靠 URL,而靠 onOpen() 握手 JSON 中的 mode 字段:
+ // wsStart() 不传参数 => mode:"online"; wsStart(url) 传参 => mode:"offline"。
+ var Uri = e ? e : `wss://${wssBaseUrl.FUNASR_WEBSOCKET_IP}/websocket_offline`;
+ if (e) {
+ modeType = "offline";
+ } else {
+ modeType = "online";
+ }
+ //--------------------FunASR 连接模式选择 end------------------
+ if (Uri.match(/wss:\S*|ws:\S*/)) {
+ console.log("Uri" + Uri);
+ } else {
+ alert("请检查wss地址正确性");
+ return 0;
+ }
+
+ if ("WebSocket" in window) {
+ speechSokt = new WebSocket(Uri); // 定义socket连接对象
+ speechSokt.onopen = function (e) {
+ onOpen(e);
+ }; // 定义响应函数
+ speechSokt.onclose = function (e) {
+ console.log("onclose ws!");
+ //speechSokt.close();
+ onClose(e);
+ };
+ speechSokt.onmessage = function (e) {
+ onMessage(e);
+ };
+ speechSokt.onerror = function (e) {
+ onError(e);
+ };
+ return 1;
+ } else {
+ alert("当前浏览器不支持 WebSocket");
+ return 0;
+ }
+ };
+
+ // 定义停止与发送函数
+ this.wsStop = function () {
+ if (speechSokt != undefined) {
+ console.log("stop ws!");
+ speechSokt.close();
+ }
+ // 清空发送队列
+ sendQueue = [];
+ isSending = false;
+ };
+
+ // 处理发送队列
+ function processQueue() {
+ if (
+ isSending ||
+ sendQueue.length === 0 ||
+ !speechSokt ||
+ speechSokt.readyState !== 1
+ ) {
+ return;
+ }
+
+ isSending = true;
+ const data = sendQueue.shift();
+
+ try {
+ speechSokt.send(data);
+ isSending = false;
+
+ // 队列警告
+ if (sendQueue.length > queueWarningThreshold) {
+ console.warn(`⚠️ WebSocket发送队列堆积: ${sendQueue.length} 个数据包待发送`);
+ }
+
+ // 继续处理队列
+ if (sendQueue.length > 0) {
+ // 使用 setTimeout 避免阻塞
+ setTimeout(() => processQueue(), 0);
+ }
+ } catch (e) {
+ console.error("❌ WebSocket发送失败,数据已放回队列:", e);
+ sendQueue.unshift(data); // 放回队列头部
+ isSending = false;
+ trimQueueIfNeeded();
+ }
+ }
+
+ this.wsSend = function (oneData) {
+ if (speechSokt == undefined) {
+ // WebSocket未初始化,缓存数据
+ enqueueData(oneData);
+ return true;
+ }
+
+ if (speechSokt.readyState === 1) {
+ // WebSocket已打开
+ if (sendQueue.length > 0) {
+ // 队列中有数据,先加入队列
+ enqueueData(oneData);
+ processQueue();
+ return true;
+ } else {
+ // 队列为空,尝试直接发送
+ try {
+ speechSokt.send(oneData);
+ return true;
+ } catch (e) {
+ console.error("❌ WebSocket直接发送失败,加入队列:", e);
+ enqueueData(oneData);
+ processQueue();
+ return true;
+ }
+ }
+ } else if (speechSokt.readyState === 0) {
+ // WebSocket正在连接,缓存数据
+ enqueueData(oneData);
+ return true;
+ } else {
+ // WebSocket已关闭或关闭中,丢弃数据并警告(节流 5 秒)
+ var now = Date.now();
+ if (now - lastDisconnWarnTime > 5000) {
+ console.warn("⚠️ WebSocket未连接,数据无法发送 (state:", speechSokt.readyState, ")");
+ lastDisconnWarnTime = now;
+ }
+ return false;
+ }
+ };
+
+ // 获取队列状态(用于调试)
+ this.getQueueStatus = function () {
+ return {
+ length: sendQueue.length,
+ isSending: isSending,
+ readyState: speechSokt ? speechSokt.readyState : -1
+ };
+ };
+
+ async function getBaseHot(params) {
+ try {
+ const response = await fetch(`/tool/speakr/api/hotwords`, {
+ method: "GET",
+ });
+ if (!response.ok) {
+ // 请求失败时返回空
+ console.warn("getBaseHot 请求失败,使用默认 hotwords");
+ return "{}";
+ }
+
+ const data = await response.json();
+
+ const resultObj = data.hotwords.reduce((obj, item) => {
+ obj[item.keyword] = item.weight;
+ return obj;
+ }, {});
+
+ return JSON.stringify(resultObj);
+
+ } catch (err) {
+ // 发生异常时返回空
+ console.error("getBaseHot 调用出错:", err);
+ return "";
+ }
+}
+
+ // SOCEKT连接中的消息与状态响应
+ async function onOpen(e) {
+ console.log(e, "e");
+
+ // 发送json
+ var chunk_size = new Array(10, 10, 10);
+ var request = {
+ chunk_size: chunk_size,
+ wav_name: "h5",
+ is_speaking: true,
+ chunk_interval: 10,
+ itn: true, // 是否使用ITN
+ // "mode":"2pass",// 模型模式
+ // 对应 app.js 的 rec/rec2 双链路:同地址下通过 mode 区分在线预览和离线正式结果。
+ mode: modeType,
+ hotwords: await getBaseHot(),
+ };
+ console.log(request, "request");
+
+ if (isfilemode) {
+ request.wav_format = file_ext;
+ if (file_ext == "wav") {
+ request.wav_format = "PCM";
+ request.audio_fs = file_sample_rate;
+ }
+ }
+
+ // var hotwords=getBaseHot();
+ // console.log(hotwords);
+
+ // if(hotwords!=null )
+ // {
+ // request.hotwords=hotwords;
+ // }
+ // console.log(JSON.stringify(request),'热词');
+ speechSokt.send(JSON.stringify(request));
+ console.log("连接成功");
+ stateHandle(0);
+
+ // 连接成功后,处理队列中积压的数据
+ if (sendQueue.length > 0) {
+ console.log(`📤 处理连接前积压的 ${sendQueue.length} 个数据包`);
+ processQueue();
+ }
+ }
+
+ function onClose(e) {
+ stateHandle(1);
+ }
+
+ function onMessage(e) {
+ msgHandle(e);
+ }
+
+ function onError(e) {
+ // info_div.innerHTML="连接"+e;
+ console.log(e);
+ stateHandle(2);
+ }
+ //--------------------FunASR WebSocket 连接器 end------------------
+}
diff --git a/speakr/static/js/wsconnecter.js b/speakr/static/js/wsconnecter.js
new file mode 100644
index 0000000..5c24d95
--- /dev/null
+++ b/speakr/static/js/wsconnecter.js
@@ -0,0 +1,278 @@
+/**
+ * Realtime ASR websocket client.
+ * Browser audio is sent only to the Speakr backend proxy.
+ */
+
+function getBackendAsrWebSocketUrl(mode) {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const basePath = "/tool/speakr/ws/asr/live";
+ return `${protocol}//${window.location.host}${basePath}?mode=${encodeURIComponent(mode || "online")}`;
+}
+
+function WebSocketConnectMethod(config) {
+ //定义socket连接方法类
+
+ var speechSokt;
+ var connKeeperID;
+
+ var msgHandle = config.msgHandle;
+ var stateHandle = config.stateHandle;
+ let modeType = "offline";
+
+ // 发送队列和流量控制
+ var sendQueue = [];
+ var isSending = false;
+ var maxQueueSize = 1000; // 最大队列长度,防止内存溢出
+ var queueWarningThreshold = 500; // 队列警告阈值
+ var lastDisconnWarnTime = 0; // WebSocket未连接警告节流
+ var gracefulStopResolver = null;
+ var gracefulStopTimer = null;
+ function trimQueueIfNeeded() {
+ if (sendQueue.length <= maxQueueSize) {
+ return false;
+ }
+
+ const dropCount = sendQueue.length - maxQueueSize;
+ console.warn(
+ `⚠️ 发送队列超过最大限制 (${maxQueueSize}),丢弃最旧的 ${dropCount} 个数据包`
+ );
+ sendQueue = sendQueue.slice(-maxQueueSize);
+ return true;
+ }
+
+ function enqueueData(data) {
+ sendQueue.push(data);
+ trimQueueIfNeeded();
+
+ if (sendQueue.length > queueWarningThreshold) {
+ console.warn(`⚠️ WebSocket发送队列堆积: ${sendQueue.length} 个数据包待发送`);
+ }
+ }
+
+ this.wsStart = function (e) {
+ if (speechSokt) {
+ speechSokt.close();
+ }
+ if (e === "offline" || (e && e !== "online")) {
+ modeType = "offline";
+ } else {
+ modeType = "online";
+ }
+ var Uri = getBackendAsrWebSocketUrl(modeType);
+ if (Uri.match(/wss:\S*|ws:\S*/)) {
+ console.log("Uri" + Uri);
+ } else {
+ alert("请检查wss地址正确性");
+ return 0;
+ }
+
+ if ("WebSocket" in window) {
+ speechSokt = new WebSocket(Uri); // 定义socket连接对象
+ speechSokt.onopen = function (e) {
+ onOpen(e);
+ }; // 定义响应函数
+ speechSokt.onclose = function (e) {
+ console.log("onclose ws!");
+ onClose(e);
+ };
+ speechSokt.onmessage = function (e) {
+ onMessage(e);
+ };
+ speechSokt.onerror = function (e) {
+ onError(e);
+ };
+ return 1;
+ } else {
+ alert("当前浏览器不支持 WebSocket");
+ return 0;
+ }
+ };
+
+ // 定义停止与发送函数
+ function resolveGracefulStop() {
+ if (gracefulStopTimer) {
+ clearTimeout(gracefulStopTimer);
+ gracefulStopTimer = null;
+ }
+ if (gracefulStopResolver) {
+ gracefulStopResolver();
+ gracefulStopResolver = null;
+ }
+ }
+
+ function closeSocketNow() {
+ if (speechSokt != undefined) {
+ try {
+ speechSokt.close();
+ } catch (e) {
+ console.warn("Failed to close WebSocket", e);
+ }
+ speechSokt = undefined;
+ }
+ sendQueue = [];
+ isSending = false;
+ }
+
+ this.wsStop = function () {
+ if (speechSokt != undefined) {
+ console.log("stop ws!");
+ try {
+ if (speechSokt.readyState === 1) {
+ speechSokt.send(JSON.stringify({ type: "stop", is_speaking: false }));
+ }
+ } catch (e) {
+ console.warn("Failed to send WebSocket stop frame", e);
+ }
+ }
+ resolveGracefulStop();
+ closeSocketNow();
+ };
+
+ this.wsFinish = function (timeoutMs = 3000) {
+ if (speechSokt == undefined || speechSokt.readyState >= 2) {
+ resolveGracefulStop();
+ return Promise.resolve();
+ }
+
+ sendQueue = [];
+ isSending = false;
+
+ return new Promise((resolve) => {
+ gracefulStopResolver = resolve;
+ gracefulStopTimer = setTimeout(() => {
+ // Give the server ownership of the close. Timeout only unblocks UI.
+ resolveGracefulStop();
+ }, timeoutMs);
+
+ try {
+ if (speechSokt.readyState === 1) {
+ speechSokt.send(JSON.stringify({ type: "stop", is_speaking: false }));
+ }
+ } catch (e) {
+ console.warn("Failed to send WebSocket finish frame", e);
+ resolveGracefulStop();
+ }
+ });
+ };
+
+ // 处理发送队列
+ function processQueue() {
+ if (
+ isSending ||
+ sendQueue.length === 0 ||
+ !speechSokt ||
+ speechSokt.readyState !== 1
+ ) {
+ return;
+ }
+
+ isSending = true;
+ const data = sendQueue.shift();
+
+ try {
+ speechSokt.send(data);
+ isSending = false;
+
+ // 队列警告
+ if (sendQueue.length > queueWarningThreshold) {
+ console.warn(`⚠️ WebSocket发送队列堆积: ${sendQueue.length} 个数据包待发送`);
+ }
+
+ // 继续处理队列
+ if (sendQueue.length > 0) {
+ // 使用 setTimeout 避免阻塞
+ setTimeout(() => processQueue(), 0);
+ }
+ } catch (e) {
+ console.error("❌ WebSocket发送失败,数据已放回队列:", e);
+ sendQueue.unshift(data); // 放回队列头部
+ isSending = false;
+ trimQueueIfNeeded();
+ }
+ }
+
+ this.wsSend = function (oneData) {
+ if (speechSokt == undefined) {
+ // WebSocket未初始化,缓存数据
+ enqueueData(oneData);
+ return true;
+ }
+
+ if (speechSokt.readyState === 1) {
+ // WebSocket已打开
+ if (sendQueue.length > 0) {
+ // 队列中有数据,先加入队列
+ enqueueData(oneData);
+ processQueue();
+ return true;
+ } else {
+ // 队列为空,尝试直接发送
+ try {
+ speechSokt.send(oneData);
+ return true;
+ } catch (e) {
+ console.error("❌ WebSocket直接发送失败,加入队列:", e);
+ enqueueData(oneData);
+ processQueue();
+ return true;
+ }
+ }
+ } else if (speechSokt.readyState === 0) {
+ // WebSocket正在连接,缓存数据
+ enqueueData(oneData);
+ return true;
+ } else {
+ // WebSocket已关闭或关闭中,丢弃数据并警告(节流 5 秒)
+ var now = Date.now();
+ if (now - lastDisconnWarnTime > 5000) {
+ console.warn("⚠️ WebSocket未连接,数据无法发送 (state:", speechSokt.readyState, ")");
+ lastDisconnWarnTime = now;
+ }
+ return false;
+ }
+ };
+
+ // 获取队列状态(用于调试)
+ this.getQueueStatus = function () {
+ return {
+ length: sendQueue.length,
+ isSending: isSending,
+ readyState: speechSokt ? speechSokt.readyState : -1
+ };
+ };
+
+
+ // SOCEKT连接中的消息与状态响应
+ function onOpen(e) {
+ console.log(e, "e");
+ console.log("WebSocket connected to backend ASR proxy");
+ stateHandle(0);
+
+ if (sendQueue.length > 0) {
+ console.log(`Processing ${sendQueue.length} queued audio chunks`);
+ processQueue();
+ }
+ }
+
+ function onClose(e) {
+ resolveGracefulStop();
+ speechSokt = undefined;
+ stateHandle(1);
+ }
+
+ function onMessage(e) {
+ msgHandle(e);
+ try {
+ const payload = JSON.parse(e.data);
+ if (payload && payload.type === "state" && payload.state === "closed") {
+ resolveGracefulStop();
+ }
+ } catch (_) {}
+ }
+
+ function onError(e) {
+ // info_div.innerHTML="连接"+e;
+ console.log(e);
+ stateHandle(2);
+ }
+}
diff --git a/speakr/static/locales/de.json b/speakr/static/locales/de.json
new file mode 100644
index 0000000..3fcc4fc
--- /dev/null
+++ b/speakr/static/locales/de.json
@@ -0,0 +1,863 @@
+{
+ "aboutPage": {
+ "aiSummarization": "KI-Zusammenfassung",
+ "aiSummarizationDesc": "OpenRouter- und Ollama-Integration mit benutzerdefinierten Prompts",
+ "asrEnabled": "ASR Aktiviert",
+ "asrEndpoint": "ASR-Endpunkt",
+ "audioTranscription": "Audio-Transkription",
+ "audioTranscriptionDesc": "Whisper API und benutzerdefinierte ASR-Unterstützung mit hoher Genauigkeit",
+ "backend": "Backend",
+ "database": "Datenbank",
+ "deployment": "Bereitstellung",
+ "dockerDescription": "Offizielle Docker Images",
+ "dockerHub": "Docker Hub",
+ "documentation": "Dokumentation",
+ "documentationDescription": "Setup-Anleitungen und Benutzerhandbuch",
+ "endpoint": "Endpunkt",
+ "frontend": "Frontend",
+ "githubDescription": "Quellcode, Issues und Releases",
+ "githubRepository": "GitHub Repository",
+ "inquireMode": "Anfrage-Modus",
+ "inquireModeDesc": "Semantische Suche in all Ihren Aufnahmen",
+ "interactiveChat": "Interaktiver Chat",
+ "interactiveChatDesc": "Chatten Sie mit Ihren Transkriptionen mittels KI",
+ "keyFeatures": "Hauptfunktionen",
+ "largeLanguageModel": "Großes Sprachmodell",
+ "model": "Modell",
+ "projectDescription": "Verwandeln Sie Ihre Audioaufnahmen in organisierte, durchsuchbare Notizen mit KI-gestützten Funktionen für Transkription, Zusammenfassung und interaktiven Chat.",
+ "projectLinks": "Projekt-Links",
+ "sharingExport": "Teilen & Exportieren",
+ "sharingExportDesc": "Teilen Sie Aufnahmen und exportieren Sie in verschiedene Formate",
+ "speakerDiarization": "Sprecher-Diarisierung",
+ "speakerDiarizationDesc": "Automatische Identifikation und Kennzeichnung verschiedener Sprecher",
+ "speechRecognition": "Spracherkennung",
+ "systemConfiguration": "Systemkonfiguration",
+ "tagline": "KI-gestützte Audio-Transkription und Notizen-App",
+ "technologyStack": "Technologie-Stack",
+ "title": "Über",
+ "version": "Version",
+ "whisperApi": "Whisper API"
+ },
+ "aboutPageDetails": {
+ "aiSummarizationDesc": "OpenRouter- und Ollama-Integration mit benutzerdefinierten Prompts",
+ "asrEnabled": "ASR Aktiviert",
+ "asrEndpoint": "ASR-Endpunkt",
+ "audioTranscriptionDesc": "Whisper-API und benutzerdefinierte ASR-Unterstützung mit hoher Genauigkeit",
+ "backend": "Backend",
+ "database": "Datenbank",
+ "deployment": "Bereitstellung",
+ "dockerDescription": "Offizielle Docker-Images",
+ "documentationDescription": "Setup-Anleitungen und Benutzerhandbuch",
+ "endpoint": "Endpunkt",
+ "frontend": "Frontend",
+ "githubDescription": "Quellcode, Issues und Releases",
+ "inquireModeDesc": "Semantische Suche in all Ihren Aufnahmen",
+ "interactiveChatDesc": "Chatten Sie mit Ihren Transkriptionen mithilfe von KI",
+ "model": "Modell",
+ "no": "Nein",
+ "sharingExportDesc": "Aufnahmen teilen und in verschiedene Formate exportieren",
+ "speakerDiarizationDesc": "Verschiedene Sprecher automatisch identifizieren und beschriften",
+ "whisperApi": "Whisper-API",
+ "yes": "Ja"
+ },
+ "account": {
+ "accountActions": "Kontoaktionen",
+ "changePassword": "Passwort ändern",
+ "chooseLanguageForInterface": "Wählen Sie die Sprache für die Anwendungsoberfläche",
+ "companyOrganization": "Unternehmen / Organisation",
+ "completedRecordings": "Abgeschlossen",
+ "email": "E-Mail",
+ "failedRecordings": "Fehlgeschlagen",
+ "fullName": "Vollständiger Name",
+ "goToRecordings": "Zu den Aufnahmen gehen",
+ "interfaceLanguage": "Interface-Sprache",
+ "jobTitle": "Berufsbezeichnung",
+ "languageForSummaries": "Sprache für Titel, Zusammenfassungen und Chat. Leer lassen für Standard (Standardverhalten Ihrer gewählten Modelle).",
+ "languagePreferences": "Spracheinstellungen",
+ "leaveBlankForAutoDetect": "Leer lassen für automatische Erkennung durch den Transkriptionsdienst",
+ "manageSpeakers": "Sprecher verwalten",
+ "personalInfo": "Persönliche Informationen",
+ "preferredOutputLanguage": "Bevorzugte Sprache für Chatbot und Zusammenfassungen",
+ "processingRecordings": "In Bearbeitung",
+ "saveAllPreferences": "Alle Einstellungen speichern",
+ "statistics": "Kontostatistiken",
+ "title": "Kontoinformationen",
+ "totalRecordings": "Gesamte Aufnahmen",
+ "transcriptionLanguage": "Transkriptionssprache",
+ "userDetails": "Benutzerdetails",
+ "username": "Benutzername"
+ },
+ "accountTabs": {
+ "about": "Über",
+ "customPrompts": "Benutzerdefinierte Prompts",
+ "sharedTranscripts": "Geteilte Transkripte",
+ "speakersManagement": "Sprecher-Verwaltung",
+ "tagManagement": "Tag-Verwaltung",
+ "transcriptTemplates": "Transkript-Vorlagen",
+ "promptOptions": "Prompt-Optionen"
+ },
+ "transcriptTemplates": {
+ "title": "Transkript-Vorlagen",
+ "description": "Anpassen, wie Transkripte für Download und Export formatiert werden.",
+ "createNew": "Vorlage Erstellen",
+ "availableTemplates": "Verfügbare Vorlagen",
+ "createDefaults": "Standardvorlagen Erstellen",
+ "templateName": "Vorlagenname",
+ "template": "Vorlage",
+ "availableVars": "Verfügbare Variablen",
+ "filters": "Filter: |upper für Großbuchstaben, |srt für Untertitelformat",
+ "setDefault": "Als Standardvorlage festlegen",
+ "save": "Speichern",
+ "cancel": "Abbrechen",
+ "delete": "Löschen",
+ "selectOrCreate": "Wählen Sie eine Vorlage zum Bearbeiten oder erstellen Sie eine neue"
+ },
+ "admin": {
+ "title": "Administration",
+ "userMenu": "Benutzermenü"
+ },
+ "adminDashboard": {
+ "aboutInquireMode": "Über den Nachfrage-Modus",
+ "actions": "AKTIONEN",
+ "addNewUser": "Neuen Benutzer Hinzufügen",
+ "addUser": "Benutzer hinzufügen",
+ "additionalContext": "Zusätzlicher Kontext",
+ "admin": "ADMIN",
+ "adminDefaultPrompt": "Admin Standard-Prompt",
+ "adminDefaultPromptDesc": "Der oben konfigurierte Prompt (auf dieser Seite angezeigt)",
+ "adminUser": "Administrator-Benutzer",
+ "allRecordingsProcessed": "Alle Aufnahmen sind verarbeitet!",
+ "available": "Verfügbar",
+ "characters": "Zeichen",
+ "chunkSize": "Chunk-Größe",
+ "complete": "abgeschlossen",
+ "completedRecordings": "Abgeschlossen",
+ "confirmPasswordLabel": "Passwort Bestätigen",
+ "contextNotes": {
+ "jsonConversion": "JSON-Transkripte werden vor dem Senden in ein sauberes Textformat konvertiert",
+ "modelConfig": "Das verwendete Modell wird über die Umgebungsvariable TEXT_MODEL_NAME konfiguriert",
+ "tagPrompts": "Wenn mehrere Tag-Prompts existieren, werden sie in der Reihenfolge zusammengeführt, in der die Tags hinzugefügt wurden",
+ "transcriptLimit": "Transkripte sind auf eine konfigurierbare Zeichenanzahl begrenzt (Standard: 30.000)"
+ },
+ "defaultPromptInfo": "Dieser Standard-Prompt wird für alle Benutzer verwendet, die keinen eigenen benutzerdefinierten Prompt in ihren Kontoeinstellungen festgelegt haben.",
+ "defaultPrompts": "Standard-Prompts",
+ "defaultSummarizationPrompt": "Standard-Zusammenfassungsprompt",
+ "editUser": "Benutzer Bearbeiten",
+ "email": "E-MAIL",
+ "emailLabel": "E-Mail",
+ "embeddingModel": "Einbettungsmodell",
+ "embeddingsStatus": "Einbettungsstatus",
+ "errors": {
+ "failedToLoadPrompt": "Fehler beim Laden des Standard-Prompts",
+ "failedToSavePrompt": "Fehler beim Speichern des Standard-Prompts. Bitte versuchen Sie es erneut.",
+ "invalidFileSize": "Bitte geben Sie eine gültige Größe zwischen 1 und 10000 MB ein",
+ "invalidInteger": "Bitte geben Sie eine gültige Ganzzahl ein",
+ "invalidNumber": "Bitte geben Sie eine gültige Zahl ein",
+ "maxTimeout": "Timeout kann 10 Stunden (36000 Sekunden) nicht überschreiten",
+ "minCharacters": "Bitte geben Sie eine gültige Anzahl von mindestens 1000 Zeichen ein",
+ "minTimeout": "Timeout muss mindestens 60 Sekunden betragen"
+ },
+ "failedRecordings": "Fehlgeschlagen",
+ "id": "ID",
+ "inquireModeDescription": "Der Nachfrage-Modus ermöglicht es Benutzern, mit natürlichen Sprachfragen über mehrere Transkriptionen zu suchen. Es funktioniert, indem Transkriptionen in Chunks aufgeteilt und durchsuchbare Einbettungen mit KI-Modellen erstellt werden.",
+ "languagePreferenceNote": "Hinweis: Wenn der Benutzer eine Ausgabesprachpräferenz festgelegt hat, wird \" Stellen Sie sicher, dass Ihre Antwort in {Sprache} ist.\" hinzugefügt.",
+ "lastUpdated": "Zuletzt aktualisiert",
+ "maxFileSizeHelp": "Maximale Dateigröße in Megabyte (1-10000 MB)",
+ "megabytes": "MB",
+ "minutes": "Minuten",
+ "needProcessing": "Benötigt Verarbeitung",
+ "never": "Nie",
+ "newPasswordLabel": "Neues Passwort (leer lassen, um das aktuelle zu behalten)",
+ "no": "Nein",
+ "noData": "Keine Daten",
+ "noDescriptionAvailable": "Keine Beschreibung verfügbar",
+ "noLimit": "Keine Begrenzung",
+ "notSet": "Nicht festgelegt",
+ "overlap": "Überlappung",
+ "passwordLabel": "Passwort",
+ "pendingRecordings": "Ausstehend",
+ "placeholdersNote": "Platzhalter werden beim Verarbeiten einer Aufnahme durch tatsächliche Werte ersetzt.",
+ "processAllRecordings": "Alle Aufnahmen Verarbeiten",
+ "processNext10": "Nächste 10 Verarbeiten",
+ "processedForInquire": "Für Nachfrage Verarbeitet",
+ "processing": "Verarbeitung läuft...",
+ "processingActions": "Verarbeitungsaktionen",
+ "processingProgress": "Verarbeitungsfortschritt",
+ "processingRecordings": "In Bearbeitung",
+ "promptDescription": "Dieser Prompt wird verwendet, um Zusammenfassungen für alle Aufnahmen zu generieren, wenn Benutzer keinen eigenen Prompt festgelegt haben.",
+ "promptHierarchy": "Prompt-Hierarchie",
+ "promptPriorityDescription": "Das System verwendet die folgende Prioritätsreihenfolge bei der Auswahl des zu verwendenden Prompts:",
+ "promptResetMessage": "Prompt auf Systemstandard zurückgesetzt. Klicken Sie auf \"Änderungen speichern\", um anzuwenden.",
+ "promptSavedSuccessfully": "Standard-Prompt erfolgreich gespeichert!",
+ "recordingStatusDistribution": "Verteilung des Aufnahmestatus",
+ "recordings": "AUFNAHMEN",
+ "recordingsNeedProcessing": "Es gibt {{count}} Aufnahmen, die für den Nachfrage-Modus verarbeitet werden müssen.",
+ "refreshStatus": "Status Aktualisieren",
+ "resetToDefault": "Auf Standard zurücksetzen",
+ "saving": "Speichern...",
+ "searchUsers": "Benutzer suchen...",
+ "seconds": "Sekunden",
+ "settings": {
+ "asrTimeoutDesc": "Maximale Zeit in Sekunden, um auf den Abschluss der ASR-Transkription zu warten. Standard ist 1800 Sekunden (30 Minuten).",
+ "defaultSummaryPromptDesc": "Standard-Zusammenfassungsprompt, der verwendet wird, wenn Benutzer keinen eigenen Prompt festgelegt haben. Dies dient als Basis-Prompt für alle Benutzer.",
+ "maxFileSizeDesc": "Maximale Dateigröße für Audio-Uploads in Megabyte (MB).",
+ "recordingDisclaimerDesc": "Rechtlicher Hinweis, der Benutzern vor Beginn der Aufnahme angezeigt wird. Unterstützt Markdown-Formatierung. Leer lassen zum Deaktivieren.",
+ "transcriptLengthLimitDesc": "Maximale Anzahl von Zeichen, die vom Transkript an das LLM für Zusammenfassung und Chat gesendet werden. Verwenden Sie -1 für kein Limit."
+ },
+ "storageUsed": "GENUTZTER SPEICHER",
+ "summarizationInstructions": "Zusammenfassungsanweisungen",
+ "systemFallback": "System-Fallback",
+ "systemFallbackDesc": "Ein hartcodierter Standard, wenn keiner der oben genannten festgelegt ist",
+ "systemPrompt": "System-Prompt",
+ "systemSettings": "Systemeinstellungen",
+ "systemStatistics": "Systemstatistiken",
+ "tagCustomPrompt": "Tag Benutzerdefinierter Prompt",
+ "tagCustomPromptDesc": "Wenn eine Aufnahme Tags mit benutzerdefinierten Prompts hat",
+ "textSearchOnly": "Nur Textsuche",
+ "timeoutRecommendation": "Empfohlen: 30-120 Minuten für lange Audiodateien",
+ "title": "Administrator-Dashboard",
+ "topUsers": "Top-Benutzer",
+ "topUsersByStorage": "Top-Benutzer nach Speicher",
+ "total": "Gesamt",
+ "totalChunks": "Gesamte Chunks",
+ "totalQueries": "Gesamte Abfragen",
+ "totalRecordings": "Gesamte Aufnahmen",
+ "totalStorage": "Gesamter Speicher",
+ "totalUsers": "Gesamte Benutzer",
+ "updateUser": "Benutzer Aktualisieren",
+ "userCustomPrompt": "Benutzerdefinierter Prompt",
+ "userCustomPromptDesc": "Wenn der Benutzer seinen eigenen Prompt in den Kontoeinstellungen festgelegt hat",
+ "userManagement": "Benutzerverwaltung",
+ "userMessageTemplate": "Benutzernachricht-Vorlage",
+ "username": "BENUTZERNAME",
+ "usernameLabel": "Benutzername",
+ "vectorDimensions": "Vektordimensionen",
+ "vectorStore": "Vektorspeicher",
+ "vectorStoreManagement": "Vektorspeicher-Verwaltung",
+ "vectorStoreUpToDate": "Der Vektorspeicher ist auf dem neuesten Stand.",
+ "viewFullPromptStructure": "Vollständige LLM-Prompt-Struktur anzeigen",
+ "yes": "Ja"
+ },
+ "buttons": {
+ "cancel": "Cancel",
+ "clearAllFilters": "Alle Filter löschen",
+ "clearCompleted": "Abgeschlossene Uploads löschen",
+ "clearSearchText": "Suchtext löschen",
+ "close": "Close",
+ "copyMessage": "Nachricht kopieren",
+ "copyNotes": "Notizen kopieren",
+ "copySummary": "Zusammenfassung kopieren",
+ "copyToClipboard": "In Zwischenablage kopieren",
+ "createTag": "Create Tag",
+ "deleteAll": "Delete All",
+ "deleteSpeaker": "Sprecher löschen",
+ "deleteTag": "Tag löschen",
+ "deleteUser": "Benutzer löschen",
+ "downloadAsWord": "Als Word herunterladen",
+ "downloadChat": "Chat als Word-Dokument herunterladen",
+ "downloadNotes": "Notizen als Word-Dokument herunterladen",
+ "downloadSummary": "Zusammenfassung als Word-Dokument herunterladen",
+ "exportCalendar": "In Kalender exportieren",
+ "editNotes": "Notizen bearbeiten",
+ "editSetting": "Einstellung bearbeiten",
+ "editSummary": "Zusammenfassung bearbeiten",
+ "editTag": "Tag bearbeiten",
+ "editTags": "Tags bearbeiten",
+ "editTranscription": "Transkription bearbeiten",
+ "editUser": "Benutzer bearbeiten",
+ "help": "Hilfe",
+ "identifySpeakers": "Sprecher identifizieren",
+ "refresh": "Refresh",
+ "reprocessSummary": "Zusammenfassung neu verarbeiten",
+ "reprocessTranscription": "Transkription neu verarbeiten",
+ "reprocessWithAsr": "Mit ASR neu verarbeiten",
+ "resetStuckProcessing": "Blockierte Verarbeitung zurücksetzen",
+ "saveChanges": "Änderungen speichern",
+ "saveCustomPrompt": "Save Custom Prompt",
+ "shareRecording": "Aufnahme teilen",
+ "toggleTheme": "Design umschalten",
+ "updateTag": "Update Tag",
+ "saveSettings": "Einstellungen Speichern"
+ },
+ "changePasswordModal": {
+ "confirmPassword": "Neues Passwort Bestätigen",
+ "currentPassword": "Aktuelles Passwort",
+ "newPassword": "Neues Passwort",
+ "passwordRequirement": "Das Passwort muss mindestens 8 Zeichen lang sein",
+ "title": "Passwort Ändern"
+ },
+ "chat": {
+ "availableAfterTranscription": "Chat wird verfügbar sein, sobald die Transkription abgeschlossen ist",
+ "chatWithTranscription": "Mit Transkription chatten",
+ "clearChat": "Chat löschen",
+ "error": "Nachricht konnte nicht gesendet werden",
+ "noMessages": "Noch keine Nachrichten",
+ "placeholder": "Eine Frage zu dieser Aufnahme stellen...",
+ "placeholderWithHint": "Eine Frage zu dieser Aufnahme stellen... (Enter zum Senden, Strg+Enter für neue Zeile)",
+ "send": "Senden",
+ "suggestedQuestions": "Vorgeschlagene Fragen",
+ "thinking": "Denke nach...",
+ "title": "Chat",
+ "whatAreActionItems": "Was sind die Aktionspunkte?",
+ "whatAreKeyPoints": "Was sind die wichtigsten Punkte?",
+ "whatWasDiscussed": "Was wurde besprochen?",
+ "whoSaidWhat": "Wer hat was gesagt?"
+ },
+ "colorScheme": {
+ "chooseRecording": "Wählen Sie eine Aufnahme aus der Seitenleiste, um ihre Transkription und Zusammenfassung zu sehen",
+ "selectRecording": "Eine Aufnahme auswählen",
+ "subtitle": "Passen Sie Ihre Benutzeroberfläche mit schönen Farbthemen an",
+ "title": "Farbschema"
+ },
+ "common": {
+ "back": "Zurück",
+ "cancel": "Abbrechen",
+ "close": "Schließen",
+ "confirm": "Bestätigen",
+ "delete": "Löschen",
+ "deselectAll": "Alle abwählen",
+ "download": "Herunterladen",
+ "edit": "Bearbeiten",
+ "error": "Fehler",
+ "failed": "Fehlgeschlagen",
+ "filter": "Filter",
+ "info": "Info",
+ "loading": "Lädt...",
+ "new": "Neu",
+ "next": "Weiter",
+ "no": "Nein",
+ "noResults": "Keine Ergebnisse gefunden",
+ "ok": "OK",
+ "or": "Oder",
+ "previous": "Vorherige",
+ "processing": "Verarbeite...",
+ "refresh": "Aktualisieren",
+ "retry": "Wiederholen",
+ "save": "Speichern",
+ "search": "Suchen",
+ "selectAll": "Alle auswählen",
+ "sort": "Sortieren",
+ "success": "Erfolgreich",
+ "untitled": "Unbenannt",
+ "upload": "Hochladen",
+ "warning": "Warnung",
+ "yes": "Ja"
+ },
+ "customPrompts": {
+ "currentDefaultPrompt": "Aktueller Standard-Prompt (Wird verwendet, wenn Sie das obige Feld leer lassen)",
+ "promptDescription": "Dieser Prompt wird verwendet, um Zusammenfassungen Ihrer Transkriptionen zu generieren. Er überschreibt den Standardprompt des Administrators.",
+ "promptPlaceholder": "Beschreiben Sie, wie Ihre Zusammenfassungen strukturiert sein sollen. Leer lassen, um den Standard-Prompt des Administrators zu verwenden.",
+ "summaryGeneration": "Zusammenfassungsgenerierungs-Prompt",
+ "tip1": "Seien Sie spezifisch bezüglich der Abschnitte, die Sie in Ihrer Zusammenfassung wünschen",
+ "tip2": "Verwenden Sie klare Formatierungsanweisungen (z.B. \"Verwenden Sie Aufzählungszeichen\", \"Erstellen Sie nummerierte Listen\")",
+ "tip3": "Geben Sie an, wenn Sie möchten, dass bestimmte Informationen priorisiert werden",
+ "tip4": "Das System wird automatisch den Transkriptionsinhalt an die KI weiterleiten",
+ "tip5": "Ihre Ausgabesprachen-Präferenz (falls gesetzt) wird automatisch angewendet",
+ "tipsTitle": "Tipps für das Schreiben Effektiver Prompts",
+ "yourCustomPrompt": "Ihr Benutzerdefinierter Zusammenfassungs-Prompt"
+ },
+ "deleteAllSpeakersModal": {
+ "confirmMessage": "Sind Sie sicher, dass Sie alle gespeicherten Sprecher löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "title": "Alle Sprecher Löschen"
+ },
+ "dialogs": {
+ "deleteRecording": {
+ "cancel": "Abbrechen",
+ "confirm": "Löschen",
+ "message": "Sind Sie sicher, dass Sie diese Aufnahme löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "title": "Aufnahme löschen"
+ },
+ "deleteShare": {
+ "message": "Sind Sie sicher, dass Sie diese Freigabe löschen möchten? Dadurch wird der Zugriff auf den öffentlichen Link widerrufen.",
+ "title": "Freigabe löschen"
+ },
+ "reprocessTranscription": {
+ "cancel": "Abbrechen",
+ "confirm": "Neu verarbeiten",
+ "message": "Sind Sie sicher, dass Sie diese Transkription neu verarbeiten möchten? Die aktuelle Transkription wird ersetzt.",
+ "title": "Transkription neu verarbeiten"
+ },
+ "unsavedChanges": {
+ "cancel": "Abbrechen",
+ "discard": "Verwerfen",
+ "message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese vor dem Verlassen speichern?",
+ "save": "Speichern",
+ "title": "Ungespeicherte Änderungen"
+ }
+ },
+ "duration": {
+ "hours": "{{count}} Stunde",
+ "hoursPlural": "{{count}} Stunden",
+ "minutes": "{{count}} Minute",
+ "minutesPlural": "{{count}} Minuten",
+ "seconds": "{{count}} Sekunde",
+ "secondsPlural": "{{count}} Sekunden"
+ },
+ "editTagModal": {
+ "asrDefaultSettings": "ASR-Standard-Einstellungen",
+ "asrSettingsDescription": "Diese Einstellungen werden standardmäßig angewendet, wenn dieser Tag mit ASR-Transkription verwendet wird",
+ "color": "Farbe",
+ "colorDescription": "Wählen Sie eine Farbe zur einfachen Identifikation",
+ "createTitle": "Tag Erstellen",
+ "customPrompt": "Benutzerdefinierter Zusammenfassungs-Prompt",
+ "customPromptPlaceholder": "Optional: Benutzerdefinierter Prompt für die Generierung von Zusammenfassungen für Aufnahmen mit diesem Tag",
+ "defaultLanguage": "Standard-Sprache",
+ "defaultPromptPlaceholder": "Leer lassen, um Ihren Standard-Zusammenfassungs-Prompt zu verwenden",
+ "leaveBlankPrompt": "Leer lassen, um Ihren Standard-Zusammenfassungs-Prompt zu verwenden",
+ "maxSpeakers": "Maximale Sprecheranzahl",
+ "minSpeakers": "Minimale Sprecheranzahl",
+ "tagName": "Tag-Name *",
+ "tagNamePlaceholder": "z.B. Meetings, Interviews",
+ "title": "Tag Bearbeiten"
+ },
+ "errors": {
+ "audioRecordingFailed": "音频录制失败。请检查您的麦克风。",
+ "fileTooLarge": "文件太大",
+ "generic": "发生错误",
+ "loadingShares": "Fehler beim Laden der Freigaben",
+ "networkError": "网络错误。请检查您的连接。",
+ "notFound": "未找到",
+ "permissionDenied": "权限被拒绝",
+ "quotaExceeded": "存储配额已超出",
+ "serverError": "服务器错误。请稍后重试。",
+ "summaryFailed": "摘要生成失败。请重试。",
+ "transcriptionFailed": "转录失败。请重试。",
+ "unauthorized": "未授权",
+ "unsupportedFormat": "不支持的文件格式",
+ "uploadFailed": "上传失败。请重试。",
+ "validationError": "验证错误"
+ },
+ "fileSize": {
+ "bytes": "{{count}} B",
+ "gigabytes": "{{count}} GB",
+ "kilobytes": "{{count}} KB",
+ "megabytes": "{{count}} MB"
+ },
+ "form": {
+ "auto": "Auto",
+ "autoDetect": "Automatisch erkennen",
+ "dateFrom": "Von",
+ "dateTo": "Bis",
+ "enterNotesMarkdown": "Notizen im Markdown-Format eingeben...",
+ "enterSummaryMarkdown": "Zusammenfassung im Markdown-Format eingeben...",
+ "language": "Sprache",
+ "maxSpeakers": "Max. Sprecher",
+ "meetingDate": "Meeting-Datum",
+ "minSpeakers": "Min. Sprecher",
+ "minutes": "Minuten",
+ "notes": "Notizen",
+ "notesPlaceholder": "Geben Sie Ihre Notizen im Markdown-Format ein...",
+ "optional": "Optional",
+ "participants": "Teilnehmer",
+ "placeholderAuto": "Auto",
+ "placeholderCharacterLimit": "Zeichenlimit eingeben (z.B. 30000)",
+ "placeholderMinutes": "Minuten",
+ "placeholderOptional": "Optional",
+ "placeholderSeconds": "Sekunden",
+ "placeholderSizeMB": "Größe in MB eingeben",
+ "searchSpeakers": "Sprecher suchen...",
+ "searchTags": "Tags suchen...",
+ "seconds": "Sekunden",
+ "shareNotes": "Notizen teilen",
+ "shareSummary": "Zusammenfassung teilen",
+ "shareableLink": "Teilbarer Link",
+ "summaryPromptPlaceholder": "Standard-Zusammenfassungsprompt eingeben...",
+ "title": "Titel",
+ "yourFullName": "Ihr vollständiger Name"
+ },
+ "help": {
+ "actions": "Aktionen",
+ "activeFilters": "Aktive Filter",
+ "advancedAsrOptions": "Erweiterte ASR-Optionen",
+ "allRecordingsLoaded": "Alle Aufnahmen geladen",
+ "approachingLimit": "Nähert sich {{limit}}MB-Limit",
+ "askAboutTranscription": "Fragen zu dieser Transkription stellen",
+ "audioPlayer": "Audio-Player",
+ "autoIdentify": "Automatisch identifizieren",
+ "bothAudioDesc": "Nimmt Ihre Stimme + Meeting-Teilnehmer auf (empfohlen für Online-Meetings)",
+ "clearFilters": "Filter löschen",
+ "clickToAddNotes": "Klicken um Notizen hinzuzufügen...",
+ "colorRepeats": "Farbe wiederholt sich ab Sprecher {{number}}",
+ "completedFiles": "Abgeschlossene Dateien",
+ "confirmReprocessingTitle": "Neu-Verarbeitung bestätigen",
+ "copyMessage": "Nachricht kopieren",
+ "createPublicLink": "Einen öffentlichen Link erstellen, um diese Aufnahme zu teilen. Teilen ist nur bei sicheren (HTTPS) Verbindungen verfügbar.",
+ "createTags": "Tags erstellen",
+ "discard": "Verwerfen",
+ "endTime": "Ende",
+ "enterNameFor": "Namen eingeben für",
+ "entireScreen": "Gesamter Bildschirm",
+ "estimatedSize": "Geschätzte Größe",
+ "generatingSummary": "Generiere Zusammenfassung...",
+ "howToRecordSystemAudio": "Wie man System-Audio aufnimmt",
+ "importantNote": "Wichtiger Hinweis",
+ "lines": "{{count}} Zeilen",
+ "loadingMore": "Lade weitere Aufnahmen...",
+ "loadingRecordings": "Lade Aufnahmen...",
+ "me": "Ich",
+ "microphoneDesc": "Nimmt nur Ihre Stimme auf",
+ "modelReasoning": "Modell-Begründung",
+ "moreSpeakersThanColors": "Mehr Sprecher als verfügbare Farben",
+ "noDateSet": "Kein Datum gesetzt",
+ "noParticipants": "Keine Teilnehmer",
+ "noTagsCreated": "Noch keine Tags erstellt.",
+ "processingTime": "Verarbeitungszeit",
+ "processingTimeDescription": "Dies kann einige Minuten dauern. Sie können die App während der Verarbeitung weiter verwenden.",
+ "processingTranscription": "Verarbeite Transkription...",
+ "recordSystemSteps1": "Klicken Sie auf \"System-Audio aufnehmen\" oder \"Beides aufnehmen\".",
+ "recordSystemSteps2": "Im Popup wählen Sie",
+ "recordSystemSteps3": "Stellen Sie sicher, dass Sie das Kästchen ankreuzen, das sagt",
+ "recordingFinished": "Aufnahme beendet",
+ "recordingInProgress": "Aufnahme läuft...",
+ "regenerateSummaryAfterNames": "Zusammenfassung nach Namens-Update neu generieren",
+ "sentence": "Satz",
+ "shareSystemAudio": "System-Audio teilen",
+ "shareTabAudio": "Tab-Audio teilen",
+ "sharedOn": "Geteilt am",
+ "sharingWindowNoAudio": "Das Teilen eines \"Fensters\" nimmt kein Audio auf.",
+ "speakerCount": "Sprecher",
+ "specificBrowserTab": "spezifischen Browser-Tab",
+ "startTime": "Start",
+ "systemAudioDesc": "Nimmt Meeting-Teilnehmer und Systemklänge auf",
+ "tagManagement": "Tag-Verwaltung",
+ "thisActionCannotBeUndone": "Diese Aktion kann nicht rückgängig gemacht werden.",
+ "toCaptureAudioFromMeetings": "Um Audio von Besprechungen oder anderen Apps aufzunehmen, müssen Sie Ihren Bildschirm oder einen Browser-Tab freigeben.",
+ "troubleshooting": "Fehlerbehebung",
+ "tryAdjustingSearch": "Versuchen Sie Ihre Suche anzupassen oder",
+ "unsupportedBrowser": "Nicht unterstützter Browser",
+ "untitled": "Unbenannte Aufnahme",
+ "uploadRecordingNotes": "Aufnahme und Notizen hochladen",
+ "whatWillHappen": "Was wird passieren?",
+ "whyNotWorking": "Warum funktioniert es nicht?",
+ "speakers": "Sprecher"
+ },
+ "inquire": {
+ "title": "Nachfragen",
+ "filters": "Filter",
+ "clearAll": "Alle löschen",
+ "tags": "Tags",
+ "speakers": "Sprecher",
+ "dateRange": "Datumsbereich",
+ "from": "Von",
+ "to": "Bis",
+ "noSpeakerData": "Keine Sprecherdaten verfügbar",
+ "speakerRequirement": "Sprecheridentifikation erfordert Aufnahmen mit mehreren Sprechern",
+ "activeFilters": "Aktive Filter:",
+ "tagsCount": "Tags",
+ "speakersCount": "Sprecher",
+ "dateRangeActive": "Datumsbereich aktiv",
+ "filtersActive": "Filter aktiv",
+ "askQuestions": "Fragen Sie zu Ihren Transkriptionen",
+ "selectFilters": "Wählen Sie Filter auf der linken Seite aus, um Ihre Transkriptionen einzugrenzen, und stellen Sie dann Fragen, um Einblicke aus Ihren Aufnahmen zu erhalten.",
+ "exampleQuestions": "Beispielfragen:",
+ "exampleQuestion1": "\"Welche Aktionspunkte wurden besprochen?\"",
+ "exampleQuestion2": "\"Wann haben wir beschlossen, den Zeitplan zu ändern?\"",
+ "exampleQuestion3": "\"Welche Bedenken wurden bezüglich des Budgets geäußert?\"",
+ "exampleQuestion4": "\"Wer war für die Marketing-Aufgaben verantwortlich?\"",
+ "placeholder": "Stellen Sie Fragen zu Ihren gefilterten Transkriptionen...",
+ "sendHint": "Enter zum Senden • Strg+Enter für neue Zeile"
+ },
+ "languages": {
+ "ar": "Arabisch",
+ "de": "Deutsch",
+ "en": "Englisch",
+ "es": "Spanisch",
+ "fr": "Französisch",
+ "hi": "Hindi",
+ "it": "Italienisch",
+ "ja": "Japanisch",
+ "ko": "Koreanisch",
+ "nl": "Niederländisch",
+ "pt": "Portugiesisch",
+ "ru": "Russisch",
+ "zh": "Chinesisch"
+ },
+ "manageSpeakersModal": {
+ "created": "Erstellt",
+ "description": "Verwalten Sie Ihre gespeicherten Sprecher. Diese werden automatisch gespeichert, wenn Sie Sprechernamen in Ihren Aufnahmen verwenden.",
+ "failedToLoad": "Fehler beim Laden der Sprecher",
+ "lastUsed": "Zuletzt verwendet",
+ "loadingSpeakers": "Sprecher werden geladen...",
+ "noSpeakersYet": "Noch keine Sprecher gespeichert",
+ "speakersSaved": "{{count}} Sprecher gespeichert",
+ "speakersWillAppear": "Sprecher erscheinen hier, wenn Sie Sprechernamen in Ihren Aufnahmen verwenden",
+ "times": "mal",
+ "title": "Sprecher Verwalten",
+ "used": "Verwendet"
+ },
+ "messages": {
+ "copiedToClipboard": "In Zwischenablage kopiert",
+ "downloadStarted": "Download gestartet",
+ "notesUpdated": "Notizen erfolgreich aktualisiert",
+ "passwordChanged": "Passwort erfolgreich geändert",
+ "profileUpdated": "Profil erfolgreich aktualisiert",
+ "recordingDeleted": "Aufnahme erfolgreich gelöscht",
+ "recordingSaved": "Aufnahme erfolgreich gespeichert",
+ "settingsSaved": "Einstellungen erfolgreich gespeichert",
+ "summaryGenerated": "Zusammenfassung erfolgreich generiert",
+ "tagAdded": "Tag erfolgreich hinzugefügt",
+ "tagRemoved": "Tag erfolgreich entfernt",
+ "transcriptionUpdated": "Transkription erfolgreich aktualisiert"
+ },
+ "metadata": {
+ "cancelEdit": "Abbrechen",
+ "createdAt": "Erstellt",
+ "duration": "Dauer",
+ "editMetadata": "Metadaten bearbeiten",
+ "fileName": "Dateiname",
+ "fileSize": "Dateigröße",
+ "language": "Sprache",
+ "meetingDate": "Meeting-Datum",
+ "processingTime": "Verarbeitungszeit",
+ "saveMetadata": "Speichern",
+ "status": "Status",
+ "title": "Metadaten",
+ "updatedAt": "Aktualisiert",
+ "wordCount": "Wortanzahl"
+ },
+ "modal": {
+ "colorScheme": "Farbschema",
+ "deleteRecording": "Aufnahme löschen",
+ "editAsrTranscription": "ASR-Transkription bearbeiten",
+ "editRecording": "Aufnahme bearbeiten",
+ "editTags": "Aufnahme-Tags bearbeiten",
+ "editTranscription": "Transkription bearbeiten",
+ "identifySpeakers": "Sprecher identifizieren",
+ "recordingNotice": "Aufnahme-Hinweis",
+ "reprocessSummary": "Zusammenfassung neu verarbeiten",
+ "reprocessTranscription": "Transkription neu verarbeiten",
+ "resetStatus": "Aufnahme-Status zurücksetzen?",
+ "shareRecording": "Aufnahme teilen",
+ "sharedTranscripts": "Meine geteilten Transkripte",
+ "systemAudioHelp": "System-Audio Hilfe"
+ },
+ "nav": {
+ "account": "Konto",
+ "accountSettings": "Kontoeinstellungen",
+ "admin": "Administration",
+ "adminDashboard": "Admin-Dashboard",
+ "darkMode": "Dunkler Modus",
+ "home": "Startseite",
+ "language": "Sprache",
+ "lightMode": "Heller Modus",
+ "newRecording": "Neue Aufnahme",
+ "recording": "Aufnahme",
+ "settings": "Einstellungen",
+ "signOut": "Abmelden",
+ "upload": "Hochladen",
+ "userProfile": "Benutzerprofil"
+ },
+ "notes": {
+ "cancelEdit": "Bearbeitung abbrechen",
+ "characterCount": "{{count}} Zeichen",
+ "characterCountPlural": "{{count}} Zeichen",
+ "editNotes": "Notizen bearbeiten",
+ "lastUpdated": "Zuletzt aktualisiert",
+ "placeholder": "Ihre Notizen hier hinzufügen...",
+ "saveNotes": "Notizen speichern",
+ "title": "Notizen"
+ },
+ "recording": {
+ "acceptDisclaimer": "Ich akzeptiere",
+ "cancelRecording": "Abbrechen",
+ "disclaimer": "Aufnahme-Hinweis",
+ "microphone": "Mikrofon",
+ "microphoneAndSystem": "Mikrofon + System",
+ "microphonePermissionDenied": "Mikrofon-Berechtigung verweigert",
+ "notes": "Notizen",
+ "notesPlaceholder": "Notizen zu dieser Aufnahme hinzufügen...",
+ "pauseRecording": "Pause",
+ "recordingFailed": "Aufnahme fehlgeschlagen",
+ "recordingInProgress": "Aufnahme läuft...",
+ "recordingSize": "Geschätzte Größe",
+ "recordingStopped": "Aufnahme gestoppt",
+ "recordingTime": "Aufnahmezeit",
+ "resumeRecording": "Fortsetzen",
+ "saveRecording": "Aufnahme speichern",
+ "startRecording": "Aufnahme starten",
+ "stopRecording": "Aufnahme stoppen",
+ "systemAudio": "System-Audio",
+ "systemAudioNotSupported": "System-Audio-Aufnahme wird in diesem Browser nicht unterstützt",
+ "title": "Audio-Aufnahme"
+ },
+ "reprocessModal": {
+ "audioReTranscribedFromScratch": "Das Audio wird von Grund auf neu transkribiert. Dies wird auch den Titel und die Zusammenfassung basierend auf der neuen Transkription neu generieren.",
+ "audioReTranscribedWithAsr": "Das Audio wird mit dem ASR-Endpoint neu transkribiert. Dies umfasst Diarisierung und wird Titel und Zusammenfassung neu generieren.",
+ "manualEditsOverwritten": "Alle manuellen Bearbeitungen der Transkription, des Titels oder der Zusammenfassung werden überschrieben.",
+ "manualEditsOverwrittenSummary": "Alle manuellen Bearbeitungen des Titels oder der Zusammenfassung werden überschrieben.",
+ "newTitleAndSummary": "Ein neuer Titel und eine neue Zusammenfassung werden basierend auf der vorhandenen Transkription generiert."
+ },
+ "settings": {
+ "apiKeys": "API-Schlüssel",
+ "appearance": "Erscheinungsbild",
+ "changePassword": "Passwort ändern",
+ "dataExport": "Datenexport",
+ "deleteAccount": "Konto löschen",
+ "integrations": "Integrationen",
+ "language": "Sprache",
+ "notifications": "Benachrichtigungen",
+ "preferences": "Einstellungen",
+ "privacy": "Datenschutz",
+ "profile": "Profil",
+ "security": "Sicherheit",
+ "theme": "Design",
+ "title": "Einstellungen",
+ "twoFactorAuth": "Zwei-Faktor-Authentifizierung"
+ },
+ "sharedTranscripts": {
+ "noSharedTranscripts": "Sie haben noch keine Transkripte geteilt."
+ },
+ "sharedTranscriptsPage": {
+ "noSharedTranscripts": "Sie haben noch keine Transkripte geteilt."
+ },
+ "sidebar": {
+ "advancedSearch": "Erweiterte Suche",
+ "dateRange": "Datumsbereich",
+ "filters": "Filter",
+ "highlighted": "Hervorgehoben",
+ "inbox": "Posteingang",
+ "lastMonth": "Letzten Monat",
+ "lastWeek": "Letzte Woche",
+ "loadMore": "Mehr laden",
+ "markAsRead": "Als gelesen markieren",
+ "moveToInbox": "In Posteingang verschieben",
+ "noRecordings": "Keine Aufnahmen gefunden",
+ "older": "Älter",
+ "removeFromHighlighted": "Von Favoriten entfernen",
+ "searchRecordings": "Aufnahmen suchen...",
+ "sortBy": "Sortieren nach",
+ "sortByDate": "Erstellungsdatum",
+ "sortByMeetingDate": "Meeting-Datum",
+ "tags": "Tags",
+ "thisMonth": "Diesen Monat",
+ "thisWeek": "Diese Woche",
+ "today": "Heute",
+ "totalRecordings": "{{count}} Aufnahme",
+ "totalRecordingsPlural": "{{count}} Aufnahmen",
+ "yesterday": "Gestern"
+ },
+ "status": {
+ "completed": "Abgeschlossen",
+ "failed": "Fehlgeschlagen",
+ "processing": "Verarbeitung",
+ "queued": "In Warteschlange",
+ "stuck": "Blockierte Verarbeitung zurücksetzen",
+ "summarizing": "Zusammenfassung",
+ "transcribing": "Transkribierung",
+ "uploading": "Upload"
+ },
+ "summary": {
+ "actionItems": "Aktionspunkte",
+ "cancelEdit": "Bearbeitung abbrechen",
+ "decisions": "Entscheidungen",
+ "editSummary": "Zusammenfassung bearbeiten",
+ "generateSummary": "Zusammenfassung generieren",
+ "keyPoints": "Wichtige Punkte",
+ "noSummary": "Keine Zusammenfassung verfügbar",
+ "participants": "Teilnehmer",
+ "regenerateSummary": "Zusammenfassung neu generieren",
+ "saveSummary": "Zusammenfassung speichern",
+ "summaryFailed": "Generierung der Zusammenfassung fehlgeschlagen",
+ "summaryInProgress": "Zusammenfassung wird generiert...",
+ "title": "Zusammenfassung"
+ },
+ "tagManagement": {
+ "asrDefaults": "ASR-Standards",
+ "createTag": "Tag Erstellen",
+ "customPrompt": "Benutzerdefinierter Prompt",
+ "description": "Organisieren Sie Ihre Aufnahmen mit benutzerdefinierten Tags. Jeder Tag kann seinen eigenen Zusammenfassungs-Prompt und Standard-ASR-Einstellungen haben.",
+ "maxSpeakers": "Max",
+ "minSpeakers": "Min",
+ "noTags": "Sie haben noch keine Tags erstellt."
+ },
+ "tags": {
+ "addTag": "Tag hinzufügen",
+ "clearTagFilter": "Filter löschen",
+ "createTag": "Tag erstellen",
+ "filterByTag": "Nach Tag filtern",
+ "manageAllTags": "Alle Tags verwalten",
+ "noTags": "Keine Tags",
+ "removeTag": "Tag entfernen",
+ "tagColor": "Tag-Farbe",
+ "tagName": "Tag-Name",
+ "title": "Tags"
+ },
+ "tagsModal": {
+ "addTags": "Tags hinzufügen",
+ "currentTags": "Aktuelle Tags",
+ "done": "Fertig",
+ "noTagsAssigned": "Dieser Aufnahme sind keine Tags zugewiesen",
+ "searchTags": "Tags suchen..."
+ },
+ "time": {
+ "dayAgo": "Vor 1 Tag",
+ "daysAgo": "Vor {{count}} Tagen",
+ "hourAgo": "Vor 1 Stunde",
+ "hoursAgo": "Vor {{count}} Stunden",
+ "justNow": "Gerade eben",
+ "minuteAgo": "Vor 1 Minute",
+ "minutesAgo": "Vor {{count}} Minuten",
+ "monthAgo": "Vor 1 Monat",
+ "monthsAgo": "Vor {{count}} Monaten",
+ "weekAgo": "Vor 1 Woche",
+ "weeksAgo": "Vor {{count}} Wochen",
+ "yearAgo": "Vor 1 Jahr",
+ "yearsAgo": "Vor {{count}} Jahren"
+ },
+ "transcription": {
+ "autoIdentifySpeakers": "Sprecher automatisch identifizieren",
+ "simple": "Einfach",
+ "bubble": "Blase",
+ "copy": "Kopieren",
+ "download": "Herunterladen",
+ "edit": "Bearbeiten",
+ "cancelEdit": "Bearbeitung abbrechen",
+ "copyToClipboard": "In Zwischenablage kopieren",
+ "downloadTranscript": "Transkript herunterladen",
+ "editSpeakers": "Sprecher bearbeiten",
+ "editTranscription": "Transkription bearbeiten",
+ "highlightSearchResults": "Suchergebnisse hervorheben",
+ "noTranscription": "Keine Transkription verfügbar",
+ "regenerateTranscription": "Transkription neu generieren",
+ "saveTranscription": "Transkription speichern",
+ "searchInTranscript": "In Transkript suchen...",
+ "speaker": "Sprecher {{number}}",
+ "speakerLabels": "Sprecher-Labels",
+ "title": "Transkription",
+ "unknownSpeaker": "Unbekannter Sprecher"
+ },
+ "upload": {
+ "chunking": "Große Dateien werden automatisch für die Verarbeitung aufgeteilt",
+ "completed": "Abgeschlossen",
+ "dropzone": "Audio-Dateien hier hineinziehen oder klicken zum Durchsuchen",
+ "failed": "Fehlgeschlagen",
+ "maxFileSize": "Maximale Dateigröße",
+ "queued": "In Warteschlange",
+ "selectFiles": "Dateien auswählen",
+ "summarizing": "Zusammenfasse...",
+ "supportedFormats": "Unterstützt MP3, WAV, M4A, MP4, MOV, AVI, AMR und mehr",
+ "title": "Audio hochladen",
+ "transcribing": "Transkribiere...",
+ "untitled": "Unbenannte Aufnahme",
+ "uploadProgress": "Upload-Fortschritt",
+ "willAutoSummarize": "Wird nach der Transkription automatisch zusammengefasst"
+ },
+ "uploadProgress": {
+ "title": "Upload-Fortschritt"
+ },
+ "eventExtraction": {
+ "title": "Ereignisextraktion",
+ "enableLabel": "Automatische Ereignisextraktion aus Transkripten aktivieren",
+ "description": "Wenn aktiviert, identifiziert die KI Meetings, Termine und Fristen in Ihren Aufnahmen und erstellt herunterladbare Kalenderereignisse.",
+ "info": "Extrahierte Ereignisse erscheinen im 'Ereignisse'-Tab bei Aufnahmen, in denen Kalenderelemente erkannt werden."
+ },
+ "events": {
+ "title": "Ereignisse",
+ "addToCalendar": "Zum Kalender Hinzufügen",
+ "noEvents": "Keine Ereignisse in dieser Aufnahme erkannt",
+ "start": "Beginn",
+ "end": "Ende",
+ "location": "Ort",
+ "attendees": "Teilnehmer"
+ }
+}
\ No newline at end of file
diff --git a/speakr/static/locales/en.json b/speakr/static/locales/en.json
new file mode 100644
index 0000000..21d4c3d
--- /dev/null
+++ b/speakr/static/locales/en.json
@@ -0,0 +1,876 @@
+{
+ "aboutPage": {
+ "aiSummarization": "AI Summarization",
+ "aiSummarizationDesc": "OpenRouter and Ollama integration with custom prompts",
+ "asrEnabled": "ASR Enabled",
+ "asrEndpoint": "ASR Endpoint",
+ "audioTranscription": "Audio Transcription",
+ "audioTranscriptionDesc": "Whisper API and custom ASR support with high accuracy",
+ "backend": "Backend",
+ "database": "Database",
+ "deployment": "Deployment",
+ "dockerDescription": "Official Docker images",
+ "dockerHub": "Docker Hub",
+ "documentation": "Documentation",
+ "documentationDescription": "User guide and tutorials",
+ "endpoint": "Endpoint",
+ "frontend": "Frontend",
+ "githubDescription": "Source code, issues, and releases",
+ "githubRepository": "GitHub Repository",
+ "inquireMode": "Inquire Mode",
+ "inquireModeDesc": "Semantic search across all your recordings",
+ "interactiveChat": "Interactive Chat",
+ "interactiveChatDesc": "Chat with your transcriptions using AI",
+ "keyFeatures": "Key Features",
+ "largeLanguageModel": "Large Language Model",
+ "model": "Model",
+ "projectDescription": "Transform your audio recordings into organized, searchable notes with AI-powered transcription, summarization, and interactive chat features.",
+ "projectLinks": "Project Links",
+ "sharingExport": "Sharing & Export",
+ "sharingExportDesc": "Share recordings and export to various formats",
+ "speakerDiarization": "Speaker Diarization",
+ "speakerDiarizationDesc": "Identify and label different speakers automatically",
+ "speechRecognition": "Speech Recognition",
+ "systemConfiguration": "System Configuration",
+ "tagline": "AI-Powered Audio Transcription & Note-Taking",
+ "technologyStack": "Technology Stack",
+ "title": "About",
+ "version": "Version",
+ "whisperApi": "Whisper API"
+ },
+ "aboutPageDetails": {
+ "aiSummarizationDesc": "OpenRouter and Ollama integration with custom prompts",
+ "asrEnabled": "ASR Enabled",
+ "asrEndpoint": "ASR Endpoint",
+ "audioTranscriptionDesc": "Whisper API and custom ASR support with high accuracy",
+ "backend": "Backend",
+ "database": "Database",
+ "deployment": "Deployment",
+ "dockerDescription": "Official Docker images",
+ "documentationDescription": "Setup guides and user manual",
+ "endpoint": "Endpoint",
+ "frontend": "Frontend",
+ "githubDescription": "Source code, issues, and releases",
+ "inquireModeDesc": "Semantic search across all your recordings",
+ "interactiveChatDesc": "Chat with your transcriptions using AI",
+ "model": "Model",
+ "no": "No",
+ "sharingExportDesc": "Share recordings and export to various formats",
+ "speakerDiarizationDesc": "Identify and label different speakers automatically",
+ "whisperApi": "Whisper API",
+ "yes": "Yes"
+ },
+ "account": {
+ "accountActions": "Account Actions",
+ "changePassword": "Change Password",
+ "chooseLanguageForInterface": "Choose the language for the application interface",
+ "companyOrganization": "Company / Organization",
+ "completedRecordings": "Completed",
+ "email": "Email",
+ "failedRecordings": "Failed",
+ "fullName": "Full Name",
+ "goToRecordings": "Go to Recordings",
+ "interfaceLanguage": "Interface Language",
+ "jobTitle": "Job Title",
+ "languageForSummaries": "Language for titles, summaries, and chat. Leave blank for default (default behavior of your chosen models).",
+ "languagePreferences": "Language Preferences",
+ "leaveBlankForAutoDetect": "Leave blank for auto-detection by transcription service",
+ "manageSpeakers": "Manage Speakers",
+ "personalInfo": "Personal Information",
+ "preferredOutputLanguage": "Preferred Chatbot and Summarization Language",
+ "processingRecordings": "Processing",
+ "saveAllPreferences": "Save All Preferences",
+ "statistics": "Account Statistics",
+ "title": "Account Information",
+ "totalRecordings": "Total Recordings",
+ "transcriptionLanguage": "Transcription Language",
+ "userDetails": "User Details",
+ "username": "Username"
+ },
+ "accountTabs": {
+ "about": "About",
+ "customPrompts": "Custom Prompts",
+ "promptOptions": "Prompt Options",
+ "sharedTranscripts": "Shared Transcripts",
+ "speakersManagement": "Speakers Management",
+ "tagManagement": "Tag Management",
+ "transcriptTemplates": "Transcript Templates"
+ },
+ "eventExtraction": {
+ "title": "Event Extraction",
+ "enableLabel": "Enable automatic event extraction from transcripts",
+ "description": "When enabled, the AI will identify meetings, appointments, and deadlines mentioned in your recordings and create downloadable calendar events.",
+ "info": "Extracted events will appear in an 'Events' tab on recordings where calendar items are detected."
+ },
+ "transcriptTemplates": {
+ "title": "Transcript Templates",
+ "description": "Customize how transcripts are formatted for download and export.",
+ "createNew": "Create Template",
+ "availableTemplates": "Available Templates",
+ "createDefaults": "Create Default Templates",
+ "templateName": "Template Name",
+ "template": "Template",
+ "availableVars": "Available Variables",
+ "filters": "Filters: |upper for uppercase, |srt for subtitle time format",
+ "setDefault": "Set as default template",
+ "save": "Save",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "selectOrCreate": "Select a template to edit or create a new one",
+ "viewGuide": "View Template Guide"
+ },
+ "admin": {
+ "title": "Admin",
+ "userMenu": "User Menu"
+ },
+ "adminDashboard": {
+ "aboutInquireMode": "About Inquire Mode",
+ "actions": "ACTIONS",
+ "addNewUser": "Add New User",
+ "addUser": "Add User",
+ "additionalContext": "Additional Context",
+ "admin": "ADMIN",
+ "adminDefaultPrompt": "Admin Default Prompt",
+ "adminDefaultPromptDesc": "The prompt configured above (shown on this page)",
+ "adminUser": "Admin User",
+ "allRecordingsProcessed": "All recordings are processed!",
+ "available": "Available",
+ "characters": "characters",
+ "chunkSize": "Chunk Size",
+ "complete": "complete",
+ "completedRecordings": "Completed",
+ "confirmPasswordLabel": "Confirm Password",
+ "contextNotes": {
+ "jsonConversion": "JSON transcripts are converted to clean text format before sending",
+ "modelConfig": "The model used is configured via TEXT_MODEL_NAME environment variable",
+ "tagPrompts": "If multiple tag prompts exist, they are merged in the order tags were added",
+ "transcriptLimit": "Transcripts are limited to a configurable character count (default: 30,000)"
+ },
+ "defaultPromptInfo": "This default prompt will be used for all users who haven't set their own custom prompt in their account settings.",
+ "defaultPrompts": "Default Prompts",
+ "defaultSummarizationPrompt": "Default Summarization Prompt",
+ "editUser": "Edit User",
+ "email": "EMAIL",
+ "emailLabel": "Email",
+ "embeddingModel": "Embedding Model",
+ "embeddingsStatus": "Embeddings Status",
+ "errors": {
+ "failedToLoadPrompt": "Failed to load default prompt",
+ "failedToSavePrompt": "Failed to save default prompt. Please try again.",
+ "invalidFileSize": "Please enter a valid size between 1 and 10000 MB",
+ "invalidInteger": "Please enter a valid integer",
+ "invalidNumber": "Please enter a valid number",
+ "maxTimeout": "Timeout cannot exceed 10 hours (36000 seconds)",
+ "minCharacters": "Please enter a valid number of at least 1000 characters",
+ "minTimeout": "Timeout must be at least 60 seconds"
+ },
+ "failedRecordings": "Failed",
+ "id": "ID",
+ "inquireModeDescription": "Inquire Mode allows users to search across multiple transcriptions using natural language questions. It works by breaking transcriptions into chunks and creating searchable embeddings using AI models.",
+ "languagePreferenceNote": "Note: If the user has set an output language preference, \" Ensure your response is in {language}.\" will be added.",
+ "lastUpdated": "Last updated",
+ "maxFileSizeHelp": "Maximum file size in megabytes (1-10000 MB)",
+ "megabytes": "MB",
+ "minutes": "minutes",
+ "needProcessing": "Need Processing",
+ "never": "Never",
+ "newPasswordLabel": "New Password (leave blank to keep current)",
+ "no": "No",
+ "noData": "No data",
+ "noDescriptionAvailable": "No description available",
+ "noLimit": "No Limit",
+ "notSet": "Not set",
+ "overlap": "Overlap",
+ "passwordLabel": "Password",
+ "pendingRecordings": "Pending",
+ "placeholdersNote": "Placeholders are replaced with actual values when processing a recording.",
+ "processAllRecordings": "Process All Recordings",
+ "processNext10": "Process Next 10",
+ "processedForInquire": "Processed for Inquire",
+ "processing": "Processing...",
+ "processingActions": "Processing Actions",
+ "processingProgress": "Processing Progress",
+ "processingRecordings": "Processing",
+ "promptDescription": "This prompt will be used to generate summaries for all recordings when users haven't set their own prompt.",
+ "promptHierarchy": "Prompt Hierarchy",
+ "promptPriorityDescription": "The system uses the following priority order when selecting which prompt to use:",
+ "promptResetMessage": "Prompt reset to system default. Click \"Save Changes\" to apply.",
+ "promptSavedSuccessfully": "Default prompt saved successfully!",
+ "recordingStatusDistribution": "Recording Status Distribution",
+ "recordings": "RECORDINGS",
+ "recordingsNeedProcessing": "There are {{count}} recordings that need to be processed for Inquire Mode.",
+ "refreshStatus": "Refresh Status",
+ "resetToDefault": "Reset to Default",
+ "saving": "Saving...",
+ "searchUsers": "Search users...",
+ "seconds": "seconds",
+ "settings": {
+ "asrTimeoutDesc": "Maximum time in seconds to wait for ASR transcription to complete. Default is 1800 seconds (30 minutes).",
+ "defaultSummaryPromptDesc": "Default summarization prompt used when users have not set their own prompt. This serves as the base prompt for all users.",
+ "maxFileSizeDesc": "Maximum file size allowed for audio uploads in megabytes (MB).",
+ "recordingDisclaimerDesc": "Legal disclaimer shown to users before recording starts. Supports Markdown formatting. Leave empty to disable.",
+ "transcriptLengthLimitDesc": "Maximum number of characters to send from transcript to LLM for summarization and chat. Use -1 for no limit."
+ },
+ "storageUsed": "STORAGE USED",
+ "summarizationInstructions": "Summarization Instructions",
+ "systemFallback": "System Fallback",
+ "systemFallbackDesc": "A hardcoded default if none of the above are set",
+ "systemPrompt": "System Prompt",
+ "systemSettings": "System Settings",
+ "systemStatistics": "System Statistics",
+ "tagCustomPrompt": "Tag Custom Prompt",
+ "tagCustomPromptDesc": "If a recording has tags with custom prompts",
+ "textSearchOnly": "Text Search Only",
+ "timeoutRecommendation": "Recommended: 30-120 minutes for long audio files",
+ "title": "Admin Dashboard",
+ "topUsers": "Top Users",
+ "topUsersByStorage": "Top Users by Storage",
+ "total": "Total",
+ "totalChunks": "Total Chunks",
+ "totalQueries": "Total Queries",
+ "totalRecordings": "Total Recordings",
+ "totalStorage": "Total Storage",
+ "totalUsers": "Total Users",
+ "updateUser": "Update User",
+ "userCustomPrompt": "User Custom Prompt",
+ "userCustomPromptDesc": "If the user has set their own prompt in account settings",
+ "userManagement": "User Management",
+ "userMessageTemplate": "User Message Template",
+ "username": "USERNAME",
+ "usernameLabel": "Username",
+ "vectorDimensions": "Vector Dimensions",
+ "vectorStore": "Vector Store",
+ "vectorStoreManagement": "Vector Store Management",
+ "vectorStoreUpToDate": "The vector store is up to date.",
+ "viewFullPromptStructure": "View Full LLM Prompt Structure",
+ "yes": "Yes"
+ },
+ "buttons": {
+ "cancel": "Cancel",
+ "clearAllFilters": "Clear all filters",
+ "clearCompleted": "Clear completed uploads",
+ "clearSearchText": "Clear search text",
+ "close": "Close",
+ "copyMessage": "Copy message",
+ "copyNotes": "Copy notes",
+ "copySummary": "Copy summary",
+ "copyToClipboard": "Copy to clipboard",
+ "createTag": "Create Tag",
+ "deleteAll": "Delete All",
+ "deleteSpeaker": "Delete speaker",
+ "deleteTag": "Delete tag",
+ "deleteUser": "Delete User",
+ "downloadAsWord": "Download as Word",
+ "downloadChat": "Download chat as Word document",
+ "downloadNotes": "Download Notes as Word Document",
+ "downloadSummary": "Download Summary as Word Document",
+ "exportCalendar": "Export to Calendar",
+ "editNotes": "Edit notes",
+ "editSetting": "Edit Setting",
+ "editSummary": "Edit summary",
+ "editTag": "Edit tag",
+ "editTags": "Edit Tags",
+ "editTranscription": "Edit transcription",
+ "editUser": "Edit User",
+ "help": "Help",
+ "identifySpeakers": "Identify speakers",
+ "refresh": "Refresh",
+ "reprocessSummary": "Reprocess summary",
+ "reprocessTranscription": "Reprocess transcription",
+ "reprocessWithAsr": "Reprocess with ASR",
+ "resetStuckProcessing": "Reset stuck processing",
+ "saveChanges": "Save Changes",
+ "saveCustomPrompt": "Save Custom Prompt",
+ "saveSettings": "Save Settings",
+ "shareRecording": "Share recording",
+ "toggleTheme": "Toggle theme",
+ "updateTag": "Update Tag"
+ },
+ "changePasswordModal": {
+ "confirmPassword": "Confirm New Password",
+ "currentPassword": "Current Password",
+ "newPassword": "New Password",
+ "passwordRequirement": "Password must be at least 8 characters long",
+ "title": "Change Password"
+ },
+ "chat": {
+ "availableAfterTranscription": "Chat will be available once transcription is complete",
+ "chatWithTranscription": "Chat with Transcription",
+ "clearChat": "Clear Chat",
+ "error": "Failed to send message",
+ "noMessages": "No messages yet",
+ "placeholder": "Ask a question about this recording...",
+ "placeholderWithHint": "Ask a question about this recording... (Enter to send, Ctrl+Enter for new line)",
+ "send": "Send",
+ "suggestedQuestions": "Suggested Questions",
+ "thinking": "Thinking...",
+ "title": "Chat",
+ "whatAreActionItems": "What are the action items?",
+ "whatAreKeyPoints": "What are the key points?",
+ "whatWasDiscussed": "What was discussed?",
+ "whoSaidWhat": "Who said what?"
+ },
+ "colorScheme": {
+ "chooseRecording": "Choose a recording from the sidebar to view its transcription and summary",
+ "selectRecording": "Select a Recording",
+ "subtitle": "Customize your interface with beautiful color themes",
+ "title": "Color Scheme"
+ },
+ "common": {
+ "back": "Back",
+ "cancel": "Cancel",
+ "close": "Close",
+ "confirm": "Confirm",
+ "delete": "Delete",
+ "deselectAll": "Deselect All",
+ "download": "Download",
+ "edit": "Edit",
+ "error": "Error",
+ "failed": "Failed",
+ "filter": "Filter",
+ "info": "Info",
+ "loading": "Loading...",
+ "new": "New",
+ "next": "Next",
+ "no": "No",
+ "noResults": "No results found",
+ "ok": "OK",
+ "or": "Or",
+ "previous": "Previous",
+ "processing": "Processing...",
+ "refresh": "Refresh",
+ "retry": "Retry",
+ "save": "Save",
+ "search": "Search",
+ "selectAll": "Select All",
+ "sort": "Sort",
+ "success": "Success",
+ "untitled": "Untitled",
+ "upload": "Upload",
+ "warning": "Warning",
+ "yes": "Yes"
+ },
+ "customPrompts": {
+ "currentDefaultPrompt": "Current Default Prompt (Used if you leave the above blank)",
+ "promptDescription": "This prompt will be used to generate summaries for your transcriptions. It overrides the admin's default prompt.",
+ "promptPlaceholder": "Describe how you want your summaries structured. Leave blank to use the admin's default prompt.",
+ "summaryGeneration": "Summary Generation Prompt",
+ "tip1": "Be specific about the sections you want in your summary",
+ "tip2": "Use clear formatting instructions (e.g., \"Use bullet points\", \"Create numbered lists\")",
+ "tip3": "Specify if you want certain information prioritized",
+ "tip4": "The system will automatically provide the transcript content to the AI",
+ "tip5": "Your output language preference (if set) will be applied automatically",
+ "tipsTitle": "Tips for Writing Effective Prompts",
+ "yourCustomPrompt": "Your Custom Summary Prompt"
+ },
+ "deleteAllSpeakersModal": {
+ "confirmMessage": "Are you sure you want to delete all saved speakers? This action cannot be undone.",
+ "title": "Delete All Speakers"
+ },
+ "dialogs": {
+ "deleteRecording": {
+ "cancel": "Cancel",
+ "confirm": "Delete",
+ "message": "Are you sure you want to delete this recording? This action cannot be undone.",
+ "title": "Delete Recording"
+ },
+ "deleteShare": {
+ "message": "Are you sure you want to delete this share? This will revoke access to the public link.",
+ "title": "Delete Share"
+ },
+ "reprocessTranscription": {
+ "cancel": "Cancel",
+ "confirm": "Reprocess",
+ "message": "Are you sure you want to reprocess this transcription? The current transcription will be replaced.",
+ "title": "Reprocess Transcription"
+ },
+ "unsavedChanges": {
+ "cancel": "Cancel",
+ "discard": "Discard",
+ "message": "You have unsaved changes. Do you want to save them before leaving?",
+ "save": "Save",
+ "title": "Unsaved Changes"
+ }
+ },
+ "duration": {
+ "hours": "{{count}} hour",
+ "hoursPlural": "{{count}} hours",
+ "minutes": "{{count}} minute",
+ "minutesPlural": "{{count}} minutes",
+ "seconds": "{{count}} second",
+ "secondsPlural": "{{count}} seconds"
+ },
+ "editTagModal": {
+ "asrDefaultSettings": "ASR Default Settings",
+ "asrSettingsDescription": "These settings will be applied by default when using this tag with ASR transcription",
+ "color": "Color",
+ "colorDescription": "Choose a color for easy identification",
+ "createTitle": "Create Tag",
+ "customPrompt": "Custom Summary Prompt",
+ "customPromptPlaceholder": "Optional: Custom prompt for generating summaries for recordings with this tag",
+ "defaultLanguage": "Default Language",
+ "defaultPromptPlaceholder": "Leer lassen, um Ihren Standard-Zusammenfassungs-Prompt zu verwenden",
+ "leaveBlankPrompt": "Leave blank to use your default summary prompt",
+ "maxSpeakers": "Max Speakers",
+ "minSpeakers": "Min Speakers",
+ "tagName": "Tag Name *",
+ "tagNamePlaceholder": "e.g., Meetings, Interviews",
+ "title": "Edit Tag"
+ },
+ "errors": {
+ "audioRecordingFailed": "音频录制失败。请检查您的麦克风。",
+ "fileTooLarge": "文件太大",
+ "generic": "发生错误",
+ "loadingShares": "Error loading shares",
+ "networkError": "网络错误。请检查您的连接。",
+ "notFound": "未找到",
+ "permissionDenied": "权限被拒绝",
+ "quotaExceeded": "存储配额已超出",
+ "serverError": "服务器错误。请稍后重试。",
+ "summaryFailed": "摘要生成失败。请重试。",
+ "transcriptionFailed": "转录失败。请重试。",
+ "unauthorized": "未授权",
+ "unsupportedFormat": "不支持的文件格式",
+ "uploadFailed": "上传失败。请重试。",
+ "validationError": "验证错误"
+ },
+ "fileSize": {
+ "bytes": "{{count}} B",
+ "gigabytes": "{{count}} GB",
+ "kilobytes": "{{count}} KB",
+ "megabytes": "{{count}} MB"
+ },
+ "form": {
+ "auto": "Auto",
+ "autoDetect": "Auto-detect",
+ "dateFrom": "From",
+ "dateTo": "To",
+ "enterNotesMarkdown": "Enter notes in Markdown format...",
+ "enterSummaryMarkdown": "Enter summary in Markdown format...",
+ "language": "Language",
+ "maxSpeakers": "Max Speakers",
+ "meetingDate": "Meeting Date",
+ "minSpeakers": "Min Speakers",
+ "minutes": "Minutes",
+ "notes": "Notes",
+ "notesPlaceholder": "Type your notes in Markdown format...",
+ "optional": "Optional",
+ "participants": "Participants",
+ "placeholderAuto": "Auto",
+ "placeholderCharacterLimit": "Enter character limit (e.g., 30000)",
+ "placeholderMinutes": "Minutes",
+ "placeholderOptional": "Optional",
+ "placeholderSeconds": "Seconds",
+ "placeholderSizeMB": "Enter size in MB",
+ "searchSpeakers": "Search speakers...",
+ "searchTags": "Search tags...",
+ "seconds": "Seconds",
+ "shareNotes": "Share Notes",
+ "shareSummary": "Share Summary",
+ "shareableLink": "Shareable Link",
+ "summaryPromptPlaceholder": "Enter the default summarization prompt...",
+ "title": "Title",
+ "yourFullName": "Your full name"
+ },
+ "help": {
+ "actions": "Actions",
+ "activeFilters": "Active filters",
+ "advancedAsrOptions": "Advanced ASR Options",
+ "allRecordingsLoaded": "All recordings loaded",
+ "approachingLimit": "Approaching {{limit}}MB limit",
+ "askAboutTranscription": "Ask questions about this transcription",
+ "audioPlayer": "Audio Player",
+ "autoIdentify": "Auto Identify",
+ "bothAudioDesc": "Records your voice + meeting participants (recommended for online meetings)",
+ "clearFilters": "clear filters",
+ "clickToAddNotes": "Click to add notes...",
+ "colorRepeats": "Color repeats from speaker {{number}}",
+ "completedFiles": "Completed Files",
+ "confirmReprocessingTitle": "Confirm Reprocessing",
+ "copyMessage": "Copy message",
+ "createPublicLink": "Create a public link to share this recording. Sharing is only available on secure (HTTPS) connections.",
+ "createTags": "Create tags",
+ "discard": "Discard",
+ "endTime": "End",
+ "enterNameFor": "Enter name for",
+ "entireScreen": "Entire Screen",
+ "estimatedSize": "Estimated size",
+ "generatingSummary": "Generating summary...",
+ "howToRecordSystemAudio": "How to Record System Audio",
+ "importantNote": "Important note",
+ "lines": "{{count}} lines",
+ "loadingMore": "Loading more recordings...",
+ "loadingRecordings": "Loading recordings...",
+ "me": "Me",
+ "microphoneDesc": "Records your voice only",
+ "modelReasoning": "Model Reasoning",
+ "moreSpeakersThanColors": "More speakers than available colors",
+ "noDateSet": "No date set",
+ "noParticipants": "No participants",
+ "noTagsCreated": "No tags created yet.",
+ "processingTime": "Processing time",
+ "processingTimeDescription": "This may take a few minutes to complete. You can continue using the app while processing.",
+ "processingTranscription": "Processing transcription...",
+ "recordSystemSteps1": "Click \"Record System Audio\" or \"Record Both\".",
+ "recordSystemSteps2": "In the popup, choose",
+ "recordSystemSteps3": "Make sure to check the box that says",
+ "recordingFinished": "Recording finished",
+ "recordingInProgress": "Recording in progress...",
+ "regenerateSummaryAfterNames": "Regenerate summary after updating names",
+ "sentence": "Sentence",
+ "shareSystemAudio": "Share system audio",
+ "shareTabAudio": "Share tab audio",
+ "sharedOn": "Shared on",
+ "sharingWindowNoAudio": "Sharing a \"Window\" will not capture audio.",
+ "speakerCount": "Speaker",
+ "specificBrowserTab": "specific browser tab",
+ "startTime": "Start",
+ "systemAudioDesc": "Records meeting participants and system sounds",
+ "tagManagement": "Tag Management",
+ "thisActionCannotBeUndone": "This action cannot be undone.",
+ "toCaptureAudioFromMeetings": "To capture audio from meetings or other apps, you must share your screen or a browser tab.",
+ "troubleshooting": "Troubleshooting",
+ "tryAdjustingSearch": "Try adjusting your search or",
+ "unsupportedBrowser": "Unsupported Browser",
+ "untitled": "Untitled Recording",
+ "uploadRecordingNotes": "Upload Recording & Notes",
+ "whatWillHappen": "What will happen?",
+ "whyNotWorking": "Why isn't it working?",
+ "youHaveXSpeakers": "You have {{count}} speakers, but only 8 unique colors are available. Colors will repeat after the 8th speaker.",
+ "speakers": "Speakers"
+ },
+ "inquire": {
+ "title": "Inquire",
+ "filters": "Filters",
+ "clearAll": "Clear All",
+ "tags": "Tags",
+ "speakers": "Speakers",
+ "dateRange": "Date Range",
+ "from": "From",
+ "to": "To",
+ "noSpeakerData": "No speaker data available",
+ "speakerRequirement": "Speaker identification requires recordings with multiple speakers",
+ "activeFilters": "Active Filters:",
+ "tagsCount": "tags",
+ "speakersCount": "speakers",
+ "dateRangeActive": "Date range active",
+ "filtersActive": "Filters active",
+ "askQuestions": "Ask Questions About Your Transcriptions",
+ "selectFilters": "Select filters on the left to narrow down your transcriptions, then ask questions to get insights from your recordings.",
+ "exampleQuestions": "Example Questions:",
+ "exampleQuestion1": "\"What action items were discussed?\"",
+ "exampleQuestion2": "\"When did we decide to change the timeline?\"",
+ "exampleQuestion3": "\"What concerns were raised about the budget?\"",
+ "exampleQuestion4": "\"Who was responsible for the marketing tasks?\"",
+ "placeholder": "Ask questions about your filtered transcriptions...",
+ "sendHint": "Press Enter to send • Ctrl+Enter for new line"
+ },
+ "languages": {
+ "ar": "Arabic",
+ "de": "German",
+ "en": "English",
+ "es": "Spanish",
+ "fr": "French",
+ "hi": "Hindi",
+ "it": "Italian",
+ "ja": "Japanese",
+ "ko": "Korean",
+ "nl": "Dutch",
+ "pt": "Portuguese",
+ "ru": "Russian",
+ "zh": "Chinese"
+ },
+ "speakersManagement": {
+ "description": "Manage your saved speakers. These are automatically saved when you use speaker names in your recordings.",
+ "loadingSpeakers": "Loading speakers...",
+ "failedToLoad": "Failed to load speakers",
+ "noSpeakersYet": "No speakers saved yet",
+ "speakersWillAppear": "Speakers will appear here when you use speaker names in your recordings",
+ "totalSpeakers": "speakers saved",
+ "usedTimes": "Used",
+ "lastUsed": "Last used",
+ "created": "Created"
+ },
+ "manageSpeakersModal": {
+ "created": "Created",
+ "description": "Manage your saved speakers. These are automatically saved when you use speaker names in your recordings.",
+ "failedToLoad": "Failed to load speakers",
+ "lastUsed": "Last used",
+ "loadingSpeakers": "Loading speakers...",
+ "noSpeakersYet": "No speakers saved yet",
+ "speakersSaved": "{{count}} speakers saved",
+ "speakersWillAppear": "Speakers will appear here when you use speaker names in your recordings",
+ "times": "times",
+ "title": "Manage Speakers",
+ "used": "Used"
+ },
+ "messages": {
+ "copiedToClipboard": "Copied to clipboard",
+ "downloadStarted": "Download started",
+ "notesUpdated": "Notes updated successfully",
+ "passwordChanged": "Password changed successfully",
+ "profileUpdated": "Profile updated successfully",
+ "recordingDeleted": "Recording deleted successfully",
+ "recordingSaved": "Recording saved successfully",
+ "settingsSaved": "Settings saved successfully",
+ "summaryGenerated": "Summary generated successfully",
+ "tagAdded": "Tag added successfully",
+ "tagRemoved": "Tag removed successfully",
+ "transcriptionUpdated": "Transcription updated successfully"
+ },
+ "metadata": {
+ "cancelEdit": "Cancel",
+ "createdAt": "Created",
+ "duration": "Duration",
+ "editMetadata": "Edit Metadata",
+ "fileName": "File Name",
+ "fileSize": "File Size",
+ "language": "Language",
+ "meetingDate": "Meeting Date",
+ "processingTime": "Processing Time",
+ "saveMetadata": "Save",
+ "status": "Status",
+ "title": "Metadata",
+ "updatedAt": "Updated",
+ "wordCount": "Word Count"
+ },
+ "modal": {
+ "colorScheme": "Color Scheme",
+ "deleteRecording": "Delete Recording",
+ "editAsrTranscription": "Edit ASR Transcription",
+ "editRecording": "Edit Recording",
+ "editTags": "Edit Recording Tags",
+ "editTranscription": "Edit Transcription",
+ "identifySpeakers": "Identify Speakers",
+ "recordingNotice": "Recording Notice",
+ "reprocessSummary": "Reprocess Summary",
+ "reprocessTranscription": "Reprocess Transcription",
+ "resetStatus": "Reset Recording Status?",
+ "shareRecording": "Share Recording",
+ "sharedTranscripts": "My Shared Transcripts",
+ "systemAudioHelp": "System Audio Help"
+ },
+ "nav": {
+ "account": "Account",
+ "accountSettings": "Account Settings",
+ "admin": "Admin",
+ "adminDashboard": "Admin Dashboard",
+ "darkMode": "Dark Mode",
+ "home": "Home",
+ "language": "Language",
+ "lightMode": "Light Mode",
+ "newRecording": "New Recording",
+ "recording": "Recording",
+ "settings": "Settings",
+ "signOut": "Sign Out",
+ "upload": "Upload",
+ "userProfile": "User Profile"
+ },
+ "notes": {
+ "cancelEdit": "Cancel Edit",
+ "characterCount": "{{count}} character",
+ "characterCountPlural": "{{count}} characters",
+ "editNotes": "Edit Notes",
+ "lastUpdated": "Last updated",
+ "placeholder": "Add your notes here...",
+ "saveNotes": "Save Notes",
+ "title": "Notes"
+ },
+ "recording": {
+ "acceptDisclaimer": "I Accept",
+ "cancelRecording": "Cancel",
+ "disclaimer": "Recording Disclaimer",
+ "microphone": "Microphone",
+ "microphoneAndSystem": "Microphone + System",
+ "microphonePermissionDenied": "Microphone permission denied",
+ "notes": "Notes",
+ "notesPlaceholder": "Add notes about this recording...",
+ "pauseRecording": "Pause",
+ "recordingFailed": "Recording failed",
+ "recordingInProgress": "Recording in progress...",
+ "recordingSize": "Estimated Size",
+ "recordingStopped": "Recording stopped",
+ "recordingTime": "Recording Time",
+ "resumeRecording": "Resume",
+ "saveRecording": "Save Recording",
+ "startRecording": "Start Recording",
+ "stopRecording": "Stop Recording",
+ "systemAudio": "System Audio",
+ "systemAudioNotSupported": "System audio recording is not supported in this browser",
+ "title": "Audio Recording"
+ },
+ "reprocessModal": {
+ "audioReTranscribedFromScratch": "The audio will be re-transcribed from scratch. This will also regenerate the title and summary based on the new transcription.",
+ "audioReTranscribedWithAsr": "The audio will be re-transcribed using the ASR endpoint. This includes diarization and will regenerate the title and summary.",
+ "manualEditsOverwritten": "Any manual edits to the transcription, title, or summary will be overwritten.",
+ "manualEditsOverwrittenSummary": "Any manual edits to the title or summary will be overwritten.",
+ "newTitleAndSummary": "A new title and summary will be generated based on the existing transcription."
+ },
+ "settings": {
+ "apiKeys": "API Keys",
+ "appearance": "Appearance",
+ "changePassword": "Change Password",
+ "dataExport": "Data Export",
+ "deleteAccount": "Delete Account",
+ "integrations": "Integrations",
+ "language": "Language",
+ "notifications": "Notifications",
+ "preferences": "Preferences",
+ "privacy": "Privacy",
+ "profile": "Profile",
+ "security": "Security",
+ "theme": "Theme",
+ "title": "Settings",
+ "twoFactorAuth": "Two-Factor Authentication"
+ },
+ "sharedTranscripts": {
+ "noSharedTranscripts": "You haven't shared any transcripts yet."
+ },
+ "sharedTranscriptsPage": {
+ "noSharedTranscripts": "You have not shared any transcripts yet."
+ },
+ "sidebar": {
+ "advancedSearch": "Advanced Search",
+ "dateRange": "Date Range",
+ "filters": "Filters",
+ "highlighted": "Highlighted",
+ "inbox": "Inbox",
+ "lastMonth": "Last Month",
+ "lastWeek": "Last Week",
+ "loadMore": "Load More",
+ "markAsRead": "Mark as read",
+ "moveToInbox": "Move to Inbox",
+ "noRecordings": "No recordings found",
+ "older": "Older",
+ "removeFromHighlighted": "Remove from starred",
+ "searchRecordings": "Search recordings...",
+ "sortBy": "Sort By",
+ "sortByDate": "Created Date",
+ "sortByMeetingDate": "Meeting Date",
+ "tags": "Tags",
+ "thisMonth": "This Month",
+ "thisWeek": "This Week",
+ "today": "Today",
+ "totalRecordings": "{{count}} recording",
+ "totalRecordingsPlural": "{{count}} recordings",
+ "yesterday": "Yesterday"
+ },
+ "status": {
+ "completed": "Completed",
+ "failed": "Failed",
+ "processing": "Processing",
+ "queued": "Queued",
+ "stuck": "Reset stuck processing",
+ "summarizing": "Summarizing",
+ "transcribing": "Transcribing",
+ "uploading": "Uploading"
+ },
+ "summary": {
+ "actionItems": "Action Items",
+ "cancelEdit": "Cancel Edit",
+ "decisions": "Decisions",
+ "editSummary": "Edit Summary",
+ "generateSummary": "Generate Summary",
+ "keyPoints": "Key Points",
+ "noSummary": "No summary available",
+ "participants": "Participants",
+ "regenerateSummary": "Regenerate Summary",
+ "saveSummary": "Save Summary",
+ "summaryFailed": "Summary generation failed",
+ "summaryInProgress": "Summary generation in progress...",
+ "title": "Summary"
+ },
+ "tagManagement": {
+ "asrDefaults": "ASR Defaults",
+ "createTag": "Create Tag",
+ "customPrompt": "Custom Prompt",
+ "description": "Organize your recordings with custom tags. Each tag can have its own summary prompt and default ASR settings.",
+ "maxSpeakers": "Max",
+ "minSpeakers": "Min",
+ "noTags": "You haven't created any tags yet."
+ },
+ "tags": {
+ "addTag": "Add Tag",
+ "clearTagFilter": "Clear filter",
+ "createTag": "Create Tag",
+ "filterByTag": "Filter by tag",
+ "manageAllTags": "Manage All Tags",
+ "noTags": "No tags",
+ "removeTag": "Remove Tag",
+ "tagColor": "Tag Color",
+ "tagName": "Tag Name",
+ "title": "Tags"
+ },
+ "tagsModal": {
+ "addTags": "Add Tags",
+ "currentTags": "Current Tags",
+ "done": "Done",
+ "noTagsAssigned": "No tags assigned to this recording",
+ "searchTags": "Search tags..."
+ },
+ "time": {
+ "dayAgo": "1 day ago",
+ "daysAgo": "{{count}} days ago",
+ "hourAgo": "1 hour ago",
+ "hoursAgo": "{{count}} hours ago",
+ "justNow": "Just now",
+ "minuteAgo": "1 minute ago",
+ "minutesAgo": "{{count}} minutes ago",
+ "monthAgo": "1 month ago",
+ "monthsAgo": "{{count}} months ago",
+ "weekAgo": "1 week ago",
+ "weeksAgo": "{{count}} weeks ago",
+ "yearAgo": "1 year ago",
+ "yearsAgo": "{{count}} years ago"
+ },
+ "transcription": {
+ "autoIdentifySpeakers": "Auto-identify Speakers",
+ "simple": "Simple",
+ "bubble": "Bubble",
+ "copy": "Copy",
+ "download": "Download",
+ "edit": "Edit",
+ "cancelEdit": "Cancel Edit",
+ "copyToClipboard": "Copy to Clipboard",
+ "downloadTranscript": "Download Transcript",
+ "editSpeakers": "Edit Speakers",
+ "editTranscription": "Edit Transcription",
+ "highlightSearchResults": "Highlight search results",
+ "noTranscription": "No transcription available",
+ "regenerateTranscription": "Regenerate Transcription",
+ "saveTranscription": "Save Transcription",
+ "searchInTranscript": "Search in transcript...",
+ "speaker": "Speaker {{number}}",
+ "speakerLabels": "Speaker Labels",
+ "title": "Transcription",
+ "unknownSpeaker": "Unknown Speaker"
+ },
+ "upload": {
+ "chunking": "Large files will be automatically chunked for processing",
+ "completed": "Completed",
+ "dropzone": "Drag and drop audio files here, or click to browse",
+ "failed": "Failed",
+ "maxFileSize": "Maximum file size",
+ "queued": "Queued",
+ "selectFiles": "Select Files",
+ "summarizing": "Summarizing...",
+ "supportedFormats": "Supports MP3, WAV, M4A, MP4, MOV, AVI, AMR, and more",
+ "title": "Upload Audio",
+ "transcribing": "Transcribing...",
+ "untitled": "Untitled Recording",
+ "uploadProgress": "Upload Progress",
+ "willAutoSummarize": "Will auto-summarize after transcription"
+ },
+ "uploadProgress": {
+ "title": "Upload Progress"
+ },
+ "events": {
+ "title": "Events",
+ "addToCalendar": "Add to Calendar",
+ "noEvents": "No events detected in this recording",
+ "start": "Start",
+ "end": "End",
+ "location": "Location",
+ "attendees": "Attendees"
+ }
+}
\ No newline at end of file
diff --git a/speakr/static/locales/es.json b/speakr/static/locales/es.json
new file mode 100644
index 0000000..767285f
--- /dev/null
+++ b/speakr/static/locales/es.json
@@ -0,0 +1,863 @@
+{
+ "aboutPage": {
+ "aiSummarization": "Resumen con IA",
+ "aiSummarizationDesc": "Integración de OpenRouter y Ollama con prompts personalizados",
+ "asrEnabled": "ASR Habilitado",
+ "asrEndpoint": "Punto de Acceso ASR",
+ "audioTranscription": "Transcripción de Audio",
+ "audioTranscriptionDesc": "API de Whisper y soporte ASR personalizado con alta precisión",
+ "backend": "Backend",
+ "database": "Base de Datos",
+ "deployment": "Despliegue",
+ "dockerDescription": "Imágenes oficiales de Docker",
+ "dockerHub": "Docker Hub",
+ "documentation": "Documentación",
+ "documentationDescription": "Guías de configuración y manual del usuario",
+ "endpoint": "Punto de Acceso",
+ "frontend": "Frontend",
+ "githubDescription": "Código fuente, problemas y versiones",
+ "githubRepository": "Repositorio de GitHub",
+ "inquireMode": "Modo de Consulta",
+ "inquireModeDesc": "Búsqueda semántica en todas tus grabaciones",
+ "interactiveChat": "Chat Interactivo",
+ "interactiveChatDesc": "Chatea con tus transcripciones usando IA",
+ "keyFeatures": "Características Principales",
+ "largeLanguageModel": "Modelo de Lenguaje Grande",
+ "model": "Modelo",
+ "projectDescription": "Transforma tus grabaciones de audio en notas organizadas y buscables con funciones de transcripción, resumen y chat interactivo impulsadas por IA.",
+ "projectLinks": "Enlaces del Proyecto",
+ "sharingExport": "Compartir y Exportar",
+ "sharingExportDesc": "Comparte grabaciones y exporta a varios formatos",
+ "speakerDiarization": "Diarización de Hablantes",
+ "speakerDiarizationDesc": "Identifica y etiqueta diferentes hablantes automáticamente",
+ "speechRecognition": "Reconocimiento de Voz",
+ "systemConfiguration": "Configuración del Sistema",
+ "tagline": "Transcripción de Audio y Toma de Notas Impulsada por IA",
+ "technologyStack": "Pila Tecnológica",
+ "title": "Acerca de",
+ "version": "Versión",
+ "whisperApi": "API de Whisper"
+ },
+ "aboutPageDetails": {
+ "aiSummarizationDesc": "Integración con OpenRouter y Ollama con prompts personalizados",
+ "asrEnabled": "ASR Habilitado",
+ "asrEndpoint": "Punto de Acceso ASR",
+ "audioTranscriptionDesc": "API de Whisper y soporte ASR personalizado con alta precisión",
+ "backend": "Backend",
+ "database": "Base de Datos",
+ "deployment": "Implementación",
+ "dockerDescription": "Imágenes oficiales de Docker",
+ "documentationDescription": "Guías de configuración y manual de usuario",
+ "endpoint": "Punto de Acceso",
+ "frontend": "Frontend",
+ "githubDescription": "Código fuente, problemas y versiones",
+ "inquireModeDesc": "Búsqueda semántica en todas tus grabaciones",
+ "interactiveChatDesc": "Chatea con tus transcripciones usando IA",
+ "model": "Modelo",
+ "no": "No",
+ "sharingExportDesc": "Comparte grabaciones y exporta a varios formatos",
+ "speakerDiarizationDesc": "Identifica y etiqueta diferentes oradores automáticamente",
+ "whisperApi": "API de Whisper",
+ "yes": "Sí"
+ },
+ "account": {
+ "accountActions": "Acciones de la Cuenta",
+ "changePassword": "Cambiar Contraseña",
+ "chooseLanguageForInterface": "Elige el idioma para la interfaz de la aplicación",
+ "companyOrganization": "Empresa / Organización",
+ "completedRecordings": "Completadas",
+ "email": "Correo Electrónico",
+ "failedRecordings": "Fallidas",
+ "fullName": "Nombre Completo",
+ "goToRecordings": "Ir a Grabaciones",
+ "interfaceLanguage": "Idioma de la Interfaz",
+ "jobTitle": "Cargo",
+ "languageForSummaries": "Idioma para títulos, resúmenes y chat. Dejar en blanco para el predeterminado (comportamiento predeterminado de sus modelos elegidos).",
+ "languagePreferences": "Preferencias de Idioma",
+ "leaveBlankForAutoDetect": "Dejar en blanco para detección automática por el servicio de transcripción",
+ "manageSpeakers": "Gestionar Hablantes",
+ "personalInfo": "Información Personal",
+ "preferredOutputLanguage": "Idioma Preferido para Chatbot y Resúmenes",
+ "processingRecordings": "En Procesamiento",
+ "saveAllPreferences": "Guardar Todas las Preferencias",
+ "statistics": "Estadísticas de la Cuenta",
+ "title": "Información de la Cuenta",
+ "totalRecordings": "Total de Grabaciones",
+ "transcriptionLanguage": "Idioma de Transcripción",
+ "userDetails": "Detalles del Usuario",
+ "username": "Nombre de Usuario"
+ },
+ "accountTabs": {
+ "about": "Acerca de",
+ "customPrompts": "Prompts Personalizados",
+ "promptOptions": "Opciones de Prompt",
+ "sharedTranscripts": "Transcripciones Compartidas",
+ "speakersManagement": "Gestión de Hablantes",
+ "tagManagement": "Gestión de Etiquetas",
+ "transcriptTemplates": "Plantillas de Transcripción"
+ },
+ "eventExtraction": {
+ "title": "Extracción de Eventos",
+ "enableLabel": "Habilitar extracción automática de eventos de las transcripciones",
+ "description": "Cuando está habilitado, la IA identificará reuniones, citas y plazos mencionados en tus grabaciones y creará eventos de calendario descargables.",
+ "info": "Los eventos extraídos aparecerán en la pestaña 'Eventos' en las grabaciones donde se detecten elementos de calendario."
+ },
+ "transcriptTemplates": {
+ "title": "Plantillas de Transcripción",
+ "description": "Personaliza cómo se formatean las transcripciones para descarga y exportación.",
+ "createNew": "Crear Plantilla",
+ "availableTemplates": "Plantillas Disponibles",
+ "createDefaults": "Crear Plantillas Predeterminadas",
+ "templateName": "Nombre de Plantilla",
+ "template": "Plantilla",
+ "availableVars": "Variables Disponibles",
+ "filters": "Filtros: |upper para mayúsculas, |srt para formato de subtítulos",
+ "setDefault": "Establecer como plantilla predeterminada",
+ "save": "Guardar",
+ "cancel": "Cancelar",
+ "delete": "Eliminar",
+ "selectOrCreate": "Selecciona una plantilla para editar o crea una nueva"
+ },
+ "admin": {
+ "title": "Administración",
+ "userMenu": "Menú de Usuario"
+ },
+ "adminDashboard": {
+ "aboutInquireMode": "Acerca del Modo Consulta",
+ "actions": "ACCIONES",
+ "addNewUser": "Agregar Nuevo Usuario",
+ "addUser": "Agregar Usuario",
+ "additionalContext": "Contexto Adicional",
+ "admin": "ADMIN",
+ "adminDefaultPrompt": "Prompt Predeterminado del Administrador",
+ "adminDefaultPromptDesc": "El prompt configurado arriba (mostrado en esta página)",
+ "adminUser": "Usuario Administrador",
+ "allRecordingsProcessed": "¡Todas las grabaciones están procesadas!",
+ "available": "Disponible",
+ "characters": "caracteres",
+ "chunkSize": "Tamaño del Fragmento",
+ "complete": "completado",
+ "completedRecordings": "Completadas",
+ "confirmPasswordLabel": "Confirmar contraseña",
+ "contextNotes": {
+ "jsonConversion": "Las transcripciones JSON se convierten a formato de texto limpio antes de enviar",
+ "modelConfig": "El modelo utilizado se configura a través de la variable de entorno TEXT_MODEL_NAME",
+ "tagPrompts": "Si existen múltiples indicaciones de etiquetas, se fusionan en el orden en que se agregaron las etiquetas",
+ "transcriptLimit": "Las transcripciones están limitadas a un número configurable de caracteres (predeterminado: 30,000)"
+ },
+ "defaultPromptInfo": "Este prompt predeterminado se utilizará para todos los usuarios que no hayan establecido su propio prompt personalizado en la configuración de su cuenta.",
+ "defaultPrompts": "Prompts por Defecto",
+ "defaultSummarizationPrompt": "Prompt de Resumen Predeterminado",
+ "editUser": "Editar Usuario",
+ "email": "E-MAIL",
+ "emailLabel": "Correo electrónico",
+ "embeddingModel": "Modelo de Incrustación",
+ "embeddingsStatus": "Estado de Incrustaciones",
+ "errors": {
+ "failedToLoadPrompt": "Error al cargar el prompt predeterminado",
+ "failedToSavePrompt": "Error al guardar el prompt predeterminado. Por favor, inténtalo de nuevo.",
+ "invalidFileSize": "Por favor ingrese un tamaño válido entre 1 y 10000 MB",
+ "invalidInteger": "Por favor ingrese un número entero válido",
+ "invalidNumber": "Por favor ingrese un número válido",
+ "maxTimeout": "El tiempo de espera no puede exceder 10 horas (36000 segundos)",
+ "minCharacters": "Por favor ingrese un número válido de al menos 1000 caracteres",
+ "minTimeout": "El tiempo de espera debe ser de al menos 60 segundos"
+ },
+ "failedRecordings": "Fallidas",
+ "id": "ID",
+ "inquireModeDescription": "El Modo Inquire permite a los usuarios buscar en múltiples transcripciones usando preguntas en lenguaje natural. Funciona dividiendo las transcripciones en fragmentos y creando incrustaciones buscables usando modelos de IA.",
+ "languagePreferenceNote": "Nota: Si el usuario ha establecido una preferencia de idioma de salida, se agregará \" Asegúrese de que su respuesta esté en {idioma}.\".",
+ "lastUpdated": "Última actualización",
+ "maxFileSizeHelp": "Tamaño máximo de archivo en megabytes (1-10000 MB)",
+ "megabytes": "MB",
+ "minutes": "minutos",
+ "needProcessing": "Necesita Procesamiento",
+ "never": "Nunca",
+ "newPasswordLabel": "Nueva Contraseña (dejar en blanco para mantener la actual)",
+ "no": "No",
+ "noData": "Sin datos",
+ "noDescriptionAvailable": "Sin descripción disponible",
+ "noLimit": "Sin Límite",
+ "notSet": "No establecido",
+ "overlap": "Superposición",
+ "passwordLabel": "Contraseña",
+ "pendingRecordings": "Pendientes",
+ "placeholdersNote": "Los marcadores de posición se reemplazan con valores reales al procesar una grabación.",
+ "processAllRecordings": "Procesar Todas las Grabaciones",
+ "processNext10": "Procesar Próximas 10",
+ "processedForInquire": "Procesado para Consulta",
+ "processing": "Procesando...",
+ "processingActions": "Acciones de Procesamiento",
+ "processingProgress": "Progreso de Procesamiento",
+ "processingRecordings": "En Procesamiento",
+ "promptDescription": "Este prompt se utilizará para generar resúmenes para todas las grabaciones cuando los usuarios no hayan establecido su propio prompt.",
+ "promptHierarchy": "Jerarquía de Prompts",
+ "promptPriorityDescription": "El sistema utiliza el siguiente orden de prioridad al seleccionar qué prompt usar:",
+ "promptResetMessage": "Prompt restablecido al predeterminado del sistema. Haz clic en \"Guardar Cambios\" para aplicar.",
+ "promptSavedSuccessfully": "¡Prompt predeterminado guardado con éxito!",
+ "recordingStatusDistribution": "Distribución del Estado de Grabaciones",
+ "recordings": "GRABACIONES",
+ "recordingsNeedProcessing": "Hay {{count}} grabaciones que necesitan ser procesadas para el Modo Inquire.",
+ "refreshStatus": "Actualizar Estado",
+ "resetToDefault": "Restablecer a Predeterminado",
+ "saving": "Guardando...",
+ "searchUsers": "Buscar usuarios...",
+ "seconds": "segundos",
+ "settings": {
+ "asrTimeoutDesc": "Tiempo máximo en segundos para esperar a que se complete la transcripción ASR. El valor predeterminado es 1800 segundos (30 minutos).",
+ "defaultSummaryPromptDesc": "Indicación de resumen predeterminada utilizada cuando los usuarios no han establecido su propia indicación. Esto sirve como la indicación base para todos los usuarios.",
+ "maxFileSizeDesc": "Tamaño máximo de archivo permitido para cargas de audio en megabytes (MB).",
+ "recordingDisclaimerDesc": "Aviso legal mostrado a los usuarios antes de comenzar la grabación. Admite formato Markdown. Dejar vacío para desactivar.",
+ "transcriptLengthLimitDesc": "Número máximo de caracteres a enviar desde la transcripción al LLM para resumen y chat. Use -1 para sin límite."
+ },
+ "storageUsed": "ALMACENAMIENTO USADO",
+ "summarizationInstructions": "Instrucciones de Resumen",
+ "systemFallback": "Respaldo del Sistema",
+ "systemFallbackDesc": "Un valor predeterminado codificado si ninguno de los anteriores está establecido",
+ "systemPrompt": "Prompt del Sistema",
+ "systemSettings": "Configuración del Sistema",
+ "systemStatistics": "Estadísticas del Sistema",
+ "tagCustomPrompt": "Prompt Personalizado de Etiqueta",
+ "tagCustomPromptDesc": "Si una grabación tiene etiquetas con prompts personalizados",
+ "textSearchOnly": "Solo Búsqueda de Texto",
+ "timeoutRecommendation": "Recomendado: 30-120 minutos para archivos de audio largos",
+ "title": "Panel de Administración",
+ "topUsers": "Usuarios Principales",
+ "topUsersByStorage": "Principales Usuarios por Almacenamiento",
+ "total": "Total",
+ "totalChunks": "Total de Fragmentos",
+ "totalQueries": "Total de Consultas",
+ "totalRecordings": "Total de Grabaciones",
+ "totalStorage": "Almacenamiento Total",
+ "totalUsers": "Total de Usuarios",
+ "updateUser": "Actualizar Usuario",
+ "userCustomPrompt": "Prompt Personalizado del Usuario",
+ "userCustomPromptDesc": "Si el usuario ha establecido su propio prompt en la configuración de la cuenta",
+ "userManagement": "Gestión de Usuarios",
+ "userMessageTemplate": "Plantilla de Mensaje del Usuario",
+ "username": "NOMBRE DE USUARIO",
+ "usernameLabel": "Nombre de usuario",
+ "vectorDimensions": "Dimensiones del Vector",
+ "vectorStore": "Almacén Vectorial",
+ "vectorStoreManagement": "Gestión del Almacén Vectorial",
+ "vectorStoreUpToDate": "El almacén de vectores está actualizado.",
+ "viewFullPromptStructure": "Ver Estructura Completa del Prompt LLM",
+ "yes": "Sí"
+ },
+ "buttons": {
+ "cancel": "Cancel",
+ "clearAllFilters": "Limpiar todos los filtros",
+ "clearCompleted": "Limpiar cargas completadas",
+ "clearSearchText": "Limpiar texto de búsqueda",
+ "close": "Close",
+ "copyMessage": "Copiar mensaje",
+ "copyNotes": "Copiar Notas",
+ "copySummary": "Copiar Resumen",
+ "copyToClipboard": "Copiar al Portapapeles",
+ "createTag": "Create Tag",
+ "deleteAll": "Delete All",
+ "deleteSpeaker": "Eliminar hablante",
+ "deleteTag": "Eliminar etiqueta",
+ "deleteUser": "Eliminar usuario",
+ "downloadAsWord": "Descargar como Word",
+ "downloadChat": "Descargar chat como documento de Word",
+ "downloadNotes": "Descargar notas como documento de Word",
+ "downloadSummary": "Descargar resumen como documento de Word",
+ "exportCalendar": "Exportar al Calendario",
+ "editNotes": "Editar Notas",
+ "editSetting": "Editar configuración",
+ "editSummary": "Editar Resumen",
+ "editTag": "Editar etiqueta",
+ "editTags": "Editar etiquetas",
+ "editTranscription": "Editar Transcripción",
+ "editUser": "Editar usuario",
+ "help": "Ayuda",
+ "identifySpeakers": "Identificar Hablantes",
+ "refresh": "Refresh",
+ "reprocessSummary": "Reprocesar resumen",
+ "reprocessTranscription": "Reprocesar transcripción",
+ "reprocessWithAsr": "Reprocesar con ASR",
+ "resetStuckProcessing": "Restablecer procesamiento bloqueado",
+ "saveChanges": "Guardar Cambios",
+ "saveCustomPrompt": "Guardar Prompt Personalizado",
+ "saveSettings": "Guardar Configuración",
+ "shareRecording": "Compartir Grabación",
+ "toggleTheme": "Cambiar tema",
+ "updateTag": "Update Tag"
+ },
+ "changePasswordModal": {
+ "confirmPassword": "Confirmar Nueva Contraseña",
+ "currentPassword": "Contraseña Actual",
+ "newPassword": "Nueva Contraseña",
+ "passwordRequirement": "La contraseña debe tener al menos 8 caracteres",
+ "title": "Cambiar Contraseña"
+ },
+ "chat": {
+ "availableAfterTranscription": "El chat estará disponible una vez que se complete la transcripción",
+ "chatWithTranscription": "Chatear con la Transcripción",
+ "clearChat": "Limpiar Chat",
+ "error": "Error al enviar el mensaje",
+ "noMessages": "No hay mensajes aún",
+ "placeholder": "Haz una pregunta sobre esta grabación...",
+ "placeholderWithHint": "Haz una pregunta sobre esta grabación... (Enter para enviar, Ctrl+Enter para nueva línea)",
+ "send": "Enviar",
+ "suggestedQuestions": "Preguntas Sugeridas",
+ "thinking": "Pensando...",
+ "title": "Chat",
+ "whatAreActionItems": "¿Cuáles son los elementos de acción?",
+ "whatAreKeyPoints": "¿Cuáles son los puntos clave?",
+ "whatWasDiscussed": "¿Qué se discutió?",
+ "whoSaidWhat": "¿Quién dijo qué?"
+ },
+ "colorScheme": {
+ "chooseRecording": "Elige una grabación de la barra lateral para ver su transcripción y resumen",
+ "selectRecording": "Seleccionar una Grabación",
+ "subtitle": "Personaliza tu interfaz con hermosos temas de color",
+ "title": "Esquema de Color"
+ },
+ "common": {
+ "back": "Atrás",
+ "cancel": "Cancelar",
+ "close": "Cerrar",
+ "confirm": "Confirmar",
+ "delete": "Eliminar",
+ "deselectAll": "Deseleccionar Todo",
+ "download": "Descargar",
+ "edit": "Editar",
+ "error": "Error",
+ "failed": "Fallido",
+ "filter": "Filtrar",
+ "info": "Información",
+ "loading": "Cargando...",
+ "new": "Nuevo",
+ "next": "Siguiente",
+ "no": "No",
+ "noResults": "No se encontraron resultados",
+ "ok": "OK",
+ "or": "Or",
+ "previous": "Anterior",
+ "processing": "Procesando...",
+ "refresh": "Actualizar",
+ "retry": "Reintentar",
+ "save": "Guardar",
+ "search": "Buscar",
+ "selectAll": "Seleccionar Todo",
+ "sort": "Ordenar",
+ "success": "Éxito",
+ "untitled": "Grabación sin título",
+ "upload": "Subir",
+ "warning": "Advertencia",
+ "yes": "Sí"
+ },
+ "customPrompts": {
+ "currentDefaultPrompt": "Prompt Predeterminado Actual (Se usa si dejas lo anterior en blanco)",
+ "promptDescription": "Este prompt se utilizará para generar resúmenes de tus transcripciones. Anula el prompt predeterminado del administrador.",
+ "promptPlaceholder": "Describe cómo quieres que estén estructurados tus resúmenes. Deja en blanco para usar el prompt predeterminado del administrador.",
+ "summaryGeneration": "Prompt de Generación de Resúmenes",
+ "tip1": "Sé específico sobre las secciones que quieres en tu resumen",
+ "tip2": "Usa instrucciones de formato claras (ej. \"Usa viñetas\", \"Crea listas numeradas\")",
+ "tip3": "Especifica si quieres que cierta información sea priorizada",
+ "tip4": "El sistema proporcionará automáticamente el contenido de la transcripción a la IA",
+ "tip5": "Tu preferencia de idioma de salida (si está configurada) se aplicará automáticamente",
+ "tipsTitle": "Consejos para Escribir Prompts Efectivos",
+ "yourCustomPrompt": "Tu Prompt Personalizado de Resumen"
+ },
+ "deleteAllSpeakersModal": {
+ "confirmMessage": "¿Estás seguro de que quieres eliminar todos los oradores guardados? Esta acción no se puede deshacer.",
+ "title": "Eliminar Todos los Oradores"
+ },
+ "dialogs": {
+ "deleteRecording": {
+ "cancel": "Cancelar",
+ "confirm": "Eliminar",
+ "message": "¿Estás seguro de que deseas eliminar esta grabación? Esta acción no se puede deshacer.",
+ "title": "Eliminar Grabación"
+ },
+ "deleteShare": {
+ "message": "¿Estás seguro de que quieres eliminar este enlace compartido? Esto revocará el acceso al enlace público.",
+ "title": "Eliminar enlace compartido"
+ },
+ "reprocessTranscription": {
+ "cancel": "Cancelar",
+ "confirm": "Reprocesar",
+ "message": "¿Estás seguro de que deseas reprocesar esta transcripción? La transcripción actual será reemplazada.",
+ "title": "Reprocesar Transcripción"
+ },
+ "unsavedChanges": {
+ "cancel": "Cancelar",
+ "discard": "Descartar",
+ "message": "Tienes cambios sin guardar. ¿Deseas guardarlos antes de salir?",
+ "save": "Guardar",
+ "title": "Cambios Sin Guardar"
+ }
+ },
+ "duration": {
+ "hours": "{{count}} hora",
+ "hoursPlural": "{{count}} horas",
+ "minutes": "{{count}} minuto",
+ "minutesPlural": "{{count}} minutos",
+ "seconds": "{{count}} segundo",
+ "secondsPlural": "{{count}} segundos"
+ },
+ "editTagModal": {
+ "asrDefaultSettings": "Configuraciones Predeterminadas de ASR",
+ "asrSettingsDescription": "Estas configuraciones se aplicarán por defecto al usar esta etiqueta con transcripción ASR",
+ "color": "Color",
+ "colorDescription": "Elige un color para fácil identificación",
+ "createTitle": "Crear Etiqueta",
+ "customPrompt": "Prompt de Resumen Personalizado",
+ "customPromptPlaceholder": "Opcional: Prompt personalizado para generar resúmenes de grabaciones con esta etiqueta",
+ "defaultLanguage": "Idioma Predeterminado",
+ "defaultPromptPlaceholder": "Deja en blanco para usar tu prompt de resumen predeterminado",
+ "leaveBlankPrompt": "Deja en blanco para usar tu prompt de resumen predeterminado",
+ "maxSpeakers": "Máximo de Oradores",
+ "minSpeakers": "Mínimo de Oradores",
+ "tagName": "Nombre de la Etiqueta *",
+ "tagNamePlaceholder": "ej., Reuniones, Entrevistas",
+ "title": "Editar Etiqueta"
+ },
+ "errors": {
+ "audioRecordingFailed": "音频录制失败。请检查您的麦克风。",
+ "fileTooLarge": "文件太大",
+ "generic": "发生错误",
+ "loadingShares": "Error al cargar los compartidos",
+ "networkError": "网络错误。请检查您的连接。",
+ "notFound": "未找到",
+ "permissionDenied": "权限被拒绝",
+ "quotaExceeded": "存储配额已超出",
+ "serverError": "服务器错误。请稍后重试。",
+ "summaryFailed": "摘要生成失败。请重试。",
+ "transcriptionFailed": "转录失败。请重试。",
+ "unauthorized": "未授权",
+ "unsupportedFormat": "不支持的文件格式",
+ "uploadFailed": "上传失败。请重试。",
+ "validationError": "验证错误"
+ },
+ "fileSize": {
+ "bytes": "{{count}} B",
+ "gigabytes": "{{count}} GB",
+ "kilobytes": "{{count}} KB",
+ "megabytes": "{{count}} MB"
+ },
+ "form": {
+ "auto": "Auto",
+ "autoDetect": "Detección automática",
+ "dateFrom": "Desde",
+ "dateTo": "Hasta",
+ "enterNotesMarkdown": "Ingrese notas en formato Markdown...",
+ "enterSummaryMarkdown": "Ingrese resumen en formato Markdown...",
+ "language": "Idioma",
+ "maxSpeakers": "Hablantes Máximos",
+ "meetingDate": "Fecha de Reunión",
+ "minSpeakers": "Hablantes Mínimos",
+ "minutes": "Minutos",
+ "notes": "Notas",
+ "notesPlaceholder": "Escriba sus notas en formato Markdown...",
+ "optional": "Opcional",
+ "participants": "Participantes",
+ "placeholderAuto": "Auto",
+ "placeholderCharacterLimit": "Ingrese límite de caracteres (ej. 30000)",
+ "placeholderMinutes": "Minutos",
+ "placeholderOptional": "Opcional",
+ "placeholderSeconds": "Segundos",
+ "placeholderSizeMB": "Ingrese tamaño en MB",
+ "searchSpeakers": "Buscar hablantes...",
+ "searchTags": "Buscar etiquetas...",
+ "seconds": "Segundos",
+ "shareNotes": "Compartir Notas",
+ "shareSummary": "Compartir Resumen",
+ "shareableLink": "Enlace Compartible",
+ "summaryPromptPlaceholder": "Ingrese el prompt predeterminado de resumen...",
+ "title": "Título",
+ "yourFullName": "Su nombre completo"
+ },
+ "help": {
+ "actions": "Acciones",
+ "activeFilters": "Filtros activos",
+ "advancedAsrOptions": "Opciones ASR Avanzadas",
+ "allRecordingsLoaded": "Todas las grabaciones cargadas",
+ "approachingLimit": "Acercándose al límite de {{limit}}MB",
+ "askAboutTranscription": "Haz preguntas sobre esta transcripción",
+ "audioPlayer": "Reproductor de Audio",
+ "autoIdentify": "Identificar Automáticamente",
+ "bothAudioDesc": "Graba tu voz + participantes de reunión (recomendado para reuniones en línea)",
+ "clearFilters": "limpiar filtros",
+ "clickToAddNotes": "Haz clic para añadir notas...",
+ "colorRepeats": "Color se repite desde el hablante {{number}}",
+ "completedFiles": "Archivos Completados",
+ "confirmReprocessingTitle": "Confirmar Reprocesamiento",
+ "copyMessage": "Copiar mensaje",
+ "createPublicLink": "Crear un enlace público para compartir esta grabación. Compartir solo está disponible en conexiones seguras (HTTPS).",
+ "createTags": "Crear etiquetas",
+ "discard": "Descartar",
+ "endTime": "Fin",
+ "enterNameFor": "Ingresa nombre para",
+ "entireScreen": "Pantalla Completa",
+ "estimatedSize": "Tamaño estimado",
+ "generatingSummary": "Generando resumen...",
+ "howToRecordSystemAudio": "Cómo Grabar Audio del Sistema",
+ "importantNote": "Nota importante",
+ "lines": "{{count}} líneas",
+ "loadingMore": "Cargando más grabaciones...",
+ "loadingRecordings": "Cargando grabaciones...",
+ "me": "Yo",
+ "microphoneDesc": "Graba solo tu voz",
+ "modelReasoning": "Razonamiento del Modelo",
+ "moreSpeakersThanColors": "Más hablantes que colores disponibles",
+ "noDateSet": "Sin fecha establecida",
+ "noParticipants": "Sin participantes",
+ "noTagsCreated": "Aún no se han creado etiquetas.",
+ "processingTime": "Tiempo de procesamiento",
+ "processingTimeDescription": "Esto puede tardar unos minutos en completarse. Puedes continuar usando la aplicación mientras se procesa.",
+ "processingTranscription": "Procesando transcripción...",
+ "recordSystemSteps1": "Haz clic en \"Grabar Audio del Sistema\" o \"Grabar Ambos\".",
+ "recordSystemSteps2": "En la ventana emergente, elige",
+ "recordSystemSteps3": "Asegúrate de marcar la casilla que dice",
+ "recordingFinished": "Grabación terminada",
+ "recordingInProgress": "Grabación en progreso...",
+ "regenerateSummaryAfterNames": "Regenerar resumen después de actualizar nombres",
+ "sentence": "Oración",
+ "shareSystemAudio": "Compartir audio del sistema",
+ "shareTabAudio": "Compartir audio de pestaña",
+ "sharedOn": "Compartido el",
+ "sharingWindowNoAudio": "Compartir una \"Ventana\" no capturará audio.",
+ "speakerCount": "Hablante",
+ "specificBrowserTab": "pestaña específica del navegador",
+ "startTime": "Inicio",
+ "systemAudioDesc": "Graba participantes de reunión y sonidos del sistema",
+ "tagManagement": "Gestión de Etiquetas",
+ "thisActionCannotBeUndone": "Esta acción no se puede deshacer.",
+ "toCaptureAudioFromMeetings": "Para capturar audio de reuniones u otras aplicaciones, debes compartir tu pantalla o una pestaña del navegador.",
+ "troubleshooting": "Solución de Problemas",
+ "tryAdjustingSearch": "Intenta ajustar tu búsqueda o",
+ "unsupportedBrowser": "Navegador No Soportado",
+ "untitled": "Grabación Sin Título",
+ "uploadRecordingNotes": "Subir Grabación y Notas",
+ "whatWillHappen": "¿Qué pasará?",
+ "whyNotWorking": "¿Por qué no funciona?",
+ "speakers": "Hablantes"
+ },
+ "inquire": {
+ "title": "Consultar",
+ "filters": "Filtros",
+ "clearAll": "Limpiar Todo",
+ "tags": "Etiquetas",
+ "speakers": "Oradores",
+ "dateRange": "Rango de Fechas",
+ "from": "Desde",
+ "to": "Hasta",
+ "noSpeakerData": "No hay datos de oradores disponibles",
+ "speakerRequirement": "La identificación de oradores requiere grabaciones con múltiples oradores",
+ "activeFilters": "Filtros Activos:",
+ "tagsCount": "etiquetas",
+ "speakersCount": "oradores",
+ "dateRangeActive": "Rango de fechas activo",
+ "filtersActive": "Filtros activos",
+ "askQuestions": "Haga Preguntas Sobre Sus Transcripciones",
+ "selectFilters": "Seleccione filtros a la izquierda para reducir sus transcripciones, luego haga preguntas para obtener información de sus grabaciones.",
+ "exampleQuestions": "Preguntas de Ejemplo:",
+ "exampleQuestion1": "\"¿Qué elementos de acción se discutieron?\"",
+ "exampleQuestion2": "\"¿Cuándo decidimos cambiar la cronología?\"",
+ "exampleQuestion3": "\"¿Qué preocupaciones se plantearon sobre el presupuesto?\"",
+ "exampleQuestion4": "\"¿Quién fue responsable de las tareas de marketing?\"",
+ "placeholder": "Haga preguntas sobre sus transcripciones filtradas...",
+ "sendHint": "Presione Enter para enviar • Ctrl+Enter para nueva línea"
+ },
+ "languages": {
+ "ar": "Árabe",
+ "de": "Alemán",
+ "en": "Inglés",
+ "es": "Español",
+ "fr": "Francés",
+ "hi": "Hindi",
+ "it": "Italiano",
+ "ja": "Japonés",
+ "ko": "Coreano",
+ "nl": "Holandés",
+ "pt": "Portugués",
+ "ru": "Ruso",
+ "zh": "Chino"
+ },
+ "manageSpeakersModal": {
+ "created": "Creado",
+ "description": "Gestiona tus oradores guardados. Estos se guardan automáticamente cuando usas nombres de oradores en tus grabaciones.",
+ "failedToLoad": "Error al cargar oradores",
+ "lastUsed": "Último uso",
+ "loadingSpeakers": "Cargando oradores...",
+ "noSpeakersYet": "Aún no hay oradores guardados",
+ "speakersSaved": "{{count}} oradores guardados",
+ "speakersWillAppear": "Los oradores aparecerán aquí cuando uses nombres de oradores en tus grabaciones",
+ "times": "veces",
+ "title": "Gestionar Oradores",
+ "used": "Usado"
+ },
+ "messages": {
+ "copiedToClipboard": "Copiado al portapapeles",
+ "downloadStarted": "Descarga iniciada",
+ "notesUpdated": "Notas actualizadas exitosamente",
+ "passwordChanged": "Contraseña cambiada exitosamente",
+ "profileUpdated": "Perfil actualizado exitosamente",
+ "recordingDeleted": "Grabación eliminada exitosamente",
+ "recordingSaved": "Grabación guardada exitosamente",
+ "settingsSaved": "Configuración guardada exitosamente",
+ "summaryGenerated": "Resumen generado exitosamente",
+ "tagAdded": "Etiqueta añadida exitosamente",
+ "tagRemoved": "Etiqueta eliminada exitosamente",
+ "transcriptionUpdated": "Transcripción actualizada exitosamente"
+ },
+ "metadata": {
+ "cancelEdit": "Cancelar",
+ "createdAt": "Creado",
+ "duration": "Duración",
+ "editMetadata": "Editar Metadatos",
+ "fileName": "Nombre del Archivo",
+ "fileSize": "Tamaño del Archivo",
+ "language": "Idioma",
+ "meetingDate": "Fecha de Reunión",
+ "processingTime": "Tiempo de Procesamiento",
+ "saveMetadata": "Guardar",
+ "status": "Estado",
+ "title": "Metadatos",
+ "updatedAt": "Actualizado",
+ "wordCount": "Recuento de Palabras"
+ },
+ "modal": {
+ "colorScheme": "Esquema de Color",
+ "deleteRecording": "Eliminar Grabación",
+ "editAsrTranscription": "Editar Transcripción ASR",
+ "editRecording": "Editar Grabación",
+ "editTags": "Editar Etiquetas de Grabación",
+ "editTranscription": "Editar Transcripción",
+ "identifySpeakers": "Identificar Hablantes",
+ "recordingNotice": "Aviso de Grabación",
+ "reprocessSummary": "Reprocesar Resumen",
+ "reprocessTranscription": "Reprocesar Transcripción",
+ "resetStatus": "¿Restablecer Estado de Grabación?",
+ "shareRecording": "Compartir Grabación",
+ "sharedTranscripts": "Mis Transcripciones Compartidas",
+ "systemAudioHelp": "Ayuda de Audio del Sistema"
+ },
+ "nav": {
+ "account": "Cuenta",
+ "accountSettings": "Configuración de Cuenta",
+ "admin": "Administración",
+ "adminDashboard": "Panel de Administración",
+ "darkMode": "Modo Oscuro",
+ "home": "Inicio",
+ "language": "Idioma",
+ "lightMode": "Modo Claro",
+ "newRecording": "Nueva Grabación",
+ "recording": "Grabación",
+ "settings": "Configuración",
+ "signOut": "Cerrar Sesión",
+ "upload": "Subir",
+ "userProfile": "Perfil de Usuario"
+ },
+ "notes": {
+ "cancelEdit": "Cancelar Edición",
+ "characterCount": "{{count}} carácter",
+ "characterCountPlural": "{{count}} caracteres",
+ "editNotes": "Editar Notas",
+ "lastUpdated": "Última actualización",
+ "placeholder": "Añade tus notas aquí...",
+ "saveNotes": "Guardar Notas",
+ "title": "Notas"
+ },
+ "recording": {
+ "acceptDisclaimer": "Acepto",
+ "cancelRecording": "Cancelar",
+ "disclaimer": "Aviso de Grabación",
+ "microphone": "Micrófono",
+ "microphoneAndSystem": "Micrófono + Sistema",
+ "microphonePermissionDenied": "Permiso del micrófono denegado",
+ "notes": "Notas",
+ "notesPlaceholder": "Añade notas sobre esta grabación...",
+ "pauseRecording": "Pausar",
+ "recordingFailed": "La grabación falló",
+ "recordingInProgress": "Grabación en progreso...",
+ "recordingSize": "Tamaño Estimado",
+ "recordingStopped": "Grabación detenida",
+ "recordingTime": "Tiempo de Grabación",
+ "resumeRecording": "Reanudar",
+ "saveRecording": "Guardar Grabación",
+ "startRecording": "Iniciar Grabación",
+ "stopRecording": "Detener Grabación",
+ "systemAudio": "Audio del Sistema",
+ "systemAudioNotSupported": "La grabación de audio del sistema no es compatible con este navegador",
+ "title": "Grabación de Audio"
+ },
+ "reprocessModal": {
+ "audioReTranscribedFromScratch": "El audio será re-transcrito desde cero. Esto también regenerará el título y resumen basado en la nueva transcripción.",
+ "audioReTranscribedWithAsr": "El audio será re-transcrito usando el endpoint ASR. Esto incluye diarización y regenerará el título y resumen.",
+ "manualEditsOverwritten": "Cualquier edición manual de la transcripción, título o resumen será sobrescrita.",
+ "manualEditsOverwrittenSummary": "Cualquier edición manual del título o resumen será sobrescrita.",
+ "newTitleAndSummary": "Se generará un nuevo título y resumen basado en la transcripción existente."
+ },
+ "settings": {
+ "apiKeys": "Claves API",
+ "appearance": "Apariencia",
+ "changePassword": "Cambiar Contraseña",
+ "dataExport": "Exportación de Datos",
+ "deleteAccount": "Eliminar Cuenta",
+ "integrations": "Integraciones",
+ "language": "Idioma",
+ "notifications": "Notificaciones",
+ "preferences": "Preferencias",
+ "privacy": "Privacidad",
+ "profile": "Perfil",
+ "security": "Seguridad",
+ "theme": "Tema",
+ "title": "Configuración",
+ "twoFactorAuth": "Autenticación de Dos Factores"
+ },
+ "sharedTranscripts": {
+ "noSharedTranscripts": "Aún no has compartido ninguna transcripción."
+ },
+ "sharedTranscriptsPage": {
+ "noSharedTranscripts": "Aún no has compartido ninguna transcripción."
+ },
+ "sidebar": {
+ "advancedSearch": "Búsqueda Avanzada",
+ "dateRange": "Rango de Fechas",
+ "filters": "Filtros",
+ "highlighted": "Destacado",
+ "inbox": "Bandeja de Entrada",
+ "lastMonth": "Mes Pasado",
+ "lastWeek": "Semana Pasada",
+ "loadMore": "Cargar Más",
+ "markAsRead": "Marcar como leído",
+ "moveToInbox": "Mover a bandeja de entrada",
+ "noRecordings": "No se encontraron grabaciones",
+ "older": "Más Antiguo",
+ "removeFromHighlighted": "Quitar de destacados",
+ "searchRecordings": "Buscar grabaciones...",
+ "sortBy": "Ordenar Por",
+ "sortByDate": "Fecha de Creación",
+ "sortByMeetingDate": "Fecha de Reunión",
+ "tags": "Etiquetas",
+ "thisMonth": "Este Mes",
+ "thisWeek": "Esta Semana",
+ "today": "Hoy",
+ "totalRecordings": "{{count}} grabación",
+ "totalRecordingsPlural": "{{count}} grabaciones",
+ "yesterday": "Ayer"
+ },
+ "status": {
+ "completed": "Completado",
+ "failed": "Falló",
+ "processing": "Procesando",
+ "queued": "En cola",
+ "stuck": "Restablecer procesamiento bloqueado",
+ "summarizing": "Resumiendo",
+ "transcribing": "Transcribiendo",
+ "uploading": "Subiendo"
+ },
+ "summary": {
+ "actionItems": "Elementos de Acción",
+ "cancelEdit": "Cancelar Edición",
+ "decisions": "Decisiones",
+ "editSummary": "Editar Resumen",
+ "generateSummary": "Generar Resumen",
+ "keyPoints": "Puntos Clave",
+ "noSummary": "No hay resumen disponible",
+ "participants": "Participantes",
+ "regenerateSummary": "Regenerar Resumen",
+ "saveSummary": "Guardar Resumen",
+ "summaryFailed": "La generación del resumen falló",
+ "summaryInProgress": "Generación de resumen en progreso...",
+ "title": "Resumen"
+ },
+ "tagManagement": {
+ "asrDefaults": "Configuraciones ASR Predeterminadas",
+ "createTag": "Crear Etiqueta",
+ "customPrompt": "Prompt Personalizado",
+ "description": "Organiza tus grabaciones con etiquetas personalizadas. Cada etiqueta puede tener su propio prompt de resumen y configuraciones ASR predeterminadas.",
+ "maxSpeakers": "Máx",
+ "minSpeakers": "Mín",
+ "noTags": "Aún no has creado ninguna etiqueta."
+ },
+ "tags": {
+ "addTag": "Añadir Etiqueta",
+ "clearTagFilter": "Limpiar filtro",
+ "createTag": "Crear Etiqueta",
+ "filterByTag": "Filtrar por etiqueta",
+ "manageAllTags": "Gestionar Todas las Etiquetas",
+ "noTags": "Sin etiquetas",
+ "removeTag": "Eliminar Etiqueta",
+ "tagColor": "Color de Etiqueta",
+ "tagName": "Nombre de Etiqueta",
+ "title": "Etiquetas"
+ },
+ "tagsModal": {
+ "addTags": "Añadir Etiquetas",
+ "currentTags": "Etiquetas Actuales",
+ "done": "Listo",
+ "noTagsAssigned": "No hay etiquetas asignadas a esta grabación",
+ "searchTags": "Buscar etiquetas..."
+ },
+ "time": {
+ "dayAgo": "Hace 1 día",
+ "daysAgo": "Hace {{count}} días",
+ "hourAgo": "Hace 1 hora",
+ "hoursAgo": "Hace {{count}} horas",
+ "justNow": "Justo ahora",
+ "minuteAgo": "Hace 1 minuto",
+ "minutesAgo": "Hace {{count}} minutos",
+ "monthAgo": "Hace 1 mes",
+ "monthsAgo": "Hace {{count}} meses",
+ "weekAgo": "Hace 1 semana",
+ "weeksAgo": "Hace {{count}} semanas",
+ "yearAgo": "Hace 1 año",
+ "yearsAgo": "Hace {{count}} años"
+ },
+ "transcription": {
+ "autoIdentifySpeakers": "Identificar Hablantes Automáticamente",
+ "simple": "Simple",
+ "bubble": "Burbuja",
+ "copy": "Copiar",
+ "download": "Descargar",
+ "edit": "Editar",
+ "cancelEdit": "Cancelar Edición",
+ "copyToClipboard": "Copiar al Portapapeles",
+ "downloadTranscript": "Descargar Transcripción",
+ "editSpeakers": "Editar Hablantes",
+ "editTranscription": "Editar Transcripción",
+ "highlightSearchResults": "Resaltar resultados de búsqueda",
+ "noTranscription": "No hay transcripción disponible",
+ "regenerateTranscription": "Regenerar Transcripción",
+ "saveTranscription": "Guardar Transcripción",
+ "searchInTranscript": "Buscar en la transcripción...",
+ "speaker": "Hablante {{number}}",
+ "speakerLabels": "Etiquetas de Hablante",
+ "title": "Transcripción",
+ "unknownSpeaker": "Hablante Desconocido"
+ },
+ "upload": {
+ "chunking": "Los archivos grandes se dividirán automáticamente para su procesamiento",
+ "completed": "Completado",
+ "dropzone": "Arrastra y suelta archivos de audio aquí, o haz clic para explorar",
+ "failed": "Falló",
+ "maxFileSize": "Tamaño máximo de archivo",
+ "queued": "En cola",
+ "selectFiles": "Seleccionar Archivos",
+ "summarizing": "Resumiendo...",
+ "supportedFormats": "Formatos compatibles",
+ "title": "Subir Audio",
+ "transcribing": "Transcribiendo...",
+ "untitled": "Untitled Recording",
+ "uploadProgress": "Progreso de Carga",
+ "willAutoSummarize": "Se resumirá automáticamente después de la transcripción"
+ },
+ "uploadProgress": {
+ "title": "Progreso de Carga"
+ },
+ "events": {
+ "title": "Eventos",
+ "addToCalendar": "Agregar al Calendario",
+ "noEvents": "No se detectaron eventos en esta grabación",
+ "start": "Inicio",
+ "end": "Fin",
+ "location": "Ubicación",
+ "attendees": "Asistentes"
+ }
+}
\ No newline at end of file
diff --git a/speakr/static/locales/fr.json b/speakr/static/locales/fr.json
new file mode 100644
index 0000000..2ab48f0
--- /dev/null
+++ b/speakr/static/locales/fr.json
@@ -0,0 +1,863 @@
+{
+ "aboutPage": {
+ "aiSummarization": "Résumé par IA",
+ "aiSummarizationDesc": "Intégration OpenRouter et Ollama avec invites personnalisées",
+ "asrEnabled": "ASR Activé",
+ "asrEndpoint": "Point de Terminaison ASR",
+ "audioTranscription": "Transcription Audio",
+ "audioTranscriptionDesc": "API Whisper et support ASR personnalisé avec haute précision",
+ "backend": "Backend",
+ "database": "Base de Données",
+ "deployment": "Déploiement",
+ "dockerDescription": "Images Docker officielles",
+ "dockerHub": "Docker Hub",
+ "documentation": "Documentation",
+ "documentationDescription": "Guides de configuration et manuel utilisateur",
+ "endpoint": "Point de Terminaison",
+ "frontend": "Frontend",
+ "githubDescription": "Code source, problèmes et versions",
+ "githubRepository": "Référentiel GitHub",
+ "inquireMode": "Mode Enquête",
+ "inquireModeDesc": "Recherche sémantique dans tous vos enregistrements",
+ "interactiveChat": "Chat Interactif",
+ "interactiveChatDesc": "Discutez avec vos transcriptions en utilisant l'IA",
+ "keyFeatures": "Fonctionnalités Principales",
+ "largeLanguageModel": "Grand Modèle de Langage",
+ "model": "Modèle",
+ "projectDescription": "Transformez vos enregistrements audio en notes organisées et consultables avec des fonctionnalités de transcription, de résumé et de chat interactif pilotées par IA.",
+ "projectLinks": "Liens du Projet",
+ "sharingExport": "Partage et Exportation",
+ "sharingExportDesc": "Partagez des enregistrements et exportez vers divers formats",
+ "speakerDiarization": "Diarisation des Orateurs",
+ "speakerDiarizationDesc": "Identifie et étiquette automatiquement différents orateurs",
+ "speechRecognition": "Reconnaissance Vocale",
+ "systemConfiguration": "Configuration du Système",
+ "tagline": "Transcription Audio et Prise de Notes Pilotées par IA",
+ "technologyStack": "Pile Technologique",
+ "title": "À Propos",
+ "version": "Version",
+ "whisperApi": "API Whisper"
+ },
+ "aboutPageDetails": {
+ "aiSummarizationDesc": "Intégration OpenRouter et Ollama avec invites personnalisées",
+ "asrEnabled": "ASR Activé",
+ "asrEndpoint": "Point de Terminaison ASR",
+ "audioTranscriptionDesc": "API Whisper et support ASR personnalisé avec haute précision",
+ "backend": "Backend",
+ "database": "Base de Données",
+ "deployment": "Déploiement",
+ "dockerDescription": "Images Docker officielles",
+ "documentationDescription": "Guides de configuration et manuel utilisateur",
+ "endpoint": "Point de Terminaison",
+ "frontend": "Frontend",
+ "githubDescription": "Code source, problèmes et versions",
+ "inquireModeDesc": "Recherche sémantique dans tous vos enregistrements",
+ "interactiveChatDesc": "Chattez avec vos transcriptions en utilisant l'IA",
+ "model": "Modèle",
+ "no": "Non",
+ "sharingExportDesc": "Partagez des enregistrements et exportez vers divers formats",
+ "speakerDiarizationDesc": "Identifier et étiqueter automatiquement les différents intervenants",
+ "whisperApi": "API Whisper",
+ "yes": "Oui"
+ },
+ "account": {
+ "accountActions": "Actions du Compte",
+ "changePassword": "Changer le Mot de Passe",
+ "chooseLanguageForInterface": "Choisissez la langue pour l'interface de l'application",
+ "companyOrganization": "Entreprise / Organisation",
+ "completedRecordings": "Terminés",
+ "email": "E-mail",
+ "failedRecordings": "Échoués",
+ "fullName": "Nom Complet",
+ "goToRecordings": "Aller aux Enregistrements",
+ "interfaceLanguage": "Langue de l'Interface",
+ "jobTitle": "Titre du Poste",
+ "languageForSummaries": "Langue pour les titres, résumés et chat. Laisser vide pour le défaut (comportement par défaut de vos modèles choisis).",
+ "languagePreferences": "Préférences Linguistiques",
+ "leaveBlankForAutoDetect": "Laisser vide pour la détection automatique par le service de transcription",
+ "manageSpeakers": "Gérer les Orateurs",
+ "personalInfo": "Informations Personnelles",
+ "preferredOutputLanguage": "Langue Préférée pour Chatbot et Résumés",
+ "processingRecordings": "En Traitement",
+ "saveAllPreferences": "Sauvegarder Toutes les Préférences",
+ "statistics": "Statistiques du Compte",
+ "title": "Informations du Compte",
+ "totalRecordings": "Total d'Enregistrements",
+ "transcriptionLanguage": "Langue de Transcription",
+ "userDetails": "Détails de l'Utilisateur",
+ "username": "Nom d'Utilisateur"
+ },
+ "accountTabs": {
+ "about": "À Propos",
+ "customPrompts": "Invites Personnalisées",
+ "sharedTranscripts": "Transcriptions Partagées",
+ "speakersManagement": "Gestion des Locuteurs",
+ "tagManagement": "Gestion des Étiquettes",
+ "transcriptTemplates": "Modèles de Transcription",
+ "promptOptions": "Options de Prompt"
+ },
+ "transcriptTemplates": {
+ "title": "Modèles de Transcription",
+ "description": "Personnalisez le formatage des transcriptions pour le téléchargement et l'exportation.",
+ "createNew": "Créer un Modèle",
+ "availableTemplates": "Modèles Disponibles",
+ "createDefaults": "Créer des Modèles par Défaut",
+ "templateName": "Nom du Modèle",
+ "template": "Modèle",
+ "availableVars": "Variables Disponibles",
+ "filters": "Filtres: |upper pour majuscules, |srt pour format sous-titres",
+ "setDefault": "Définir comme modèle par défaut",
+ "save": "Enregistrer",
+ "cancel": "Annuler",
+ "delete": "Supprimer",
+ "selectOrCreate": "Sélectionnez un modèle à éditer ou créez-en un nouveau"
+ },
+ "admin": {
+ "title": "Administration",
+ "userMenu": "Menu Utilisateur"
+ },
+ "adminDashboard": {
+ "aboutInquireMode": "À Propos du Mode Enquête",
+ "actions": "ACTIONS",
+ "addNewUser": "Ajouter un Nouvel Utilisateur",
+ "addUser": "Ajouter un Utilisateur",
+ "additionalContext": "Contexte Supplémentaire",
+ "admin": "ADMIN",
+ "adminDefaultPrompt": "Invite par Défaut de l'Admin",
+ "adminDefaultPromptDesc": "L'invite configurée ci-dessus (affichée sur cette page)",
+ "adminUser": "Utilisateur Administrateur",
+ "allRecordingsProcessed": "Tous les enregistrements sont traités !",
+ "available": "Disponible",
+ "characters": "caractères",
+ "chunkSize": "Taille du Morceau",
+ "complete": "terminé",
+ "completedRecordings": "Terminés",
+ "confirmPasswordLabel": "Confirmer le mot de passe",
+ "contextNotes": {
+ "jsonConversion": "Les transcriptions JSON sont converties en format texte brut avant l'envoi",
+ "modelConfig": "Le modèle utilisé est configuré via la variable d'environnement TEXT_MODEL_NAME",
+ "tagPrompts": "Si plusieurs invites d'étiquettes existent, elles sont fusionnées dans l'ordre où les étiquettes ont été ajoutées",
+ "transcriptLimit": "Les transcriptions sont limitées à un nombre configurable de caractères (par défaut : 30 000)"
+ },
+ "defaultPromptInfo": "Cette invite par défaut sera utilisée pour tous les utilisateurs qui n'ont pas défini leur propre invite personnalisée dans les paramètres de leur compte.",
+ "defaultPrompts": "Invites par Défaut",
+ "defaultSummarizationPrompt": "Invite de Résumé par Défaut",
+ "editUser": "Modifier l'Utilisateur",
+ "email": "E-MAIL",
+ "emailLabel": "E-mail",
+ "embeddingModel": "Modèle d'Intégration",
+ "embeddingsStatus": "Statut des Intégrations",
+ "errors": {
+ "failedToLoadPrompt": "Échec du chargement de l'invite par défaut",
+ "failedToSavePrompt": "Échec de la sauvegarde de l'invite par défaut. Veuillez réessayer.",
+ "invalidFileSize": "Veuillez entrer une taille valide entre 1 et 10000 Mo",
+ "invalidInteger": "Veuillez entrer un nombre entier valide",
+ "invalidNumber": "Veuillez entrer un nombre valide",
+ "maxTimeout": "Le délai d'attente ne peut pas dépasser 10 heures (36000 secondes)",
+ "minCharacters": "Veuillez entrer un nombre valide d'au moins 1000 caractères",
+ "minTimeout": "Le délai d'attente doit être d'au moins 60 secondes"
+ },
+ "failedRecordings": "Échoués",
+ "id": "ID",
+ "inquireModeDescription": "Le mode Enquête permet aux utilisateurs de rechercher dans plusieurs transcriptions en utilisant des questions en langage naturel. Il fonctionne en divisant les transcriptions en morceaux et en créant des intégrations consultables à l'aide de modèles IA.",
+ "languagePreferenceNote": "Note: Si l'utilisateur a défini une préférence de langue de sortie, \" Assurez-vous que votre réponse est en {langue}.\" sera ajouté.",
+ "lastUpdated": "Dernière mise à jour",
+ "maxFileSizeHelp": "Taille maximale du fichier en mégaoctets (1-10000 Mo)",
+ "megabytes": "Mo",
+ "minutes": "minutes",
+ "needProcessing": "Nécessite un Traitement",
+ "never": "Jamais",
+ "newPasswordLabel": "Nouveau Mot de Passe (laisser vide pour conserver l'actuel)",
+ "no": "Non",
+ "noData": "Aucune donnée",
+ "noDescriptionAvailable": "Aucune description disponible",
+ "noLimit": "Aucune Limite",
+ "notSet": "Non défini",
+ "overlap": "Chevauchement",
+ "passwordLabel": "Mot de passe",
+ "pendingRecordings": "En Attente",
+ "placeholdersNote": "Les espaces réservés sont remplacés par des valeurs réelles lors du traitement d'un enregistrement.",
+ "processAllRecordings": "Traiter Tous les Enregistrements",
+ "processNext10": "Traiter les 10 Prochains",
+ "processedForInquire": "Traité pour Enquête",
+ "processing": "Traitement en cours...",
+ "processingActions": "Actions de Traitement",
+ "processingProgress": "Progression du Traitement",
+ "processingRecordings": "En Traitement",
+ "promptDescription": "Cette invite sera utilisée pour générer des résumés pour tous les enregistrements lorsque les utilisateurs n'ont pas défini leur propre invite.",
+ "promptHierarchy": "Hiérarchie des Invites",
+ "promptPriorityDescription": "Le système utilise l'ordre de priorité suivant lors de la sélection de l'invite à utiliser:",
+ "promptResetMessage": "Invite réinitialisée par défaut du système. Cliquez sur \"Enregistrer les modifications\" pour appliquer.",
+ "promptSavedSuccessfully": "Invite par défaut enregistrée avec succès !",
+ "recordingStatusDistribution": "Répartition du Statut des Enregistrements",
+ "recordings": "ENREGISTREMENTS",
+ "recordingsNeedProcessing": "Il y a {{count}} enregistrements qui doivent être traités pour le mode Enquête.",
+ "refreshStatus": "Actualiser le Statut",
+ "resetToDefault": "Réinitialiser par Défaut",
+ "saving": "Enregistrement...",
+ "searchUsers": "Rechercher des utilisateurs...",
+ "seconds": "secondes",
+ "settings": {
+ "asrTimeoutDesc": "Temps maximal en secondes pour attendre la fin de la transcription ASR. La valeur par défaut est 1800 secondes (30 minutes).",
+ "defaultSummaryPromptDesc": "Invite de résumé par défaut utilisée lorsque les utilisateurs n'ont pas défini leur propre invite. Cela sert d'invite de base pour tous les utilisateurs.",
+ "maxFileSizeDesc": "Taille maximale de fichier autorisée pour les téléchargements audio en mégaoctets (Mo).",
+ "recordingDisclaimerDesc": "Avis légal affiché aux utilisateurs avant le début de l'enregistrement. Prend en charge le formatage Markdown. Laisser vide pour désactiver.",
+ "transcriptLengthLimitDesc": "Nombre maximal de caractères à envoyer de la transcription au LLM pour le résumé et le chat. Utilisez -1 pour aucune limite."
+ },
+ "storageUsed": "STOCKAGE UTILISÉ",
+ "summarizationInstructions": "Instructions de Résumé",
+ "systemFallback": "Repli Système",
+ "systemFallbackDesc": "Une valeur par défaut codée en dur si aucune des précédentes n'est définie",
+ "systemPrompt": "Invite Système",
+ "systemSettings": "Paramètres du Système",
+ "systemStatistics": "Statistiques du Système",
+ "tagCustomPrompt": "Invite Personnalisée de l'Étiquette",
+ "tagCustomPromptDesc": "Si un enregistrement a des étiquettes avec des invites personnalisées",
+ "textSearchOnly": "Recherche de Texte Uniquement",
+ "timeoutRecommendation": "Recommandé : 30-120 minutes pour les longs fichiers audio",
+ "title": "Tableau de Bord Administrateur",
+ "topUsers": "Meilleurs Utilisateurs",
+ "topUsersByStorage": "Principaux Utilisateurs par Stockage",
+ "total": "Total",
+ "totalChunks": "Total des Segments",
+ "totalQueries": "Total des Requêtes",
+ "totalRecordings": "Total des Enregistrements",
+ "totalStorage": "Stockage Total",
+ "totalUsers": "Total des Utilisateurs",
+ "updateUser": "Mettre à jour l'Utilisateur",
+ "userCustomPrompt": "Invite Personnalisée de l'Utilisateur",
+ "userCustomPromptDesc": "Si l'utilisateur a défini sa propre invite dans les paramètres du compte",
+ "userManagement": "Gestion des Utilisateurs",
+ "userMessageTemplate": "Modèle de Message Utilisateur",
+ "username": "NOM D'UTILISATEUR",
+ "usernameLabel": "Nom d'utilisateur",
+ "vectorDimensions": "Dimensions du Vecteur",
+ "vectorStore": "Magasin Vectoriel",
+ "vectorStoreManagement": "Gestion du Magasin Vectoriel",
+ "vectorStoreUpToDate": "Le magasin de vecteurs est à jour.",
+ "viewFullPromptStructure": "Voir la Structure Complète de l'Invite LLM",
+ "yes": "Oui"
+ },
+ "buttons": {
+ "cancel": "Cancel",
+ "clearAllFilters": "Effacer tous les filtres",
+ "clearCompleted": "Effacer les téléchargements terminés",
+ "clearSearchText": "Effacer le texte de recherche",
+ "close": "Close",
+ "copyMessage": "Copier le message",
+ "copyNotes": "Copier les Notes",
+ "copySummary": "Copier le Résumé",
+ "copyToClipboard": "Copier dans le Presse-papiers",
+ "createTag": "Create Tag",
+ "deleteAll": "Delete All",
+ "deleteSpeaker": "Supprimer l'intervenant",
+ "deleteTag": "Supprimer l'étiquette",
+ "deleteUser": "Supprimer l'utilisateur",
+ "downloadAsWord": "Télécharger en Word",
+ "downloadChat": "Télécharger la conversation en document Word",
+ "downloadNotes": "Télécharger les notes en document Word",
+ "downloadSummary": "Télécharger le résumé en document Word",
+ "exportCalendar": "Exporter vers le calendrier",
+ "editNotes": "Modifier les Notes",
+ "editSetting": "Éditer le paramètre",
+ "editSummary": "Modifier le Résumé",
+ "editTag": "Éditer l'étiquette",
+ "editTags": "Éditer les étiquettes",
+ "editTranscription": "Modifier la Transcription",
+ "editUser": "Éditer l'utilisateur",
+ "help": "Aide",
+ "identifySpeakers": "Identifier les Orateurs",
+ "refresh": "Refresh",
+ "reprocessSummary": "Retraiter le résumé",
+ "reprocessTranscription": "Retraiter la transcription",
+ "reprocessWithAsr": "Retraiter avec ASR",
+ "resetStuckProcessing": "Réinitialiser le traitement bloqué",
+ "saveChanges": "Enregistrer les Modifications",
+ "saveCustomPrompt": "Save Custom Prompt",
+ "shareRecording": "Partager l'Enregistrement",
+ "toggleTheme": "Changer de thème",
+ "updateTag": "Update Tag",
+ "saveSettings": "Enregistrer les Paramètres"
+ },
+ "changePasswordModal": {
+ "confirmPassword": "Confirmer le Nouveau Mot de Passe",
+ "currentPassword": "Mot de Passe Actuel",
+ "newPassword": "Nouveau Mot de Passe",
+ "passwordRequirement": "Le mot de passe doit comporter au moins 8 caractères",
+ "title": "Changer le Mot de Passe"
+ },
+ "chat": {
+ "availableAfterTranscription": "Le chat sera disponible une fois la transcription terminée",
+ "chatWithTranscription": "Discuter avec la Transcription",
+ "clearChat": "Effacer la Discussion",
+ "error": "Échec de l'envoi du message",
+ "noMessages": "Pas encore de messages",
+ "placeholder": "Posez une question sur cet enregistrement...",
+ "placeholderWithHint": "Posez une question sur cet enregistrement... (Entrée pour envoyer, Ctrl+Entrée pour nouvelle ligne)",
+ "send": "Envoyer",
+ "suggestedQuestions": "Questions Suggérées",
+ "thinking": "Réflexion...",
+ "title": "Discussion",
+ "whatAreActionItems": "Quelles sont les actions à mener?",
+ "whatAreKeyPoints": "Quels sont les points clés?",
+ "whatWasDiscussed": "Qu'est-ce qui a été discuté?",
+ "whoSaidWhat": "Qui a dit quoi?"
+ },
+ "colorScheme": {
+ "chooseRecording": "Choisissez un enregistrement dans la barre latérale pour voir sa transcription et son résumé",
+ "selectRecording": "Sélectionner un Enregistrement",
+ "subtitle": "Personnalisez votre interface avec de magnifiques thèmes de couleur",
+ "title": "Schéma de Couleurs"
+ },
+ "common": {
+ "back": "Retour",
+ "cancel": "Annuler",
+ "close": "Fermer",
+ "confirm": "Confirmer",
+ "delete": "Supprimer",
+ "deselectAll": "Tout Désélectionner",
+ "download": "Télécharger",
+ "edit": "Modifier",
+ "error": "Erreur",
+ "failed": "Échoué",
+ "filter": "Filtrer",
+ "info": "Information",
+ "loading": "Chargement...",
+ "new": "Nouveau",
+ "next": "Suivant",
+ "no": "Non",
+ "noResults": "Aucun résultat trouvé",
+ "ok": "OK",
+ "or": "Or",
+ "previous": "Précédent",
+ "processing": "Traitement...",
+ "refresh": "Actualiser",
+ "retry": "Réessayer",
+ "save": "Enregistrer",
+ "search": "Rechercher",
+ "selectAll": "Tout Sélectionner",
+ "sort": "Trier",
+ "success": "Succès",
+ "untitled": "Sans titre",
+ "upload": "Télécharger",
+ "warning": "Avertissement",
+ "yes": "Oui"
+ },
+ "customPrompts": {
+ "currentDefaultPrompt": "Invite Par Défaut Actuelle (Utilisée si vous laissez le champ ci-dessus vide)",
+ "promptDescription": "Cette invite sera utilisée pour générer des résumés de vos transcriptions. Elle remplace l'invite par défaut de l'administrateur.",
+ "promptPlaceholder": "Décrivez comment vous voulez que vos résumés soient structurés. Laissez vide pour utiliser l'invite par défaut de l'administrateur.",
+ "summaryGeneration": "Invite de Génération de Résumé",
+ "tip1": "Soyez spécifique sur les sections que vous voulez dans votre résumé",
+ "tip2": "Utilisez des instructions de formatage claires (ex. \"Utilisez des puces\", \"Créez des listes numérotées\")",
+ "tip3": "Spécifiez si vous voulez que certaines informations soient prioritaires",
+ "tip4": "Le système fournira automatiquement le contenu de la transcription à l'IA",
+ "tip5": "Votre préférence de langue de sortie (si définie) sera appliquée automatiquement",
+ "tipsTitle": "Conseils pour Rédiger des Invites Efficaces",
+ "yourCustomPrompt": "Votre Invite Personnalisée de Résumé"
+ },
+ "deleteAllSpeakersModal": {
+ "confirmMessage": "Êtes-vous sûr de vouloir supprimer tous les intervenants sauvegardés ? Cette action ne peut pas être annulée.",
+ "title": "Supprimer Tous les Intervenants"
+ },
+ "dialogs": {
+ "deleteRecording": {
+ "cancel": "Annuler",
+ "confirm": "Supprimer",
+ "message": "Êtes-vous sûr de vouloir supprimer cet enregistrement? Cette action ne peut pas être annulée.",
+ "title": "Supprimer l'Enregistrement"
+ },
+ "deleteShare": {
+ "message": "Êtes-vous sûr de vouloir supprimer ce partage ? Cela révoquera l'accès au lien public.",
+ "title": "Supprimer le partage"
+ },
+ "reprocessTranscription": {
+ "cancel": "Annuler",
+ "confirm": "Retraiter",
+ "message": "Êtes-vous sûr de vouloir retraiter cette transcription? La transcription actuelle sera remplacée.",
+ "title": "Retraiter la Transcription"
+ },
+ "unsavedChanges": {
+ "cancel": "Annuler",
+ "discard": "Ignorer",
+ "message": "Vous avez des modifications non enregistrées. Voulez-vous les enregistrer avant de partir?",
+ "save": "Enregistrer",
+ "title": "Modifications Non Enregistrées"
+ }
+ },
+ "duration": {
+ "hours": "{{count}} heure",
+ "hoursPlural": "{{count}} heures",
+ "minutes": "{{count}} minute",
+ "minutesPlural": "{{count}} minutes",
+ "seconds": "{{count}} seconde",
+ "secondsPlural": "{{count}} secondes"
+ },
+ "editTagModal": {
+ "asrDefaultSettings": "Paramètres ASR par Défaut",
+ "asrSettingsDescription": "Ces paramètres seront appliqués par défaut lors de l'utilisation de cette étiquette avec la transcription ASR",
+ "color": "Couleur",
+ "colorDescription": "Choisissez une couleur pour faciliter l'identification",
+ "createTitle": "Créer une Étiquette",
+ "customPrompt": "Invite de Résumé Personnalisée",
+ "customPromptPlaceholder": "Optionnel : Invite personnalisée pour générer des résumés pour les enregistrements avec cette étiquette",
+ "defaultLanguage": "Langue par Défaut",
+ "defaultPromptPlaceholder": "Laissez vide pour utiliser votre invite de résumé par défaut",
+ "leaveBlankPrompt": "Laissez vide pour utiliser votre invite de résumé par défaut",
+ "maxSpeakers": "Maximum d'Intervenants",
+ "minSpeakers": "Minimum d'Intervenants",
+ "tagName": "Nom de l'Étiquette *",
+ "tagNamePlaceholder": "ex., Réunions, Entretiens",
+ "title": "Modifier l'Étiquette"
+ },
+ "errors": {
+ "audioRecordingFailed": "音频录制失败。请检查您的麦克风。",
+ "fileTooLarge": "文件太大",
+ "generic": "发生错误",
+ "loadingShares": "Erreur lors du chargement des partages",
+ "networkError": "网络错误。请检查您的连接。",
+ "notFound": "未找到",
+ "permissionDenied": "权限被拒绝",
+ "quotaExceeded": "存储配额已超出",
+ "serverError": "服务器错误。请稍后重试。",
+ "summaryFailed": "摘要生成失败。请重试。",
+ "transcriptionFailed": "转录失败。请重试。",
+ "unauthorized": "未授权",
+ "unsupportedFormat": "不支持的文件格式",
+ "uploadFailed": "上传失败。请重试。",
+ "validationError": "验证错误"
+ },
+ "fileSize": {
+ "bytes": "{{count}} B",
+ "gigabytes": "{{count}} GB",
+ "kilobytes": "{{count}} KB",
+ "megabytes": "{{count}} MB"
+ },
+ "form": {
+ "auto": "Auto",
+ "autoDetect": "Détection automatique",
+ "dateFrom": "De",
+ "dateTo": "À",
+ "enterNotesMarkdown": "Entrez des notes au format Markdown...",
+ "enterSummaryMarkdown": "Entrez un résumé au format Markdown...",
+ "language": "Langue",
+ "maxSpeakers": "Orateurs Maximum",
+ "meetingDate": "Date de Réunion",
+ "minSpeakers": "Orateurs Minimum",
+ "minutes": "Minutes",
+ "notes": "Notes",
+ "notesPlaceholder": "Tapez vos notes au format Markdown...",
+ "optional": "Optionnel",
+ "participants": "Participants",
+ "placeholderAuto": "Auto",
+ "placeholderCharacterLimit": "Entrez la limite de caractères (ex. 30000)",
+ "placeholderMinutes": "Minutes",
+ "placeholderOptional": "Optionnel",
+ "placeholderSeconds": "Secondes",
+ "placeholderSizeMB": "Entrez la taille en Mo",
+ "searchSpeakers": "Rechercher des intervenants...",
+ "searchTags": "Rechercher des étiquettes...",
+ "seconds": "Secondes",
+ "shareNotes": "Partager les Notes",
+ "shareSummary": "Partager le Résumé",
+ "shareableLink": "Lien Partageable",
+ "summaryPromptPlaceholder": "Entrez l'invite de résumé par défaut...",
+ "title": "Titre",
+ "yourFullName": "Votre nom complet"
+ },
+ "help": {
+ "actions": "Actions",
+ "activeFilters": "Filtres actifs",
+ "advancedAsrOptions": "Options ASR Avancées",
+ "allRecordingsLoaded": "Tous les enregistrements chargés",
+ "approachingLimit": "Approche de la limite de {{limit}}MB",
+ "askAboutTranscription": "Posez des questions sur cette transcription",
+ "audioPlayer": "Lecteur Audio",
+ "autoIdentify": "Identifier Automatiquement",
+ "bothAudioDesc": "Enregistre votre voix + les participants à la réunion (recommandé pour les réunions en ligne)",
+ "clearFilters": "effacer les filtres",
+ "clickToAddNotes": "Cliquez pour ajouter des notes...",
+ "colorRepeats": "La couleur se répète à partir de l'orateur {{number}}",
+ "completedFiles": "Fichiers Terminés",
+ "confirmReprocessingTitle": "Confirmer le Retraitement",
+ "copyMessage": "Copier le message",
+ "createPublicLink": "Créer un lien public pour partager cet enregistrement. Le partage n'est disponible que sur les connexions sécurisées (HTTPS).",
+ "createTags": "Créer des étiquettes",
+ "discard": "Ignorer",
+ "endTime": "Fin",
+ "enterNameFor": "Entrez le nom pour",
+ "entireScreen": "Écran Entier",
+ "estimatedSize": "Taille estimée",
+ "generatingSummary": "Génération du résumé...",
+ "howToRecordSystemAudio": "Comment Enregistrer l'Audio Système",
+ "importantNote": "Note importante",
+ "lines": "{{count}} lignes",
+ "loadingMore": "Chargement de plus d'enregistrements...",
+ "loadingRecordings": "Chargement des enregistrements...",
+ "me": "Moi",
+ "microphoneDesc": "Enregistre uniquement votre voix",
+ "modelReasoning": "Raisonnement du Modèle",
+ "moreSpeakersThanColors": "Plus d'orateurs que de couleurs disponibles",
+ "noDateSet": "Aucune date définie",
+ "noParticipants": "Aucun participant",
+ "noTagsCreated": "Aucune étiquette créée pour le moment.",
+ "processingTime": "Temps de traitement",
+ "processingTimeDescription": "Cela peut prendre quelques minutes. Vous pouvez continuer à utiliser l'application pendant le traitement.",
+ "processingTranscription": "Traitement de la transcription...",
+ "recordSystemSteps1": "Cliquez sur \"Enregistrer l'Audio Système\" ou \"Enregistrer les Deux\".",
+ "recordSystemSteps2": "Dans la fenêtre contextuelle, choisissez",
+ "recordSystemSteps3": "Assurez-vous de cocher la case qui dit",
+ "recordingFinished": "Enregistrement terminé",
+ "recordingInProgress": "Enregistrement en cours...",
+ "regenerateSummaryAfterNames": "Régénérer le résumé après la mise à jour des noms",
+ "sentence": "Phrase",
+ "shareSystemAudio": "Partager l'audio du système",
+ "shareTabAudio": "Partager l'audio de l'onglet",
+ "sharedOn": "Partagé le",
+ "sharingWindowNoAudio": "Partager une \"Fenêtre\" ne capturera pas l'audio.",
+ "speakerCount": "Orateur",
+ "specificBrowserTab": "onglet de navigateur spécifique",
+ "startTime": "Début",
+ "systemAudioDesc": "Enregistre les participants à la réunion et les sons du système",
+ "tagManagement": "Gestion des Étiquettes",
+ "thisActionCannotBeUndone": "Cette action ne peut pas être annulée.",
+ "toCaptureAudioFromMeetings": "Pour capturer l'audio des réunions ou d'autres applications, vous devez partager votre écran ou un onglet du navigateur.",
+ "troubleshooting": "Dépannage",
+ "tryAdjustingSearch": "Essayez d'ajuster votre recherche ou",
+ "unsupportedBrowser": "Navigateur Non Pris en Charge",
+ "untitled": "Enregistrement Sans Titre",
+ "uploadRecordingNotes": "Télécharger l'Enregistrement et les Notes",
+ "whatWillHappen": "Que va-t-il se passer?",
+ "whyNotWorking": "Pourquoi ça ne marche pas?",
+ "speakers": "Orateurs"
+ },
+ "inquire": {
+ "title": "Enquêter",
+ "filters": "Filtres",
+ "clearAll": "Effacer Tout",
+ "tags": "Étiquettes",
+ "speakers": "Intervenants",
+ "dateRange": "Plage de Dates",
+ "from": "De",
+ "to": "À",
+ "noSpeakerData": "Aucune donnée d'intervenant disponible",
+ "speakerRequirement": "L'identification des intervenants nécessite des enregistrements avec plusieurs intervenants",
+ "activeFilters": "Filtres Actifs :",
+ "tagsCount": "étiquettes",
+ "speakersCount": "intervenants",
+ "dateRangeActive": "Plage de dates active",
+ "filtersActive": "Filtres actifs",
+ "askQuestions": "Posez des Questions sur Vos Transcriptions",
+ "selectFilters": "Sélectionnez les filtres à gauche pour affiner vos transcriptions, puis posez des questions pour obtenir des informations à partir de vos enregistrements.",
+ "exampleQuestions": "Exemples de Questions :",
+ "exampleQuestion1": "\"Quels éléments d'action ont été discutés ?\"",
+ "exampleQuestion2": "\"Quand avons-nous décidé de changer le calendrier ?\"",
+ "exampleQuestion3": "\"Quelles préoccupations ont été soulevées concernant le budget ?\"",
+ "exampleQuestion4": "\"Qui était responsable des tâches de marketing ?\"",
+ "placeholder": "Posez des questions sur vos transcriptions filtrées...",
+ "sendHint": "Appuyez sur Entrée pour envoyer • Ctrl+Entrée pour nouvelle ligne"
+ },
+ "languages": {
+ "ar": "Arabe",
+ "de": "Allemand",
+ "en": "Anglais",
+ "es": "Espagnol",
+ "fr": "Français",
+ "hi": "Hindi",
+ "it": "Italien",
+ "ja": "Japonais",
+ "ko": "Coréen",
+ "nl": "Néerlandais",
+ "pt": "Portugais",
+ "ru": "Russe",
+ "zh": "Chinois"
+ },
+ "manageSpeakersModal": {
+ "created": "Créé",
+ "description": "Gérez vos intervenants sauvegardés. Ils sont automatiquement sauvegardés lorsque vous utilisez des noms d'intervenants dans vos enregistrements.",
+ "failedToLoad": "Échec du chargement des intervenants",
+ "lastUsed": "Dernière utilisation",
+ "loadingSpeakers": "Chargement des intervenants...",
+ "noSpeakersYet": "Aucun intervenant sauvegardé pour le moment",
+ "speakersSaved": "{{count}} intervenants sauvegardés",
+ "speakersWillAppear": "Les intervenants apparaîtront ici lorsque vous utiliserez des noms d'intervenants dans vos enregistrements",
+ "times": "fois",
+ "title": "Gérer les Intervenants",
+ "used": "Utilisé"
+ },
+ "messages": {
+ "copiedToClipboard": "Copié dans le presse-papiers",
+ "downloadStarted": "Téléchargement commencé",
+ "notesUpdated": "Notes mises à jour avec succès",
+ "passwordChanged": "Mot de passe changé avec succès",
+ "profileUpdated": "Profil mis à jour avec succès",
+ "recordingDeleted": "Enregistrement supprimé avec succès",
+ "recordingSaved": "Enregistrement sauvegardé avec succès",
+ "settingsSaved": "Paramètres enregistrés avec succès",
+ "summaryGenerated": "Résumé généré avec succès",
+ "tagAdded": "Étiquette ajoutée avec succès",
+ "tagRemoved": "Étiquette supprimée avec succès",
+ "transcriptionUpdated": "Transcription mise à jour avec succès"
+ },
+ "metadata": {
+ "cancelEdit": "Annuler",
+ "createdAt": "Créé",
+ "duration": "Durée",
+ "editMetadata": "Modifier les Métadonnées",
+ "fileName": "Nom du Fichier",
+ "fileSize": "Taille du Fichier",
+ "language": "Langue",
+ "meetingDate": "Date de Réunion",
+ "processingTime": "Temps de Traitement",
+ "saveMetadata": "Enregistrer",
+ "status": "Statut",
+ "title": "Métadonnées",
+ "updatedAt": "Mis à jour",
+ "wordCount": "Nombre de Mots"
+ },
+ "modal": {
+ "colorScheme": "Schéma de Couleurs",
+ "deleteRecording": "Supprimer l'Enregistrement",
+ "editAsrTranscription": "Modifier la Transcription ASR",
+ "editRecording": "Modifier l'Enregistrement",
+ "editTags": "Modifier les Étiquettes d'Enregistrement",
+ "editTranscription": "Modifier la Transcription",
+ "identifySpeakers": "Identifier les Orateurs",
+ "recordingNotice": "Avis d'Enregistrement",
+ "reprocessSummary": "Retraiter le Résumé",
+ "reprocessTranscription": "Retraiter la Transcription",
+ "resetStatus": "Réinitialiser le Statut de l'Enregistrement?",
+ "shareRecording": "Partager l'Enregistrement",
+ "sharedTranscripts": "Mes Transcriptions Partagées",
+ "systemAudioHelp": "Aide Audio Système"
+ },
+ "nav": {
+ "account": "Compte",
+ "accountSettings": "Paramètres du Compte",
+ "admin": "Administration",
+ "adminDashboard": "Tableau de Bord Admin",
+ "darkMode": "Mode Sombre",
+ "home": "Accueil",
+ "language": "Langue",
+ "lightMode": "Mode Clair",
+ "newRecording": "Nouvel Enregistrement",
+ "recording": "Enregistrement",
+ "settings": "Paramètres",
+ "signOut": "Déconnexion",
+ "upload": "Télécharger",
+ "userProfile": "Profil Utilisateur"
+ },
+ "notes": {
+ "cancelEdit": "Annuler la Modification",
+ "characterCount": "{{count}} caractère",
+ "characterCountPlural": "{{count}} caractères",
+ "editNotes": "Modifier les Notes",
+ "lastUpdated": "Dernière mise à jour",
+ "placeholder": "Ajoutez vos notes ici...",
+ "saveNotes": "Enregistrer les Notes",
+ "title": "Notes"
+ },
+ "recording": {
+ "acceptDisclaimer": "J'accepte",
+ "cancelRecording": "Annuler",
+ "disclaimer": "Avertissement d'Enregistrement",
+ "microphone": "Microphone",
+ "microphoneAndSystem": "Microphone + Système",
+ "microphonePermissionDenied": "Permission du microphone refusée",
+ "notes": "Notes",
+ "notesPlaceholder": "Ajouter des notes sur cet enregistrement...",
+ "pauseRecording": "Pause",
+ "recordingFailed": "L'enregistrement a échoué",
+ "recordingInProgress": "Enregistrement en cours...",
+ "recordingSize": "Taille Estimée",
+ "recordingStopped": "Enregistrement arrêté",
+ "recordingTime": "Temps d'Enregistrement",
+ "resumeRecording": "Reprendre",
+ "saveRecording": "Enregistrer l'Enregistrement",
+ "startRecording": "Démarrer l'Enregistrement",
+ "stopRecording": "Arrêter l'Enregistrement",
+ "systemAudio": "Audio Système",
+ "systemAudioNotSupported": "L'enregistrement audio système n'est pas pris en charge dans ce navigateur",
+ "title": "Enregistrement Audio"
+ },
+ "reprocessModal": {
+ "audioReTranscribedFromScratch": "L'audio sera re-transcrit à partir de zéro. Cela régénérera également le titre et le résumé basés sur la nouvelle transcription.",
+ "audioReTranscribedWithAsr": "L'audio sera re-transcrit en utilisant l'endpoint ASR. Cela inclut la diarisation et régénérera le titre et le résumé.",
+ "manualEditsOverwritten": "Toute modification manuelle de la transcription, du titre ou du résumé sera écrasée.",
+ "manualEditsOverwrittenSummary": "Toute modification manuelle du titre ou du résumé sera écrasée.",
+ "newTitleAndSummary": "Un nouveau titre et résumé seront générés basés sur la transcription existante."
+ },
+ "settings": {
+ "apiKeys": "Clés API",
+ "appearance": "Apparence",
+ "changePassword": "Changer le Mot de Passe",
+ "dataExport": "Export de Données",
+ "deleteAccount": "Supprimer le Compte",
+ "integrations": "Intégrations",
+ "language": "Langue",
+ "notifications": "Notifications",
+ "preferences": "Préférences",
+ "privacy": "Confidentialité",
+ "profile": "Profil",
+ "security": "Sécurité",
+ "theme": "Thème",
+ "title": "Paramètres",
+ "twoFactorAuth": "Authentification à Deux Facteurs"
+ },
+ "sharedTranscripts": {
+ "noSharedTranscripts": "Vous n'avez encore partagé aucune transcription."
+ },
+ "sharedTranscriptsPage": {
+ "noSharedTranscripts": "Vous n'avez pas encore partagé de transcriptions."
+ },
+ "sidebar": {
+ "advancedSearch": "Recherche Avancée",
+ "dateRange": "Plage de Dates",
+ "filters": "Filtres",
+ "highlighted": "Mis en Évidence",
+ "inbox": "Boîte de Réception",
+ "lastMonth": "Mois Dernier",
+ "lastWeek": "Semaine Dernière",
+ "loadMore": "Charger Plus",
+ "markAsRead": "Mark as read",
+ "moveToInbox": "Move to Inbox",
+ "noRecordings": "Aucun enregistrement trouvé",
+ "older": "Plus Ancien",
+ "removeFromHighlighted": "Remove from starred",
+ "searchRecordings": "Rechercher des enregistrements...",
+ "sortBy": "Trier Par",
+ "sortByDate": "Date de Création",
+ "sortByMeetingDate": "Date de Réunion",
+ "tags": "Étiquettes",
+ "thisMonth": "Ce Mois",
+ "thisWeek": "Cette Semaine",
+ "today": "Aujourd'hui",
+ "totalRecordings": "{{count}} enregistrement",
+ "totalRecordingsPlural": "{{count}} enregistrements",
+ "yesterday": "Hier"
+ },
+ "status": {
+ "completed": "Terminé",
+ "failed": "Échec",
+ "processing": "Traitement",
+ "queued": "En file d'attente",
+ "stuck": "Réinitialiser le traitement bloqué",
+ "summarizing": "Résumé",
+ "transcribing": "Transcription",
+ "uploading": "Téléchargement"
+ },
+ "summary": {
+ "actionItems": "Actions à Mener",
+ "cancelEdit": "Annuler la Modification",
+ "decisions": "Décisions",
+ "editSummary": "Modifier le Résumé",
+ "generateSummary": "Générer un Résumé",
+ "keyPoints": "Points Clés",
+ "noSummary": "Aucun résumé disponible",
+ "participants": "Participants",
+ "regenerateSummary": "Régénérer le Résumé",
+ "saveSummary": "Enregistrer le Résumé",
+ "summaryFailed": "La génération du résumé a échoué",
+ "summaryInProgress": "Génération du résumé en cours...",
+ "title": "Résumé"
+ },
+ "tagManagement": {
+ "asrDefaults": "Paramètres ASR par Défaut",
+ "createTag": "Créer une Étiquette",
+ "customPrompt": "Invite Personnalisée",
+ "description": "Organisez vos enregistrements avec des étiquettes personnalisées. Chaque étiquette peut avoir sa propre invite de résumé et ses paramètres ASR par défaut.",
+ "maxSpeakers": "Max",
+ "minSpeakers": "Min",
+ "noTags": "Vous n'avez pas encore créé d'étiquettes."
+ },
+ "tags": {
+ "addTag": "Ajouter une Étiquette",
+ "clearTagFilter": "Effacer le filtre",
+ "createTag": "Créer une Étiquette",
+ "filterByTag": "Filtrer par étiquette",
+ "manageAllTags": "Gérer Toutes les Étiquettes",
+ "noTags": "Aucune étiquette",
+ "removeTag": "Supprimer l'Étiquette",
+ "tagColor": "Couleur de l'Étiquette",
+ "tagName": "Nom de l'Étiquette",
+ "title": "Étiquettes"
+ },
+ "tagsModal": {
+ "addTags": "Ajouter des Étiquettes",
+ "currentTags": "Étiquettes Actuelles",
+ "done": "Terminé",
+ "noTagsAssigned": "Aucune étiquette assignée à cet enregistrement",
+ "searchTags": "Rechercher des étiquettes..."
+ },
+ "time": {
+ "dayAgo": "Il y a 1 jour",
+ "daysAgo": "Il y a {{count}} jours",
+ "hourAgo": "Il y a 1 heure",
+ "hoursAgo": "Il y a {{count}} heures",
+ "justNow": "À l'instant",
+ "minuteAgo": "Il y a 1 minute",
+ "minutesAgo": "Il y a {{count}} minutes",
+ "monthAgo": "Il y a 1 mois",
+ "monthsAgo": "Il y a {{count}} mois",
+ "weekAgo": "Il y a 1 semaine",
+ "weeksAgo": "Il y a {{count}} semaines",
+ "yearAgo": "Il y a 1 an",
+ "yearsAgo": "Il y a {{count}} ans"
+ },
+ "transcription": {
+ "autoIdentifySpeakers": "Identifier Automatiquement les Orateurs",
+ "simple": "Simple",
+ "bubble": "Bulle",
+ "copy": "Copier",
+ "download": "Télécharger",
+ "edit": "Éditer",
+ "cancelEdit": "Annuler la Modification",
+ "copyToClipboard": "Copier dans le Presse-papiers",
+ "downloadTranscript": "Télécharger la Transcription",
+ "editSpeakers": "Modifier les Orateurs",
+ "editTranscription": "Modifier la Transcription",
+ "highlightSearchResults": "Surligner les résultats de recherche",
+ "noTranscription": "Aucune transcription disponible",
+ "regenerateTranscription": "Régénérer la Transcription",
+ "saveTranscription": "Enregistrer la Transcription",
+ "searchInTranscript": "Rechercher dans la transcription...",
+ "speaker": "Orateur {{number}}",
+ "speakerLabels": "Étiquettes d'Orateur",
+ "title": "Transcription",
+ "unknownSpeaker": "Orateur Inconnu"
+ },
+ "upload": {
+ "chunking": "Les gros fichiers seront automatiquement divisés pour le traitement",
+ "completed": "Terminé",
+ "dropzone": "Glissez et déposez des fichiers audio ici, ou cliquez pour parcourir",
+ "failed": "Échec",
+ "maxFileSize": "Taille maximale du fichier",
+ "queued": "En file d'attente",
+ "selectFiles": "Sélectionner des Fichiers",
+ "summarizing": "Résumé...",
+ "supportedFormats": "Formats pris en charge",
+ "title": "Télécharger Audio",
+ "transcribing": "Transcription...",
+ "untitled": "Untitled Recording",
+ "uploadProgress": "Progression du Téléchargement",
+ "willAutoSummarize": "Résumé automatique après la transcription"
+ },
+ "uploadProgress": {
+ "title": "Progression du Téléchargement"
+ },
+ "eventExtraction": {
+ "title": "Extraction d'Événements",
+ "enableLabel": "Activer l'extraction automatique d'événements des transcriptions",
+ "description": "Lorsqu'activé, l'IA identifiera les réunions, rendez-vous et échéances mentionnés dans vos enregistrements et créera des événements de calendrier téléchargeables.",
+ "info": "Les événements extraits apparaîtront dans l'onglet 'Événements' sur les enregistrements où des éléments de calendrier sont détectés."
+ },
+ "events": {
+ "title": "Événements",
+ "addToCalendar": "Ajouter au Calendrier",
+ "noEvents": "Aucun événement détecté dans cet enregistrement",
+ "start": "Début",
+ "end": "Fin",
+ "location": "Lieu",
+ "attendees": "Participants"
+ }
+}
\ No newline at end of file
diff --git a/speakr/static/locales/zh.json b/speakr/static/locales/zh.json
new file mode 100644
index 0000000..a6f030a
--- /dev/null
+++ b/speakr/static/locales/zh.json
@@ -0,0 +1,1030 @@
+{
+ "aboutPage": {
+ "aiSummarization": "AI 摘要生成",
+ "aiSummarizationDesc": "集成 OpenRouter 和 Ollama,支持自定义提示词",
+ "asrEnabled": "ASR 已启用",
+ "asrEndpoint": "ASR 端点",
+ "audioTranscription": "音频转录",
+ "audioTranscriptionDesc": "支持 Whisper API 和自定义 ASR,准确率高",
+ "backend": "后端",
+ "database": "数据库",
+ "deployment": "部署",
+ "dockerDescription": "官方 Docker 镜像",
+ "dockerHub": "Docker Hub",
+ "documentation": "文档",
+ "documentationDescription": "设置指南和用户手册",
+ "endpoint": "端点",
+ "frontend": "前端",
+ "githubDescription": "源代码、问题和发布",
+ "githubRepository": "GitHub 仓库",
+ "inquireMode": "查询模式",
+ "inquireModeDesc": "在所有录音中进行语义搜索",
+ "interactiveChat": "交互式聊天",
+ "interactiveChatDesc": "使用 AI 与您的转录内容对话",
+ "keyFeatures": "主要功能",
+ "largeLanguageModel": "大语言模型",
+ "model": "模型",
+ "projectDescription": "通过 AI 驱动的转录、摘要和交互式聊天功能,将您的音频录音转换为有组织、可搜索的笔记。",
+ "projectLinks": "项目链接",
+ "sharingExport": "分享和导出",
+ "sharingExportDesc": "分享录音并导出为各种格式",
+ "speakerDiarization": "说话人分离",
+ "speakerDiarizationDesc": "自动识别和标记不同的说话人",
+ "speechRecognition": "语音识别",
+ "systemConfiguration": "系统配置",
+ "tagline": "AI 驱动的音频转录和笔记工具",
+ "technologyStack": "技术栈",
+ "title": "关于",
+ "version": "版本",
+ "whisperApi": "Whisper API",
+ "systemInformation": "系统信息",
+ "techStack": "技术栈",
+ "whisperModel": "Whisper 模型"
+ },
+ "aboutPageDetails": {
+ "aiSummarizationDesc": "集成 OpenRouter 和 Ollama,支持自定义提示词",
+ "asrEnabled": "ASR 已启用",
+ "asrEndpoint": "ASR 端点",
+ "audioTranscriptionDesc": "支持 Whisper API 和自定义 ASR,准确率高",
+ "backend": "后端",
+ "database": "数据库",
+ "deployment": "部署",
+ "dockerDescription": "官方 Docker 镜像",
+ "documentationDescription": "设置指南和用户手册",
+ "endpoint": "端点",
+ "frontend": "前端",
+ "githubDescription": "源代码、问题和发布",
+ "inquireModeDesc": "在所有录音中进行语义搜索",
+ "interactiveChatDesc": "使用 AI 与您的转录内容对话",
+ "model": "模型",
+ "no": "否",
+ "sharingExportDesc": "分享录音并导出为各种格式",
+ "speakerDiarizationDesc": "自动识别和标记不同的说话人",
+ "whisperApi": "Whisper API",
+ "yes": "是",
+ "dockerHub": "Docker Hub",
+ "documentation": "文档",
+ "githubRepository": "GitHub 仓库",
+ "largeLanguageModel": "大语言模型",
+ "projectDescription": "通过 AI 驱动的转录、摘要和交互式聊天功能,将您的音频录音转换为有组织、可搜索的笔记。",
+ "whisperModel": "Whisper 模型"
+ },
+ "account": {
+ "accountActions": "账户操作",
+ "changePassword": "更改密码",
+ "chooseLanguageForInterface": "选择应用程序界面语言",
+ "companyOrganization": "公司/组织",
+ "completedRecordings": "已完成",
+ "email": "邮箱",
+ "failedRecordings": "失败",
+ "fullName": "全名",
+ "goToRecordings": "前往录音",
+ "interfaceLanguage": "界面语言",
+ "jobTitle": "职位",
+ "languageForSummaries": "标题、摘要和聊天的语言。留空使用默认值(您所选模型的默认行为)",
+ "languagePreferences": "语言偏好",
+ "leaveBlankForAutoDetect": "留空以自动检测语言",
+ "manageSpeakers": "管理发言人",
+ "personalInfo": "个人信息",
+ "preferredOutputLanguage": "聊天机器人和摘要的首选语言",
+ "processingRecordings": "处理中",
+ "saveAllPreferences": "保存所有偏好设置",
+ "statistics": "账户统计",
+ "title": "账户信息",
+ "totalRecordings": "录音总数",
+ "transcriptionLanguage": "转录语言",
+ "userDetails": "用户详情",
+ "username": "用户名",
+ "about": "关于",
+ "accountSettings": "账户设置",
+ "customPrompts": "自定义提示",
+ "mySharedTranscripts": "我的共享转录",
+ "profile": "个人资料",
+ "sharedTranscripts": "共享转录",
+ "tagManagement": "标签管理",
+ "name": "姓名",
+ "outputLanguage": "输出语言"
+ },
+ "accountTabs": {
+ "about": "关于",
+ "customPrompts": "自定义提示",
+ "sharedTranscripts": "共享转录",
+ "speakersManagement": "发言人管理",
+ "tagManagement": "标签管理",
+ "transcriptTemplates": "转录模板",
+ "promptOptions": "提示选项"
+ },
+ "transcriptTemplates": {
+ "title": "转录模板",
+ "description": "自定义转录的下载和导出格式。",
+ "createNew": "创建模板",
+ "availableTemplates": "可用模板",
+ "createDefaults": "创建默认模板",
+ "templateName": "模板名称",
+ "template": "模板",
+ "availableVars": "可用变量",
+ "filters": "过滤器:|upper 转大写,|srt 字幕格式",
+ "setDefault": "设为默认模板",
+ "save": "保存",
+ "cancel": "取消",
+ "delete": "删除",
+ "selectOrCreate": "选择要编辑的模板或创建新模板",
+ "viewGuide": "查看模板指南"
+ },
+ "admin": {
+ "title": "管理员",
+ "userMenu": "用户菜单",
+ "adminPanel": "管理面板",
+ "settings": "设置",
+ "mySharedTranscripts": "我的共享转录",
+ "tagManagement": "标签管理",
+ "admin": "管理员",
+ "colorScheme": "配色方案",
+ "logout": "退出登录"
+ },
+ "adminDashboard": {
+ "aboutInquireMode": "关于查询模式",
+ "actions": "操作",
+ "addNewUser": "添加新用户",
+ "addUser": "添加用户",
+ "additionalContext": "附加上下文",
+ "admin": "管理员",
+ "adminDefaultPrompt": "管理员默认提示",
+ "adminDefaultPromptDesc": "上面配置的提示(显示在此页面)",
+ "adminUser": "管理员用户",
+ "allRecordingsProcessed": "所有录音已处理完成!",
+ "available": "可用",
+ "characters": "字符",
+ "chunkSize": "块大小",
+ "complete": "完成",
+ "completedRecordings": "已完成",
+ "confirmPasswordLabel": "确认密码",
+ "contextNotes": {
+ "jsonConversion": "JSON 转录在发送前会转换为纯文本格式",
+ "modelConfig": "使用的模型通过 TEXT_MODEL_NAME 环境变量配置",
+ "tagPrompts": "如果存在多个标签提示,它们将按添加标签的顺序合并",
+ "transcriptLimit": "转录限制为可配置的字符数(默认:30,000)"
+ },
+ "defaultPromptInfo": "此默认提示将用于所有未在账户设置中设置自定义提示的用户。",
+ "defaultPrompts": "默认提示",
+ "defaultSummarizationPrompt": "默认摘要提示",
+ "editUser": "编辑用户",
+ "email": "邮箱",
+ "emailLabel": "邮箱",
+ "embeddingModel": "嵌入模型",
+ "embeddingsStatus": "嵌入状态",
+ "errors": {
+ "failedToLoadPrompt": "加载默认提示失败",
+ "failedToSavePrompt": "保存默认提示失败,请重试。",
+ "invalidFileSize": "请输入 1 到 10000 MB 之间的有效大小",
+ "invalidInteger": "请输入有效的整数",
+ "invalidNumber": "请输入有效的数字",
+ "maxTimeout": "超时不能超过 10 小时(36000 秒)",
+ "minCharacters": "请输入至少 1000 个字符",
+ "minTimeout": "超时必须至少为 60 秒"
+ },
+ "failedRecordings": "失败",
+ "id": "ID",
+ "inquireModeDescription": "查询模式允许用户使用自然语言问题搜索多个转录。它通过将转录分成块并使用 AI 模型创建可搜索的嵌入来工作。",
+ "languagePreferenceNote": "注意:如果用户设置了输出语言偏好,将添加\"确保您的回复使用 {language}\"。",
+ "lastUpdated": "最后更新",
+ "maxFileSizeHelp": "最大文件大小(兆字节)(1-10000 MB)",
+ "megabytes": "MB",
+ "minutes": "分钟",
+ "needProcessing": "需要处理",
+ "never": "从未",
+ "newPasswordLabel": "新密码(留空保持不变)",
+ "no": "否",
+ "noData": "无数据",
+ "noDescriptionAvailable": "无描述",
+ "noLimit": "无限制",
+ "notSet": "未设置",
+ "overlap": "重叠",
+ "passwordLabel": "密码",
+ "pendingRecordings": "待处理",
+ "placeholdersNote": "处理录音时,占位符会被实际值替换。",
+ "processAllRecordings": "处理所有录音",
+ "processNext10": "处理接下来的 10 个",
+ "processedForInquire": "已为查询处理",
+ "processing": "处理中...",
+ "processingActions": "处理操作",
+ "processingProgress": "处理进度",
+ "processingRecordings": "处理中",
+ "promptDescription": "当用户未设置自己的提示时,此提示将用于生成所有录音的摘要。",
+ "promptHierarchy": "提示优先级",
+ "promptPriorityDescription": "系统在选择使用哪个提示时使用以下优先级顺序:",
+ "promptResetMessage": "提示已重置为系统默认值。点击\"保存更改\"应用。",
+ "promptSavedSuccessfully": "默认提示保存成功!",
+ "recordingStatusDistribution": "录音状态分布",
+ "recordings": "录音",
+ "recordingsNeedProcessing": "{{count}} 个录音需要处理",
+ "refreshStatus": "刷新状态",
+ "resetToDefault": "重置为默认",
+ "saving": "保存中...",
+ "searchUsers": "搜索用户...",
+ "seconds": "秒",
+ "settings": {
+ "asrTimeoutDesc": "等待 ASR 转录完成的最大时间(秒)。默认为 1800 秒(30 分钟)。",
+ "defaultSummaryPromptDesc": "用户未设置自己的提示时使用的默认摘要提示。这作为所有用户的基础提示。",
+ "maxFileSizeDesc": "允许的音频上传最大文件大小(兆字节)。",
+ "recordingDisclaimerDesc": "录音开始前向用户显示的法律免责声明。支持 Markdown 格式。留空以禁用。",
+ "transcriptLengthLimitDesc": "从转录发送到 LLM 进行摘要和聊天的最大字符数。使用 -1 表示无限制。"
+ },
+ "storageUsed": "已用存储",
+ "summarizationInstructions": "摘要说明",
+ "systemFallback": "系统回退",
+ "systemFallbackDesc": "如果以上都未设置,则使用硬编码默认值",
+ "systemPrompt": "系统提示",
+ "systemSettings": "系统设置",
+ "systemStatistics": "系统统计",
+ "tagCustomPrompt": "标签自定义提示",
+ "tagCustomPromptDesc": "如果录音有带自定义提示的标签",
+ "textSearchOnly": "仅文本搜索",
+ "timeoutRecommendation": "推荐:长音频文件使用 30-120 分钟",
+ "title": "管理仪表板",
+ "topUsers": "顶级用户",
+ "topUsersByStorage": "按存储排序的顶级用户",
+ "total": "总计",
+ "totalChunks": "总块数",
+ "totalQueries": "总查询数",
+ "totalRecordings": "总录音数",
+ "totalStorage": "总存储",
+ "totalUsers": "总用户数",
+ "updateUser": "更新用户",
+ "userCustomPrompt": "用户自定义提示",
+ "userCustomPromptDesc": "如果用户在账户设置中设置了自己的提示",
+ "userManagement": "用户管理",
+ "userMessageTemplate": "用户消息模板",
+ "username": "用户名",
+ "usernameLabel": "用户名",
+ "vectorDimensions": "向量维度",
+ "vectorStore": "向量存储",
+ "vectorStoreManagement": "向量存储管理",
+ "vectorStoreUpToDate": "向量存储是最新的",
+ "viewFullPromptStructure": "查看完整的 LLM 提示结构",
+ "yes": "是",
+ "allJobsCompleted": "所有任务已完成",
+ "asrConfigurationTitle": "ASR 配置",
+ "asrTimeout": "ASR 超时",
+ "asrTimeoutSeconds": "ASR 超时(秒)",
+ "asrTimeoutSecondsDesc": "ASR 处理的最大超时时间(秒)",
+ "blackHoleDirectory": "黑洞目录",
+ "buildingVectorStore": "构建向量存储",
+ "buildVectorStore": "构建向量存储",
+ "cancelAll": "取消全部",
+ "charactersWithLimit": "{{count}} 字符",
+ "clearBlackHoleJobs": "清除黑洞任务",
+ "confirmDeleteUser": "确认删除用户",
+ "confirmDeleteUserMessage": "您确定要删除用户 {{username}} 吗?",
+ "confirmPassword": "确认密码",
+ "customPrompts": "自定义提示",
+ "defaultPrompt": "默认提示",
+ "defaultSummaryPrompt": "默认摘要提示",
+ "deleteUser": "删除用户",
+ "description": "描述",
+ "editPrompt": "编辑提示",
+ "editSetting": "编辑设置",
+ "embeddingsInfo": "嵌入信息",
+ "enableBlackHole": "启用黑洞",
+ "failedJobs": "失败的任务",
+ "inquireMode": "查询模式",
+ "inquireModeSettings": "查询模式设置",
+ "isAdmin": "是管理员",
+ "isActive": "已激活",
+ "jobStatus": "任务状态",
+ "llmPromptTemplate": "LLM 提示模板",
+ "loadingInquireStatus": "加载查询状态...",
+ "maxFileSizeDesc": "最大文件大小(MB)",
+ "maxFileSizeMb": "最大文件大小 MB",
+ "monitorBlackHoleDirectory": "监控黑洞目录",
+ "name": "名称",
+ "password": "密码",
+ "pendingJobs": "待处理任务",
+ "processingJobs": "处理中任务",
+ "rebuildVectorStore": "重建向量存储",
+ "recordingDisclaimer": "录音免责声明",
+ "recordingsProcessed": "{{count}} 个录音已处理",
+ "saveDefaultPrompt": "保存默认提示",
+ "saveInquireSettings": "保存查询设置",
+ "saveSetting": "保存设置",
+ "saveUser": "保存用户",
+ "setting": "设置",
+ "settingsManagement": "设置管理",
+ "transcriptLengthLimit": "转录长度限制",
+ "transcriptLengthLimitDesc": "转录的最大字符数限制",
+ "users": "用户",
+ "value": "值",
+ "vectorStoreInfo": "向量存储信息",
+ "vectorStoreStatus": "向量存储状态"
+ },
+ "buttons": {
+ "cancel": "取消",
+ "clearAllFilters": "清除所有筛选",
+ "clearCompleted": "清除已完成的上传",
+ "clearSearchText": "清除搜索文本",
+ "close": "关闭",
+ "copyMessage": "复制消息",
+ "copyNotes": "复制笔记",
+ "copySummary": "复制摘要",
+ "copyToClipboard": "复制到剪贴板",
+ "createTag": "创建标签",
+ "deleteAll": "删除全部",
+ "deleteSpeaker": "删除发言人",
+ "deleteTag": "删除标签",
+ "deleteUser": "删除用户",
+ "downloadAsWord": "下载为Word文档",
+ "downloadChat": "下载聊天记录为Word文档",
+ "downloadNotes": "下载笔记为Word文档",
+ "downloadSummary": "下载摘要为Word文档",
+ "exportCalendar": "导出到日历",
+ "editNotes": "编辑笔记",
+ "editSetting": "编辑设置",
+ "editSummary": "编辑摘要",
+ "editTag": "编辑标签",
+ "editTags": "编辑标签",
+ "editTranscription": "编辑转录",
+ "editUser": "编辑用户",
+ "help": "帮助",
+ "identifySpeakers": "识别发言人",
+ "refresh": "刷新",
+ "reprocessSummary": "重新处理摘要",
+ "reprocessTranscription": "重新处理转录",
+ "reprocessWithAsr": "使用ASR重新处理",
+ "resetStuckProcessing": "重置卡住的处理",
+ "saveChanges": "保存更改",
+ "saveCustomPrompt": "保存自定义提示",
+ "shareRecording": "分享录音",
+ "toggleTheme": "切换主题",
+ "updateTag": "更新标签",
+ "saveSettings": "保存设置"
+ },
+ "changePasswordModal": {
+ "confirmPassword": "确认新密码",
+ "currentPassword": "当前密码",
+ "newPassword": "新密码",
+ "passwordRequirement": "密码必须至少 8 个字符",
+ "title": "更改密码"
+ },
+ "chat": {
+ "availableAfterTranscription": "转录完成后聊天功能将可用",
+ "chatWithTranscription": "与转录内容对话",
+ "clearChat": "清除聊天",
+ "error": "发送消息失败",
+ "noMessages": "暂无消息",
+ "placeholder": "询问有关此录音的问题...",
+ "placeholderWithHint": "询问有关此录音的问题...(按回车发送,Ctrl+回车换行)",
+ "send": "发送",
+ "suggestedQuestions": "建议问题",
+ "thinking": "思考中...",
+ "title": "聊天",
+ "whatAreActionItems": "有哪些行动项?",
+ "whatAreKeyPoints": "关键要点是什么?",
+ "whatWasDiscussed": "讨论了什么?",
+ "whoSaidWhat": "谁说了什么?"
+ },
+ "colorScheme": {
+ "chooseRecording": "从侧边栏选择一个录音以查看其转录和摘要",
+ "selectRecording": "选择录音",
+ "subtitle": "使用美观的颜色主题自定义您的界面",
+ "title": "配色方案"
+ },
+ "common": {
+ "back": "返回",
+ "cancel": "取消",
+ "close": "关闭",
+ "confirm": "确认",
+ "delete": "删除",
+ "deselectAll": "取消全选",
+ "download": "下载",
+ "edit": "编辑",
+ "error": "错误",
+ "failed": "失败",
+ "filter": "筛选",
+ "info": "信息",
+ "loading": "加载中...",
+ "new": "新建",
+ "next": "下一步",
+ "no": "否",
+ "noResults": "未找到结果",
+ "ok": "确定",
+ "or": "或",
+ "previous": "上一步",
+ "processing": "处理中...",
+ "refresh": "刷新",
+ "retry": "重试",
+ "save": "保存",
+ "search": "搜索",
+ "selectAll": "全选",
+ "sort": "排序",
+ "success": "成功",
+ "untitled": "无标题",
+ "upload": "上传",
+ "warning": "警告",
+ "yes": "是"
+ },
+ "customPrompts": {
+ "currentDefaultPrompt": "当前默认提示(如果上面留空则使用此提示)",
+ "promptDescription": "此提示将用于生成转录的摘要。它将覆盖管理员的默认提示。",
+ "promptPlaceholder": "描述您希望摘要的结构。留空以使用管理员的默认提示。",
+ "summaryGeneration": "摘要生成提示",
+ "tip1": "具体说明您希望在摘要中包含的部分",
+ "tip2": "使用清晰的格式说明(例如,\"使用项目符号\",\"创建编号列表\")",
+ "tip3": "指定您是否希望优先处理某些信息",
+ "tip4": "系统将自动向 AI 提供转录内容",
+ "tip5": "您的输出语言偏好(如果设置)将自动应用",
+ "tipsTitle": "编写有效提示的技巧",
+ "yourCustomPrompt": "您的自定义摘要提示"
+ },
+ "deleteAllSpeakersModal": {
+ "confirmMessage": "您确定要删除所有保存的发言人吗?此操作无法撤销。",
+ "title": "删除所有发言人"
+ },
+ "dialogs": {
+ "deleteRecording": {
+ "cancel": "取消",
+ "confirm": "删除",
+ "message": "您确定要删除此录音吗?此操作无法撤销。",
+ "title": "删除录音"
+ },
+ "deleteShare": {
+ "message": "您确定要删除此共享吗?这将撤销对公共链接的访问。",
+ "title": "删除共享"
+ },
+ "reprocessTranscription": {
+ "cancel": "取消",
+ "confirm": "重新处理",
+ "message": "您确定要重新处理此转录吗?当前转录将被替换。",
+ "title": "重新处理转录"
+ },
+ "unsavedChanges": {
+ "cancel": "取消",
+ "discard": "放弃",
+ "message": "您有未保存的更改。在离开之前是否要保存?",
+ "save": "保存",
+ "title": "未保存的更改"
+ }
+ },
+ "duration": {
+ "hours": "{{count}} 小时",
+ "hoursPlural": "{{count}} 小时",
+ "minutes": "{{count}} 分钟",
+ "minutesPlural": "{{count}} 分钟",
+ "seconds": "{{count}} 秒",
+ "secondsPlural": "{{count}} 秒"
+ },
+ "editTagModal": {
+ "asrDefaultSettings": "ASR 默认设置",
+ "asrSettingsDescription": "使用此标签进行 ASR 转录时,这些设置将默认应用",
+ "color": "颜色",
+ "colorDescription": "选择颜色以便识别",
+ "createTitle": "创建标签",
+ "customPrompt": "自定义摘要提示",
+ "customPromptPlaceholder": "可选:为带有此标签的录音生成摘要的自定义提示",
+ "defaultLanguage": "默认语言",
+ "defaultPromptPlaceholder": "Leer lassen, um Ihren Standard-Zusammenfassungs-Prompt zu verwenden",
+ "leaveBlankPrompt": "留空以使用您的默认摘要提示",
+ "maxSpeakers": "最大发言人数",
+ "minSpeakers": "最小发言人数",
+ "tagName": "标签名称 *",
+ "tagNamePlaceholder": "例如,会议、访谈",
+ "title": "编辑标签"
+ },
+ "errors": {
+ "audioRecordingFailed": "音频录制失败。请检查您的麦克风。",
+ "fileTooLarge": "文件太大",
+ "generic": "发生错误",
+ "loadingShares": "加载共享时出错",
+ "networkError": "网络错误。请检查您的连接。",
+ "notFound": "未找到",
+ "permissionDenied": "权限被拒绝",
+ "quotaExceeded": "存储配额已超出",
+ "serverError": "服务器错误。请稍后重试。",
+ "summaryFailed": "摘要生成失败。请重试。",
+ "transcriptionFailed": "转录失败。请重试。",
+ "unauthorized": "未授权",
+ "unsupportedFormat": "不支持的文件格式",
+ "uploadFailed": "上传失败。请重试。",
+ "validationError": "验证错误"
+ },
+ "fileSize": {
+ "bytes": "{{count}} B",
+ "gigabytes": "{{count}} GB",
+ "kilobytes": "{{count}} KB",
+ "megabytes": "{{count}} MB"
+ },
+ "form": {
+ "auto": "自动",
+ "autoDetect": "自动检测",
+ "dateFrom": "从",
+ "dateTo": "至",
+ "enterNotesMarkdown": "以Markdown格式输入笔记...",
+ "enterSummaryMarkdown": "以Markdown格式输入摘要...",
+ "language": "语言",
+ "maxSpeakers": "最多发言人数",
+ "meetingDate": "会议日期",
+ "minSpeakers": "最少发言人数",
+ "minutes": "分钟",
+ "notes": "笔记",
+ "notesPlaceholder": "以Markdown格式输入您的笔记...",
+ "optional": "可选",
+ "participants": "参与者",
+ "placeholderAuto": "自动",
+ "placeholderCharacterLimit": "输入字符限制(例如:30000)",
+ "placeholderMinutes": "分钟",
+ "placeholderOptional": "可选",
+ "placeholderSeconds": "秒",
+ "placeholderSizeMB": "输入大小(MB)",
+ "searchSpeakers": "搜索发言人...",
+ "searchTags": "搜索标签...",
+ "seconds": "秒",
+ "shareNotes": "分享笔记",
+ "shareSummary": "分享摘要",
+ "shareableLink": "可分享链接",
+ "summaryPromptPlaceholder": "输入默认摘要提示...",
+ "title": "标题",
+ "yourFullName": "您的全名"
+ },
+ "help": {
+ "actions": "操作",
+ "activeFilters": "活动筛选器",
+ "advancedAsrOptions": "高级 ASR 选项",
+ "allRecordingsLoaded": "已加载所有录音",
+ "approachingLimit": "接近 {{limit}}MB 限制",
+ "askAboutTranscription": "询问有关此转录的问题",
+ "audioPlayer": "音频播放器",
+ "autoIdentify": "自动识别",
+ "bothAudioDesc": "录制您的声音 + 会议参与者(推荐用于在线会议)",
+ "clearFilters": "清除筛选",
+ "clickToAddNotes": "点击添加笔记...",
+ "colorRepeats": "颜色从发言人 {{number}} 开始重复",
+ "completedFiles": "已完成文件",
+ "confirmReprocessingTitle": "确认重新处理",
+ "copyMessage": "复制消息",
+ "createPublicLink": "创建公共链接以分享此录音。分享仅在安全(HTTPS)连接上可用。",
+ "createTags": "创建标签",
+ "discard": "放弃",
+ "endTime": "结束",
+ "enterNameFor": "输入名称",
+ "entireScreen": "整个屏幕",
+ "estimatedSize": "预计大小",
+ "generatingSummary": "生成摘要中...",
+ "howToRecordSystemAudio": "如何录制系统音频",
+ "importantNote": "重要提示",
+ "lines": "{{count}} 行",
+ "loadingMore": "加载更多录音中...",
+ "loadingRecordings": "加载录音中...",
+ "me": "Me",
+ "microphoneDesc": "仅录制您的声音",
+ "modelReasoning": "模型推理",
+ "moreSpeakersThanColors": "发言人数超过可用颜色数",
+ "noDateSet": "未设置日期",
+ "noParticipants": "无参与者",
+ "noTagsCreated": "尚未创建标签。",
+ "processingTime": "处理时间",
+ "processingTimeDescription": "这可能需要几十分钟完成。处理期间您可以继续使用应用。",
+ "processingTranscription": "处理转录中...",
+ "recordSystemSteps1": "点击\"录制系统音频\"或\"录制两者\"。",
+ "recordSystemSteps2": "在弹出窗口中,选择",
+ "recordSystemSteps3": "确保勾选显示的框",
+ "recordingFinished": "录音完成",
+ "recordingInProgress": "录音进行中...",
+ "regenerateSummaryAfterNames": "更新名称后重新生成摘要",
+ "sentence": "句子",
+ "shareSystemAudio": "共享系统音频",
+ "shareTabAudio": "共享标签页音频",
+ "sharedOn": "共享于",
+ "sharingWindowNoAudio": "共享\"窗口\"不会捕获音频。",
+ "speakerCount": "发言人",
+ "specificBrowserTab": "特定浏览器标签页",
+ "startTime": "开始",
+ "systemAudioDesc": "录制会议参与者和系统声音",
+ "tagManagement": "标签管理",
+ "thisActionCannotBeUndone": "此操作无法撤销。",
+ "toCaptureAudioFromMeetings": "要从会议或其他应用捕获音频,您必须共享屏幕或浏览器标签页。",
+ "troubleshooting": "故障排除",
+ "tryAdjustingSearch": "尝试调整您的搜索或",
+ "unsupportedBrowser": "不支持的浏览器",
+ "untitled": "无标题录音",
+ "uploadRecordingNotes": "上传录音和笔记",
+ "whatWillHappen": "会发生什么?",
+ "whyNotWorking": "为什么不工作?",
+ "youHaveXSpeakers": "您有 {{count}} 位发言人,但只有 8 种独特的颜色可用。颜色将在第 8 位发言人后重复。",
+ "expandAll": "全部展开",
+ "failedReprocess": "重新处理失败",
+ "filterByTag": "按标签筛选",
+ "filterOptions": "筛选选项",
+ "finishDrop": "完成拖放以上传文件",
+ "hideAll": "全部隐藏",
+ "highlighted": "已标记",
+ "inProgress": "进行中",
+ "markdownEditor": "Markdown 编辑器",
+ "meetingDate": "会议日期",
+ "meetingParticipants": "会议参与者",
+ "microphoneOnly": "仅麦克风",
+ "microphoneOnlyDesc": "仅录制您的声音(用于独自工作或面对面会议)",
+ "minutes": "分钟",
+ "newTranscriptions": "新转录",
+ "noRecordingsFound": "未找到录音",
+ "notes": "笔记",
+ "or": "或",
+ "participants": "参与者",
+ "playPause": "播放/暂停",
+ "recordingSettings": "录音设置",
+ "recordingStatus": "录音状态",
+ "reprocessed": "已重新处理",
+ "seconds": "秒",
+ "shareableLink": "可分享链接",
+ "showAll": "显示全部",
+ "showLess": "显示更少",
+ "showMore": "显示更多",
+ "speaker": "发言人",
+ "speakerName": "发言人名称",
+ "speakerNamesUpdated": "发言人名称已更新",
+ "speakers": "发言人",
+ "startRecording": "开始录音",
+ "stopRecording": "停止录音",
+ "summary": "摘要",
+ "systemAudioBoth": "系统音频 + 麦克风",
+ "tags": "标签",
+ "tapToAddNotes": "点击添加笔记...",
+ "title": "标题",
+ "totalDuration": "总时长",
+ "transcription": "转录",
+ "transcriptionSaved": "转录已保存",
+ "updateSpeakerNames": "更新发言人名称",
+ "uploadComplete": "上传完成",
+ "uploadFailed": "上传失败",
+ "uploadFiles": "上传文件",
+ "uploading": "上传中",
+ "viewDetails": "查看详情",
+ "waitingToUpload": "等待上传"
+ },
+ "inquire": {
+ "title": "查询",
+ "filters": "筛选器",
+ "clearAll": "清除全部",
+ "tags": "标签",
+ "speakers": "发言人",
+ "dateRange": "日期范围",
+ "from": "开始日期",
+ "to": "结束日期",
+ "noSpeakerData": "无发言人数据",
+ "speakerRequirement": "发言人识别需要包含多个发言人的录音",
+ "activeFilters": "活动筛选器:",
+ "tagsCount": "个标签",
+ "speakersCount": "个发言人",
+ "dateRangeActive": "日期范围已激活",
+ "filtersActive": "筛选器已激活",
+ "askQuestions": "询问您的转录内容",
+ "selectFilters": "在左侧选择筛选器以缩小转录范围,然后提问以从录音中获得见解。",
+ "exampleQuestions": "示例问题:",
+ "exampleQuestion1": "\"讨论了哪些行动项目?\"",
+ "exampleQuestion2": "\"我们什么时候决定更改时间表?\"",
+ "exampleQuestion3": "\"对预算提出了什么担忧?\"",
+ "exampleQuestion4": "\"谁负责营销任务?\"",
+ "placeholder": "询问关于您筛选的转录内容...",
+ "sendHint": "按Enter发送 • Ctrl+Enter换行"
+ },
+ "languages": {
+ "ar": "阿拉伯语",
+ "de": "德语",
+ "en": "英语",
+ "es": "西班牙语",
+ "fr": "法语",
+ "hi": "印地语",
+ "it": "意大利语",
+ "ja": "日语",
+ "ko": "韩语",
+ "nl": "荷兰语",
+ "pt": "葡萄牙语",
+ "ru": "俄语",
+ "zh": "中文"
+ },
+ "manageSpeakersModal": {
+ "created": "创建时间",
+ "description": "管理您保存的发言人。当您在录音中使用发言人名称时,这些会自动保存。",
+ "failedToLoad": "加载发言人失败",
+ "lastUsed": "最后使用",
+ "loadingSpeakers": "加载发言人中...",
+ "noSpeakersYet": "尚未保存发言人",
+ "speakersSaved": "已保存 {{count}} 位发言人",
+ "speakersWillAppear": "当您在录音中使用发言人名称时,发言人将显示在这里",
+ "times": "次",
+ "title": "管理发言人",
+ "used": "已使用"
+ },
+ "messages": {
+ "copiedToClipboard": "已复制到剪贴板",
+ "downloadStarted": "下载已开始",
+ "notesUpdated": "笔记更新成功",
+ "passwordChanged": "密码更改成功",
+ "profileUpdated": "个人资料更新成功",
+ "recordingDeleted": "录音删除成功",
+ "recordingSaved": "录音保存成功",
+ "settingsSaved": "设置保存成功",
+ "summaryGenerated": "摘要生成成功",
+ "tagAdded": "标签添加成功",
+ "tagRemoved": "标签删除成功",
+ "transcriptionUpdated": "转录更新成功"
+ },
+ "metadata": {
+ "cancelEdit": "取消",
+ "createdAt": "创建时间",
+ "duration": "时长",
+ "editMetadata": "编辑元数据",
+ "fileName": "文件名",
+ "fileSize": "文件大小",
+ "language": "语言",
+ "meetingDate": "会议日期",
+ "processingTime": "处理时间",
+ "saveMetadata": "保存",
+ "status": "状态",
+ "title": "元数据",
+ "updatedAt": "更新时间",
+ "wordCount": "字数"
+ },
+ "modal": {
+ "colorScheme": "配色方案",
+ "deleteRecording": "删除录音",
+ "editAsrTranscription": "编辑 ASR 转录",
+ "editRecording": "编辑录音",
+ "editTags": "编辑录音标签",
+ "editTranscription": "编辑转录",
+ "identifySpeakers": "识别发言人",
+ "recordingNotice": "录音提示",
+ "reprocessSummary": "重新处理摘要",
+ "reprocessTranscription": "重新处理转录",
+ "resetStatus": "重置录音状态?",
+ "shareRecording": "分享录音",
+ "sharedTranscripts": "我的共享转录",
+ "systemAudioHelp": "系统音频帮助"
+ },
+ "nav": {
+ "account": "账户",
+ "accountSettings": "账户设置",
+ "admin": "管理",
+ "adminDashboard": "管理面板",
+ "darkMode": "深色模式",
+ "home": "主页",
+ "language": "语言",
+ "lightMode": "浅色模式",
+ "newRecording": "新录音",
+ "recording": "录音",
+ "settings": "设置",
+ "signOut": "退出登录",
+ "upload": "上传",
+ "userProfile": "用户资料"
+ },
+ "notes": {
+ "cancelEdit": "取消编辑",
+ "characterCount": "{{count}} 个字符",
+ "characterCountPlural": "{{count}} 个字符",
+ "editNotes": "编辑笔记",
+ "lastUpdated": "最后更新",
+ "placeholder": "在此添加您的笔记...",
+ "saveNotes": "保存笔记",
+ "title": "笔记"
+ },
+ "recording": {
+ "acceptDisclaimer": "我接受",
+ "cancelRecording": "取消",
+ "disclaimer": "录音免责声明",
+ "microphone": "麦克风",
+ "microphoneAndSystem": "麦克风 + 系统",
+ "microphonePermissionDenied": "麦克风权限被拒绝",
+ "notes": "笔记",
+ "notesPlaceholder": "添加关于此录音的笔记...",
+ "pauseRecording": "暂停",
+ "recordingFailed": "录音失败",
+ "recordingInProgress": "录音进行中...",
+ "recordingSize": "预计大小",
+ "recordingStopped": "录音已停止",
+ "recordingTime": "录音时长",
+ "resumeRecording": "恢复",
+ "saveRecording": "保存录音",
+ "startRecording": "开始录音",
+ "stopRecording": "停止录音",
+ "systemAudio": "系统音频",
+ "systemAudioNotSupported": "此浏览器不支持系统音频录制",
+ "title": "音频录制"
+ },
+ "reprocessModal": {
+ "audioReTranscribedFromScratch": "音频将从头重新转录。这也将基于新转录重新生成标题和摘要。",
+ "audioReTranscribedWithAsr": "音频将使用 ASR 端点重新转录。这包括说话人分离,并将重新生成标题和摘要。",
+ "manualEditsOverwritten": "对转录、标题或摘要的任何手动编辑都将被覆盖。",
+ "manualEditsOverwrittenSummary": "对标题或摘要的任何手动编辑都将被覆盖。",
+ "newTitleAndSummary": "将基于现有转录生成新的标题和摘要。"
+ },
+ "settings": {
+ "apiKeys": "API密钥",
+ "appearance": "外观",
+ "changePassword": "更改密码",
+ "dataExport": "数据导出",
+ "deleteAccount": "删除账户",
+ "integrations": "集成",
+ "language": "语言",
+ "notifications": "通知",
+ "preferences": "偏好设置",
+ "privacy": "隐私",
+ "profile": "个人资料",
+ "security": "安全",
+ "theme": "主题",
+ "title": "设置",
+ "twoFactorAuth": "双因素认证"
+ },
+ "sharedTranscripts": {
+ "noSharedTranscripts": "您尚未分享任何转录。"
+ },
+ "sharedTranscriptsPage": {
+ "noSharedTranscripts": "您尚未分享任何转录。"
+ },
+ "sidebar": {
+ "advancedSearch": "高级搜索",
+ "dateRange": "日期范围",
+ "filters": "筛选器",
+ "highlighted": "已标记",
+ "inbox": "收件箱",
+ "lastMonth": "上月",
+ "lastWeek": "上周",
+ "loadMore": "加载更多",
+ "markAsRead": "标记为已读",
+ "moveToInbox": "移至收件箱",
+ "noRecordings": "没有录音",
+ "older": "更早",
+ "removeFromHighlighted": "取消标记",
+ "searchRecordings": "搜索录音",
+ "sortBy": "排序方式",
+ "sortByDate": "按日期排序",
+ "sortByMeetingDate": "会议日期",
+ "tags": "标签",
+ "thisMonth": "本月",
+ "thisWeek": "本周",
+ "today": "今天",
+ "totalRecordings": "{{count}} 个录音",
+ "totalRecordingsPlural": "{{count}} 个录音",
+ "yesterday": "昨天",
+ "allRecordings": "所有录音",
+ "clearSearch": "清除搜索",
+ "filterByDate": "按日期筛选",
+ "filterByStatus": "按状态筛选",
+ "filterByTags": "按标签筛选",
+ "inquireMode": "查询模式",
+ "newRecording": "新录音",
+ "recordings": "录音",
+ "sortByDuration": "按时长排序",
+ "sortByName": "按名称排序",
+ "uploadFiles": "上传文件"
+ },
+ "status": {
+ "completed": "已完成",
+ "failed": "失败",
+ "processing": "处理中",
+ "queued": "排队中",
+ "stuck": "重置卡住的处理",
+ "summarizing": "生成摘要中",
+ "transcribing": "转录中",
+ "uploading": "上传中"
+ },
+ "summary": {
+ "actionItems": "行动事项",
+ "cancelEdit": "取消编辑",
+ "decisions": "决定",
+ "editSummary": "编辑摘要",
+ "generateSummary": "生成摘要",
+ "keyPoints": "关键要点",
+ "noSummary": "无可用摘要",
+ "participants": "参与者",
+ "regenerateSummary": "重新生成摘要",
+ "saveSummary": "保存摘要",
+ "summaryFailed": "摘要生成失败",
+ "summaryInProgress": "摘要生成中...",
+ "title": "摘要"
+ },
+ "tagManagement": {
+ "asrDefaults": "ASR 默认值",
+ "createTag": "创建标签",
+ "customPrompt": "自定义提示",
+ "description": "使用自定义标签组织您的录音。每个标签可以有自己的摘要提示和默认 ASR 设置。",
+ "maxSpeakers": "最大",
+ "minSpeakers": "最小",
+ "noTags": "您尚未创建任何标签。"
+ },
+ "tags": {
+ "addTag": "添加标签",
+ "clearTagFilter": "清除筛选",
+ "createTag": "创建标签",
+ "filterByTag": "按标签筛选",
+ "manageAllTags": "管理所有标签",
+ "noTags": "无标签",
+ "removeTag": "删除标签",
+ "tagColor": "标签颜色",
+ "tagName": "标签名称",
+ "title": "标签"
+ },
+ "tagsModal": {
+ "addTags": "添加标签",
+ "currentTags": "当前标签",
+ "done": "完成",
+ "noTagsAssigned": "此录音未分配标签",
+ "searchTags": "搜索标签..."
+ },
+ "time": {
+ "dayAgo": "1 天前",
+ "daysAgo": "{{count}} 天前",
+ "hourAgo": "1 小时前",
+ "hoursAgo": "{{count}} 小时前",
+ "justNow": "刚刚",
+ "minuteAgo": "1 分钟前",
+ "minutesAgo": "{{count}} 分钟前",
+ "monthAgo": "1 个月前",
+ "monthsAgo": "{{count}} 个月前",
+ "weekAgo": "1 周前",
+ "weeksAgo": "{{count}} 周前",
+ "yearAgo": "1 年前",
+ "yearsAgo": "{{count}} 年前"
+ },
+ "transcription": {
+ "autoIdentifySpeakers": "自动识别发言人",
+ "simple": "简单",
+ "bubble": "气泡",
+ "copy": "复制",
+ "download": "下载",
+ "edit": "编辑",
+ "cancelEdit": "取消编辑",
+ "copyToClipboard": "复制到剪贴板",
+ "downloadTranscript": "下载转录文本",
+ "editSpeakers": "编辑发言人",
+ "editTranscription": "编辑转录",
+ "highlightSearchResults": "高亮搜索结果",
+ "noTranscription": "无可用转录",
+ "regenerateTranscription": "重新生成转录",
+ "saveTranscription": "保存转录",
+ "searchInTranscript": "在转录中搜索...",
+ "speaker": "发言人 {{number}}",
+ "speakerLabels": "发言人标签",
+ "title": "转录",
+ "unknownSpeaker": "未知发言人"
+ },
+ "upload": {
+ "chunking": "大文件将自动分块处理",
+ "completed": "已完成",
+ "dropzone": "拖拽音频文件到此处,或点击浏览",
+ "failed": "失败",
+ "maxFileSize": "最大文件大小",
+ "queued": "排队中",
+ "selectFiles": "选择文件",
+ "summarizing": "生成摘要中...",
+ "supportedFormats": "支持的格式",
+ "title": "上传音频",
+ "transcribing": "转录中...",
+ "untitled": "无标题录音",
+ "uploadProgress": "上传进度",
+ "willAutoSummarize": "转录后将自动生成摘要"
+ },
+ "uploadProgress": {
+ "title": "上传进度"
+ },
+ "recordingView": {
+ "audioPlayer": "音频播放器",
+ "bubble": "气泡",
+ "chat": "聊天",
+ "copy": "复制",
+ "download": "下载",
+ "edit": "编辑",
+ "minutes": "分钟",
+ "notes": "笔记",
+ "participants": "参与者",
+ "save": "保存",
+ "share": "分享",
+ "simple": "简单",
+ "speakers": "发言人",
+ "summary": "摘要",
+ "transcription": "转录"
+ },
+ "eventExtraction": {
+ "title": "事件提取",
+ "enableLabel": "启用从转录中自动提取事件",
+ "description": "启用后,AI将识别录音中提到的会议、约会和截止日期,并创建可下载的日历事件。",
+ "info": "提取的事件将显示在检测到日历项目的录音的\"事件\"选项卡中。"
+ },
+ "events": {
+ "title": "事件",
+ "addToCalendar": "添加到日历",
+ "noEvents": "此录音中未检测到事件",
+ "start": "开始",
+ "end": "结束",
+ "location": "地点",
+ "attendees": "参与者"
+ },
+ "speakersManagement": {
+ "description": "管理您保存的发言人。当您在录音中使用发言人名称时,这些会自动保存。",
+ "loadingSpeakers": "加载发言人中...",
+ "failedToLoad": "加载发言人失败",
+ "noSpeakersYet": "尚未保存发言人",
+ "speakersWillAppear": "当您在录音中使用发言人名称时,发言人将显示在这里",
+ "totalSpeakers": "已保存发言人",
+ "usedTimes": "已使用",
+ "lastUsed": "最后使用",
+ "created": "创建时间"
+ }
+}
\ No newline at end of file
diff --git a/speakr/static/manifest.json b/speakr/static/manifest.json
new file mode 100644
index 0000000..909ea19
--- /dev/null
+++ b/speakr/static/manifest.json
@@ -0,0 +1,84 @@
+{
+ "id": "com.speakr.app",
+ "name": "Speakr",
+ "short_name": "Speakr",
+ "description": "Speakr - Audio transcription and summarization",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "display_override": ["window-controls-overlay", "standalone"],
+ "background_color": "#000000",
+ "theme_color": "#000000",
+ "orientation": "any",
+ "prefer_related_applications": false,
+ "categories": ["productivity", "utilities", "business"],
+ "lang": "en",
+ "dir": "ltr",
+ "icons": [
+ {
+ "src": "/tool/speakr/static/img/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/tool/speakr/static/img/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/tool/speakr/static/img/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/tool/speakr/static/img/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/tool/speakr/static/img/icon-180x180.png",
+ "sizes": "180x180",
+ "type": "image/png",
+ "purpose": "any"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "New Recording",
+ "short_name": "New",
+ "description": "Upload or record new audio",
+ "url": "/#upload",
+ "icons": [{ "src": "/tool/speakr/static/img/icon-192x192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "View Gallery",
+ "short_name": "Gallery",
+ "description": "Access your recordings gallery",
+ "url": "/#gallery",
+ "icons": [{ "src": "/tool/speakr/static/img/icon-192x192.png", "sizes": "192x192" }]
+ }
+ ],
+ "share_target": {
+ "action": "/#upload",
+ "method": "POST",
+ "enctype": "multipart/form-data",
+ "params": {
+ "title": "title",
+ "text": "text",
+ "url": "url",
+ "files": [
+ {
+ "name": "shared_audio",
+ "accept": ["audio/*"]
+ }
+ ]
+ }
+ },
+ "edge_side_panel": {
+ "preferred_width": 480
+ }
+}
diff --git a/speakr/static/offline.html b/speakr/static/offline.html
new file mode 100644
index 0000000..e326444
--- /dev/null
+++ b/speakr/static/offline.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+ Offline - Speakr
+
+
+
+
+
+
+
You're Offline
+
It looks like you're not connected to the internet. Please check your connection and try again.
+
Some content may be unavailable until you're back online.
+
+
+
diff --git a/speakr/static/sw.js b/speakr/static/sw.js
new file mode 100644
index 0000000..72395fd
--- /dev/null
+++ b/speakr/static/sw.js
@@ -0,0 +1,261 @@
+const CACHE_NAME = 'Speakr-cache-v5';
+const ASSETS_TO_CACHE = [
+ '/tool/speakr/',
+ '/tool/speakr/static/offline.html',
+ '/tool/speakr/static/manifest.json',
+ '/tool/speakr/static/css/styles.css',
+ '/tool/speakr/static/js/app.js',
+ '/tool/speakr/static/vite/index/assets/index.js',
+ '/tool/speakr/static/img/icon-192x192.png', // Assuming you will add this
+ '/tool/speakr/static/img/icon-512x512.png', // Assuming you will add this
+ '/tool/speakr/static/img/favicon.ico', // Keep existing SVG as a fallback or for other uses
+ // HTML templates (these are typically served via routes, but caching the routes themselves is handled by fetch strategies)
+ // We cache '/' which should serve the main page.
+ // Other specific page routes like /login, /register, /account will be handled by networkFirst.
+ // CDN assets - caching these can be beneficial but also complex if they change often.
+ 'https://cdn.tailwindcss.com',
+ 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'
+];
+
+// Function to update shortcuts (structure from your example)
+// The actual `lists` data would need to be sent from your client-side app.js
+const updateShortcuts = async (lists) => {
+ if (!self.registration || !('shortcuts' in self.registration)) {
+ console.log('Shortcuts API not supported or registration not available.');
+ return;
+ }
+
+ try {
+ let shortcuts = [
+ {
+ name: "New Recording",
+ short_name: "New",
+ description: "Upload or record new audio",
+ url: "/#upload", // Or your direct upload page route
+ icons: [{ src: "/tool/speakr/static/img/icon-192x192.png", sizes: "192x192" }]
+ },
+ {
+ name: "View Gallery",
+ short_name: "Gallery",
+ description: "Access your recordings gallery",
+ url: "/#gallery", // Or your direct gallery page route
+ icons: [{ src: "/tool/speakr/static/img/icon-192x192.png", sizes: "192x192" }]
+ }
+ ];
+
+ // Example: If you had dynamic lists to add as shortcuts
+ if (Array.isArray(lists) && lists.length > 0) {
+ const dynamicShortcuts = lists.slice(0, 2).map(list => { // Max 2 dynamic, total 4
+ if (list && list.id && list.title) {
+ return {
+ name: list.title,
+ short_name: list.title.length > 10 ? list.title.substring(0, 9) + '…' : list.title,
+ description: `View ${list.title}`,
+ url: `/list/${list.id}`, // Example dynamic URL
+ icons: [{ src: "/tool/speakr/static/img/icon-192x192.png", sizes: "192x192" }]
+ };
+ }
+ return null;
+ }).filter(Boolean);
+ shortcuts = [...shortcuts, ...dynamicShortcuts];
+ }
+
+ await self.registration.shortcuts.set(shortcuts);
+ console.log('PWA shortcuts updated successfully:', shortcuts);
+ } catch (error) {
+ console.error('Error updating PWA shortcuts:', error);
+ }
+};
+
+
+// Cache first strategy: Respond from cache if available, otherwise fetch from network and cache.
+const cacheFirst = async (request) => {
+ const responseFromCache = await caches.match(request);
+ if (responseFromCache) {
+ return responseFromCache;
+ }
+ try {
+ const responseFromNetwork = await fetch(request);
+ // Check if the response is valid before caching
+ if (responseFromNetwork && responseFromNetwork.ok) {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, responseFromNetwork.clone());
+ }
+ return responseFromNetwork;
+ } catch (error) {
+ console.error('CacheFirst: Network request failed for:', request.url, error);
+ // For assets, returning a generic error or specific offline asset might be better than network error.
+ // However, if it's a critical asset not found, this indicates an issue.
+ return new Response('Network error trying to fetch asset.', {
+ status: 408,
+ headers: { 'Content-Type': 'text/plain' },
+ });
+ }
+};
+
+// Stale-while-revalidate strategy: Respond from cache immediately if available,
+// then update the cache with a fresh response from the network.
+const staleWhileRevalidate = async (request) => {
+ const cache = await caches.open(CACHE_NAME);
+ const cachedResponsePromise = cache.match(request);
+ const networkResponsePromise = fetch(request).then(networkResponse => {
+ if (networkResponse && networkResponse.ok) {
+ cache.put(request, networkResponse.clone());
+ }
+ return networkResponse;
+ }).catch(error => {
+ console.error('StaleWhileRevalidate: Network request failed for:', request.url, error);
+ // If network fails, we still might have a cached response.
+ // If not, this error will propagate.
+ return new Response('API request failed and no cache available.', {
+ status: 503, // Service Unavailable
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ error: 'Service temporarily unavailable. Please try again later.' })
+ });
+ });
+
+ return (await cachedResponsePromise) || networkResponsePromise;
+};
+
+// Network first strategy: Try to fetch from network first.
+// If network fails, fall back to cache. If cache also fails, serve offline page for navigation.
+const networkFirst = async (request) => {
+ try {
+ const networkResponse = await fetch(request);
+ if (networkResponse && networkResponse.ok) {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, networkResponse.clone());
+ }
+ return networkResponse;
+ } catch (error) {
+ console.warn('NetworkFirst: Network request failed for:', request.url, error);
+ const cachedResponse = await caches.match(request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ // For navigation requests, fall back to the offline page if both network and cache fail.
+ if (request.mode === 'navigate') {
+ const offlinePage = await caches.match('/tool/speakr/static/offline.html');
+ if (offlinePage) return offlinePage;
+ }
+ // For other types of requests, or if offline page isn't cached, re-throw or return error.
+ return new Response('Network error and no cache available.', {
+ status: 408,
+ headers: { 'Content-Type': 'text/plain' },
+ });
+ }
+};
+
+self.addEventListener('install', (event) => {
+ self.skipWaiting(); // Activate new service worker immediately
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => {
+ console.log('Service Worker: Caching app shell');
+ return cache.addAll(ASSETS_TO_CACHE.map(url => new Request(url, { cache: 'reload' }))) // Force reload from network for app shell
+ .catch(error => {
+ console.error('Failed to cache app shell during install:', error);
+ // You might want to log which specific asset failed
+ ASSETS_TO_CACHE.forEach(url => {
+ cache.add(new Request(url, { cache: 'reload' })).catch(err => console.warn(`Failed to cache: ${url}`, err));
+ });
+ });
+ })
+ );
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((cacheNames) => {
+ return Promise.all(
+ cacheNames
+ .filter((name) => name !== CACHE_NAME)
+ .map((name) => {
+ console.log('Service Worker: Deleting old cache', name);
+ return caches.delete(name);
+ })
+ );
+ }).then(() => {
+ console.log('Service Worker: Activated and old caches cleared.');
+ return self.clients.claim(); // Take control of all open clients
+ })
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const request = event.request;
+ const url = new URL(request.url);
+
+ // Skip non-GET requests from caching strategies (they should pass through)
+ if (request.method !== 'GET') {
+ // event.respondWith(fetch(request)); // Let non-GET requests pass through to the network
+ return; // Or simply return to let the browser handle it
+ }
+
+ // Serve API calls from /api/ with stale-while-revalidate
+ // (excluding auth-related endpoints)
+ if (url.pathname.startsWith('/tool/speakr/api/')) {
+ if (url.pathname.includes('/login') || url.pathname.includes('/logout') || url.pathname.includes('/auth')) {
+ // For auth, always go to network, don't cache
+ event.respondWith(fetch(request));
+ return;
+ }
+ event.respondWith(staleWhileRevalidate(request));
+ return;
+ }
+
+ // Serve /audio/ requests with cache-first, then network.
+ // These are media files and can be large, so cache-first is good.
+ if (url.pathname.startsWith('/tool/speakr/audio/')) {
+ event.respondWith(cacheFirst(request));
+ return;
+ }
+
+ // Handle navigation requests (HTML pages) with network-first, then cache, then offline page.
+ if (request.mode === 'navigate') {
+ event.respondWith(networkFirst(request));
+ return;
+ }
+
+ // For static assets listed in ASSETS_TO_CACHE, use cache-first.
+ // This ensures that if an asset path is directly requested, it's served from cache if possible.
+ // We need to match against the origin + pathname for ASSETS_TO_CACHE.
+ const requestPath = url.origin === self.origin ? url.pathname : request.url;
+ if (ASSETS_TO_CACHE.includes(requestPath)) {
+ event.respondWith(cacheFirst(request));
+ return;
+ }
+
+ // Default strategy for other GET requests: try cache, then network.
+ // This is a good general fallback for other static assets not explicitly listed
+ // or for assets from other origins if not handled by ASSETS_TO_CACHE.
+ event.respondWith(
+ caches.match(request).then((cachedResponse) => {
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ return fetch(request).then(networkResponse => {
+ // Optionally cache other successful GET responses here if desired
+ // if (networkResponse && networkResponse.ok) {
+ // const cache = await caches.open(CACHE_NAME);
+ // cache.put(request, networkResponse.clone());
+ // }
+ return networkResponse;
+ }).catch(() => {
+ // If network fails for a non-navigation, non-API, non-explicitly-cached asset
+ // there isn't much we can do other than return an error or nothing.
+ // For simplicity, let the browser handle the error.
+ });
+ })
+ );
+});
+
+// Listen for messages from the client (e.g., to update shortcuts)
+self.addEventListener('message', (event) => {
+ if (event.data && event.data.type === 'UPDATE_SHORTCUTS') {
+ console.log('Service Worker: Received UPDATE_SHORTCUTS message:', event.data.lists);
+ // updateShortcuts(event.data.lists); // Call if you implement dynamic shortcuts based on client data
+ }
+ if (event.data && event.data.type === 'SKIP_WAITING') {
+ self.skipWaiting();
+ }
+});
diff --git a/speakr/static/vendor/css/easymde.min.css b/speakr/static/vendor/css/easymde.min.css
new file mode 100644
index 0000000..66fd6a9
--- /dev/null
+++ b/speakr/static/vendor/css/easymde.min.css
@@ -0,0 +1,7 @@
+/**
+ * easymde v2.20.0
+ * Copyright Jeroen Akkerman
+ * @link https://github.com/ionaru/easy-markdown-editor
+ * @license MIT
+ */
+.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors{visibility:visible}.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:rgba(255,255,0,.4)}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:''}span.CodeMirror-selectedtext{background:0 0}.EasyMDEContainer{display:block}.CodeMirror-rtl pre{direction:rtl}.EasyMDEContainer.sided--no-fullscreen{display:flex;flex-direction:row;flex-wrap:wrap}.EasyMDEContainer .CodeMirror{box-sizing:border-box;height:auto;border:1px solid #ced4da;border-bottom-left-radius:4px;border-bottom-right-radius:4px;padding:10px;font:inherit;z-index:0;word-wrap:break-word}.EasyMDEContainer .CodeMirror-scroll{cursor:text}.EasyMDEContainer .CodeMirror-fullscreen{background:#fff;position:fixed!important;top:50px;left:0;right:0;bottom:0;height:auto;z-index:8;border-right:none!important;border-bottom-right-radius:0!important}.EasyMDEContainer .CodeMirror-sided{width:50%!important}.EasyMDEContainer.sided--no-fullscreen .CodeMirror-sided{border-right:none!important;border-bottom-right-radius:0;position:relative;flex:1 1 auto}.EasyMDEContainer .CodeMirror-placeholder{opacity:.5}.EasyMDEContainer .CodeMirror-focused .CodeMirror-selected{background:#d9d9d9}.editor-toolbar{position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;padding:9px 10px;border-top:1px solid #ced4da;border-left:1px solid #ced4da;border-right:1px solid #ced4da;border-top-left-radius:4px;border-top-right-radius:4px}.editor-toolbar.fullscreen{width:100%;height:50px;padding-top:10px;padding-bottom:10px;box-sizing:border-box;background:#fff;border:0;position:fixed;top:0;left:0;opacity:1;z-index:9}.editor-toolbar.fullscreen::before{width:20px;height:50px;background:-moz-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-webkit-gradient(linear,left top,right top,color-stop(0,#fff),color-stop(100%,rgba(255,255,255,0)));background:-webkit-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-o-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-ms-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:linear-gradient(to right,#fff 0,rgba(255,255,255,0) 100%);position:fixed;top:0;left:0;margin:0;padding:0}.editor-toolbar.fullscreen::after{width:20px;height:50px;background:-moz-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-webkit-gradient(linear,left top,right top,color-stop(0,rgba(255,255,255,0)),color-stop(100%,#fff));background:-webkit-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-o-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-ms-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:linear-gradient(to right,rgba(255,255,255,0) 0,#fff 100%);position:fixed;top:0;right:0;margin:0;padding:0}.EasyMDEContainer.sided--no-fullscreen .editor-toolbar{width:100%}.editor-toolbar .easymde-dropdown,.editor-toolbar button{background:0 0;display:inline-block;text-align:center;text-decoration:none!important;height:30px;margin:0;padding:0;border:1px solid transparent;border-radius:3px;cursor:pointer}.editor-toolbar button{font-weight:700;min-width:30px;padding:0 6px;white-space:nowrap}.editor-toolbar button.active,.editor-toolbar button:hover{background:#fcfcfc;border-color:#95a5a6}.editor-toolbar i.separator{display:inline-block;width:0;border-left:1px solid #d9d9d9;border-right:1px solid #fff;color:transparent;text-indent:-10px;margin:0 6px}.editor-toolbar button:after{font-family:Arial,"Helvetica Neue",Helvetica,sans-serif;font-size:65%;vertical-align:text-bottom;position:relative;top:2px}.editor-toolbar button.heading-1:after{content:"1"}.editor-toolbar button.heading-2:after{content:"2"}.editor-toolbar button.heading-3:after{content:"3"}.editor-toolbar button.heading-bigger:after{content:"▲"}.editor-toolbar button.heading-smaller:after{content:"▼"}.editor-toolbar.disabled-for-preview button:not(.no-disable){opacity:.6;pointer-events:none}@media only screen and (max-width:700px){.editor-toolbar i.no-mobile{display:none}}.editor-statusbar{padding:8px 10px;font-size:12px;color:#959694;text-align:right}.EasyMDEContainer.sided--no-fullscreen .editor-statusbar{width:100%}.editor-statusbar span{display:inline-block;min-width:4em;margin-left:1em}.editor-statusbar .lines:before{content:'lines: '}.editor-statusbar .words:before{content:'words: '}.editor-statusbar .characters:before{content:'characters: '}.editor-preview-full{position:absolute;width:100%;height:100%;top:0;left:0;z-index:7;overflow:auto;display:none;box-sizing:border-box}.editor-preview-side{position:fixed;bottom:0;width:50%;top:50px;right:0;z-index:9;overflow:auto;display:none;box-sizing:border-box;border:1px solid #ddd;word-wrap:break-word}.editor-preview-active-side{display:block}.EasyMDEContainer.sided--no-fullscreen .editor-preview-active-side{flex:1 1 auto;height:auto;position:static}.editor-preview-active{display:block}.editor-preview{padding:10px;background:#fafafa}.editor-preview>p{margin-top:0}.editor-preview pre{background:#eee;margin-bottom:10px}.editor-preview table td,.editor-preview table th{border:1px solid #ddd;padding:5px}.cm-s-easymde .cm-tag{color:#63a35c}.cm-s-easymde .cm-attribute{color:#795da3}.cm-s-easymde .cm-string{color:#183691}.cm-s-easymde .cm-header-1{font-size:calc(1.375rem + 1.5vw)}.cm-s-easymde .cm-header-2{font-size:calc(1.325rem + .9vw)}.cm-s-easymde .cm-header-3{font-size:calc(1.3rem + .6vw)}.cm-s-easymde .cm-header-4{font-size:calc(1.275rem + .3vw)}.cm-s-easymde .cm-header-5{font-size:1.25rem}.cm-s-easymde .cm-header-6{font-size:1rem}.cm-s-easymde .cm-header-1,.cm-s-easymde .cm-header-2,.cm-s-easymde .cm-header-3,.cm-s-easymde .cm-header-4,.cm-s-easymde .cm-header-5,.cm-s-easymde .cm-header-6{margin-bottom:.5rem;line-height:1.2}.cm-s-easymde .cm-comment{background:rgba(0,0,0,.05);border-radius:2px}.cm-s-easymde .cm-link{color:#7f8c8d}.cm-s-easymde .cm-url{color:#aab2b3}.cm-s-easymde .cm-quote{color:#7f8c8d;font-style:italic}.editor-toolbar .easymde-dropdown{position:relative;background:linear-gradient(to bottom right,#fff 0,#fff 84%,#333 50%,#333 100%);border-radius:0;border:1px solid #fff}.editor-toolbar .easymde-dropdown:hover{background:linear-gradient(to bottom right,#fff 0,#fff 84%,#333 50%,#333 100%)}.easymde-dropdown-content{display:block;visibility:hidden;position:absolute;background-color:#f9f9f9;box-shadow:0 8px 16px 0 rgba(0,0,0,.2);padding:8px;z-index:2;top:30px}.easymde-dropdown:active .easymde-dropdown-content,.easymde-dropdown:focus .easymde-dropdown-content,.easymde-dropdown:focus-within .easymde-dropdown-content{visibility:visible}.easymde-dropdown-content button{display:block}span[data-img-src]::after{content:'';background-image:var(--bg-image);display:block;max-height:100%;max-width:100%;background-size:contain;height:0;padding-top:var(--height);width:var(--width);background-repeat:no-repeat}.CodeMirror .cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word){background:rgba(255,0,0,.15)}
\ No newline at end of file
diff --git a/speakr/static/vendor/css/fontawesome.min.css b/speakr/static/vendor/css/fontawesome.min.css
new file mode 100644
index 0000000..59e0900
--- /dev/null
+++ b/speakr/static/vendor/css/fontawesome.min.css
@@ -0,0 +1,6 @@
+/*!
+ * Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
+ * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
+ * Copyright 2022 Fonticons, Inc.
+ */
+.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-duotone,.fa-light,.fa-regular,.fa-solid,.fa-thin,.fab,.fad,.fal,.far,.fas,.fat{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;transition-delay:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)}.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-a:before{content:"\41"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-anchor:before{content:"\f13d"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-ankh:before{content:"\f644"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-archway:before{content:"\f557"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-arrow-trend-down:before{content:"\e097"}.fa-arrow-trend-up:before{content:"\e098"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-asterisk:before{content:"\2a"}.fa-at:before{content:"\40"}.fa-atom:before{content:"\f5d2"}.fa-audio-description:before{content:"\f29e"}.fa-austral-sign:before{content:"\e0a9"}.fa-award:before{content:"\f559"}.fa-b:before{content:"\42"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-backward:before{content:"\f04a"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-bacon:before{content:"\f7e5"}.fa-bacteria:before{content:"\e059"}.fa-bacterium:before{content:"\e05a"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-bahai:before{content:"\f666"}.fa-baht-sign:before{content:"\e0ac"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-barcode:before{content:"\f02a"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-bed:before{content:"\f236"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-bell:before{content:"\f0f3"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bicycle:before{content:"\f206"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blog:before{content:"\f781"}.fa-bold:before{content:"\f032"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-bookmark:before{content:"\f02e"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-box-open:before{content:"\f49e"}.fa-box-tissue:before{content:"\e05b"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broom:before{content:"\f51a"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-brush:before{content:"\f55d"}.fa-bug:before{content:"\f188"}.fa-bug-slash:before{content:"\e490"}.fa-building:before{content:"\f1ad"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-c:before{content:"\43"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-week:before{content:"\f784"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-camera-rotate:before{content:"\e0d8"}.fa-campground:before{content:"\f6bb"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-car-side:before{content:"\f5e4"}.fa-caravan:before{content:"\f8ff"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-cart-plus:before{content:"\f217"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cedi-sign:before{content:"\e0df"}.fa-cent-sign:before{content:"\e3f5"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-charging-station:before{content:"\f5e7"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-chart-column:before{content:"\e0e3"}.fa-chart-gantt:before{content:"\e0e4"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-double:before{content:"\f560"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-circle-notch:before{content:"\f1ce"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-city:before{content:"\f64f"}.fa-clapperboard:before{content:"\e131"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-clover:before{content:"\e139"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-code-commit:before{content:"\f386"}.fa-code-compare:before{content:"\e13a"}.fa-code-fork:before{content:"\e13b"}.fa-code-merge:before{content:"\f387"}.fa-code-pull-request:before{content:"\e13c"}.fa-coins:before{content:"\f51e"}.fa-colon-sign:before{content:"\e140"}.fa-comment:before{content:"\f075"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-compress:before{content:"\f066"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-couch:before{content:"\f4b8"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-d:before{content:"\44"}.fa-database:before{content:"\f1c0"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-democrat:before{content:"\f747"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-dharmachakra:before{content:"\f655"}.fa-diagram-next:before{content:"\e476"}.fa-diagram-predecessor:before{content:"\e477"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-diagram-successor:before{content:"\e47a"}.fa-diamond:before{content:"\f219"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-disease:before{content:"\f7fa"}.fa-divide:before{content:"\f529"}.fa-dna:before{content:"\f471"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-dong-sign:before{content:"\e169"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dove:before{content:"\f4ba"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-download:before{content:"\f019"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-e:before{content:"\45"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elevator:before{content:"\e16d"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-equals:before{content:"\3d"}.fa-eraser:before{content:"\f12d"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-exclamation:before{content:"\21"}.fa-expand:before{content:"\f065"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-eye-slash:before{content:"\f070"}.fa-f:before{content:"\46"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-fan:before{content:"\f863"}.fa-faucet:before{content:"\e005"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-file:before{content:"\f15b"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-excel:before{content:"\f1c3"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-file-medical:before{content:"\f477"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-video:before{content:"\f1c8"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-file-word:before{content:"\f1c2"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-extinguisher:before{content:"\f134"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-fish:before{content:"\f578"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-florin-sign:before{content:"\e184"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-folder-tree:before{content:"\f802"}.fa-font:before{content:"\f031"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-franc-sign:before{content:"\e18f"}.fa-frog:before{content:"\f52e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-g:before{content:"\47"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-glasses:before{content:"\f530"}.fa-globe:before{content:"\f0ac"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-greater-than:before{content:"\3e"}.fa-greater-than-equal:before{content:"\f532"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-guarani-sign:before{content:"\e19a"}.fa-guitar:before{content:"\f7a6"}.fa-gun:before{content:"\e19b"}.fa-h:before{content:"\48"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-sparkles:before{content:"\e05d"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-hands-clapping:before{content:"\e1a8"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-handshake:before{content:"\f2b5"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-handshake-slash:before{content:"\e060"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-hashtag:before{content:"\23"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-wizard:before{content:"\f6e8"}.fa-head-side-cough:before{content:"\e061"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-head-side-mask:before{content:"\e063"}.fa-head-side-virus:before{content:"\e064"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-highlighter:before{content:"\f591"}.fa-hippo:before{content:"\f6ed"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-hospital-user:before{content:"\f80d"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hourglass-2:before,.fa-hourglass-half:before,.fa-hourglass:before{content:"\f254"}.fa-hourglass-empty:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-house-chimney-user:before{content:"\e065"}.fa-house-chimney-window:before{content:"\e00d"}.fa-house-crack:before{content:"\e3b1"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-house-medical:before{content:"\e3b2"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-i:before{content:"\49"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-images:before{content:"\f302"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-italic:before{content:"\f033"}.fa-j:before{content:"\4a"}.fa-jedi:before{content:"\f669"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-joint:before{content:"\f595"}.fa-k:before{content:"\4b"}.fa-kaaba:before{content:"\f66b"}.fa-key:before{content:"\f084"}.fa-keyboard:before{content:"\f11c"}.fa-khanda:before{content:"\f66d"}.fa-kip-sign:before{content:"\e1c4"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-kiwi-bird:before{content:"\f535"}.fa-l:before{content:"\4c"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-medical:before{content:"\f812"}.fa-lari-sign:before{content:"\e1c8"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-lemon:before{content:"\f094"}.fa-less-than:before{content:"\3c"}.fa-less-than-equal:before{content:"\f537"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-lira-sign:before{content:"\f195"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-location-arrow:before{content:"\f124"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-lungs:before{content:"\f604"}.fa-lungs-virus:before{content:"\e067"}.fa-m:before{content:"\4d"}.fa-magnet:before{content:"\f076"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-manat-sign:before{content:"\e1d5"}.fa-map:before{content:"\f279"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-pin:before{content:"\f276"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-and-venus:before{content:"\f224"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-mask:before{content:"\f6fa"}.fa-mask-face:before{content:"\e1d7"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-medal:before{content:"\f5a2"}.fa-memory:before{content:"\f538"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-meteor:before{content:"\f753"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-mill-sign:before{content:"\e1ed"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-mitten:before{content:"\f7b5"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-mobile-button:before{content:"\f10b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mug-hot:before{content:"\f7b6"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-music:before{content:"\f001"}.fa-n:before{content:"\4e"}.fa-naira-sign:before{content:"\e1f6"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-not-equal:before{content:"\f53e"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-notes-medical:before{content:"\f481"}.fa-o:before{content:"\4f"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-oil-can:before{content:"\f613"}.fa-om:before{content:"\f679"}.fa-otter:before{content:"\f700"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-p:before{content:"\50"}.fa-pager:before{content:"\f815"}.fa-paint-roller:before{content:"\f5aa"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-palette:before{content:"\f53f"}.fa-pallet:before{content:"\f482"}.fa-panorama:before{content:"\e209"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-passport:before{content:"\f5ab"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-pause:before{content:"\f04c"}.fa-paw:before{content:"\f1b0"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-person-booth:before{content:"\f756"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-peseta-sign:before{content:"\e221"}.fa-peso-sign:before{content:"\e222"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-plane-slash:before{content:"\e069"}.fa-play:before{content:"\f04b"}.fa-plug:before{content:"\f1e6"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-plus-minus:before{content:"\e43c"}.fa-podcast:before{content:"\f2ce"}.fa-poo:before{content:"\f2fe"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-power-off:before{content:"\f011"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-pump-medical:before{content:"\e06a"}.fa-pump-soap:before{content:"\e06b"}.fa-puzzle-piece:before{content:"\f12e"}.fa-q:before{content:"\51"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\3f"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-r:before{content:"\52"}.fa-radiation:before{content:"\f7b9"}.fa-rainbow:before{content:"\f75b"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-recycle:before{content:"\f1b8"}.fa-registered:before{content:"\f25d"}.fa-repeat:before{content:"\f363"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-republican:before{content:"\f75e"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-ribbon:before{content:"\f4d6"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-route:before{content:"\f4d7"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-rupiah-sign:before{content:"\e23d"}.fa-s:before{content:"\53"}.fa-sailboat:before{content:"\e445"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-school:before{content:"\f549"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-screwdriver:before{content:"\f54a"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-scroll:before{content:"\f70e"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-sd-card:before{content:"\f7c2"}.fa-section:before{content:"\e447"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-server:before{content:"\f233"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-arrow-turn-right:before,.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-shield:before{content:"\f132"}.fa-shield-alt:before,.fa-shield-blank:before{content:"\f3ed"}.fa-shield-virus:before{content:"\e06c"}.fa-ship:before{content:"\f21a"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-shoe-prints:before{content:"\f54b"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-shower:before{content:"\f2cc"}.fa-shrimp:before{content:"\e448"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-sim-card:before{content:"\f7c4"}.fa-sink:before{content:"\e06d"}.fa-sitemap:before{content:"\f0e8"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-soap:before{content:"\e06e"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-spa:before{content:"\f5bb"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-spray-can:before{content:"\f5bd"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-square:before{content:"\f0c8"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-square-full:before{content:"\f45c"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-stairs:before{content:"\e289"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-stethoscope:before{content:"\f0f1"}.fa-stop:before{content:"\f04d"}.fa-stopwatch:before{content:"\f2f2"}.fa-stopwatch-20:before{content:"\e06f"}.fa-store:before{content:"\f54e"}.fa-store-slash:before{content:"\e071"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stroopwafel:before{content:"\f551"}.fa-subscript:before{content:"\f12c"}.fa-suitcase:before{content:"\f0f2"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superscript:before{content:"\f12b"}.fa-swatchbook:before{content:"\f5c3"}.fa-synagogue:before{content:"\f69b"}.fa-syringe:before{content:"\f48e"}.fa-t:before{content:"\54"}.fa-table:before{content:"\f0ce"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-tablet-button:before{content:"\f10a"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-text-width:before{content:"\f035"}.fa-thermometer:before{content:"\f491"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-ticket:before{content:"\f145"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-timeline:before{content:"\e29c"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-toolbox:before{content:"\f552"}.fa-tooth:before{content:"\f5c9"}.fa-torii-gate:before{content:"\f6a1"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-tractor:before{content:"\f722"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-trailer:before{content:"\e041"}.fa-train:before{content:"\f238"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-train-tram:before,.fa-tram:before{content:"\f7da"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-u:before{content:"\55"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-universal-access:before{content:"\f29a"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-upload:before{content:"\f093"}.fa-user:before{content:"\f007"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-clock:before{content:"\f4fd"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-user-graduate:before{content:"\f501"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-user-injured:before{content:"\f728"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-user-lock:before{content:"\f502"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-users-slash:before{content:"\e073"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-v:before{content:"\56"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-vault:before{content:"\e2c5"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-vest:before{content:"\e085"}.fa-vest-patches:before{content:"\e086"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-virus:before{content:"\e074"}.fa-virus-covid:before{content:"\e4a8"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-virus-slash:before{content:"\e075"}.fa-viruses:before{content:"\e076"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-volume-off:before{content:"\f026"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-vr-cardboard:before{content:"\f729"}.fa-w:before{content:"\57"}.fa-wallet:before{content:"\f555"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-wand-sparkles:before{content:"\f72b"}.fa-warehouse:before{content:"\f494"}.fa-water:before{content:"\f773"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-wave-square:before{content:"\f83e"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-wheelchair:before{content:"\f193"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-wind:before{content:"\f72e"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-wrench:before{content:"\f0ad"}.fa-x:before{content:"\58"}.fa-x-ray:before{content:"\f497"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-y:before{content:"\59"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-z:before{content:"\5a"}.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}:host,:root{--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../fonts/webfonts/fa-brands-400.woff2) format("woff2"),url(../fonts/webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-family:"Font Awesome 6 Brands";font-weight:400}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-alipay:before{content:"\f642"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-amilia:before{content:"\f36d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-pay:before{content:"\f415"}.fa-artstation:before{content:"\f77a"}.fa-asymmetrik:before{content:"\f372"}.fa-atlassian:before{content:"\f77b"}.fa-audible:before{content:"\f373"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-aws:before{content:"\f375"}.fa-bandcamp:before{content:"\f2d5"}.fa-battle-net:before{content:"\f835"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bilibili:before{content:"\e3d9"}.fa-bimobject:before{content:"\f378"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bootstrap:before{content:"\f836"}.fa-bots:before{content:"\e340"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-buromobelexperte:before{content:"\f37f"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-cloudflare:before{content:"\e07d"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cmplid:before{content:"\e360"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cotton-bureau:before{content:"\f89e"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-critical-role:before{content:"\f6c9"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dailymotion:before{content:"\e052"}.fa-dashcube:before{content:"\f210"}.fa-deezer:before{content:"\e077"}.fa-delicious:before{content:"\f1a5"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dhl:before{content:"\f790"}.fa-diaspora:before{content:"\f791"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-draft2digital:before{content:"\f396"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drupal:before{content:"\f1a9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edge-legacy:before{content:"\e078"}.fa-elementor:before{content:"\f430"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-etsy:before{content:"\f2d7"}.fa-evernote:before{content:"\f839"}.fa-expeditedssl:before{content:"\f23e"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-figma:before{content:"\f799"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\e007"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-fly:before{content:"\f417"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-fulcrum:before{content:"\f50b"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-gofore:before{content:"\f3a7"}.fa-golang:before{content:"\e40f"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-pay:before{content:"\e079"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guilded:before{content:"\e07e"}.fa-gulp:before{content:"\f3ae"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hashnode:before{content:"\e499"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-hive:before{content:"\e07f"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-hotjar:before{content:"\f3b1"}.fa-houzz:before{content:"\f27c"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-ideal:before{content:"\e013"}.fa-imdb:before{content:"\f2d8"}.fa-instagram:before{content:"\f16d"}.fa-instagram-square:before{content:"\e055"}.fa-instalod:before{content:"\e081"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joomla:before{content:"\f1aa"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaggle:before{content:"\f5fa"}.fa-keybase:before{content:"\f4f5"}.fa-keycdn:before{content:"\f3ba"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-korvue:before{content:"\f42f"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-leanpub:before{content:"\f212"}.fa-less:before{content:"\f41d"}.fa-line:before{content:"\f3c0"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-mailchimp:before{content:"\f59e"}.fa-mandalorian:before{content:"\f50f"}.fa-markdown:before{content:"\f60f"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-medapps:before{content:"\f3c6"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-mendeley:before{content:"\f7b3"}.fa-microblog:before{content:"\e01a"}.fa-microsoft:before{content:"\f3ca"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mixer:before{content:"\e056"}.fa-mizuni:before{content:"\f3cc"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-octopus-deploy:before{content:"\e082"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-old-republic:before{content:"\f510"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-osi:before{content:"\f41a"}.fa-padlet:before{content:"\e4a0"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-palfed:before{content:"\f3d8"}.fa-patreon:before{content:"\f3d9"}.fa-paypal:before{content:"\f1ed"}.fa-perbyte:before{content:"\e083"}.fa-periscope:before{content:"\f3da"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\e01e"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pix:before{content:"\e43a"}.fa-playstation:before{content:"\f3df"}.fa-product-hunt:before{content:"\f288"}.fa-pushed:before{content:"\f3e1"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-r-project:before{content:"\f4f7"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-renren:before{content:"\f18b"}.fa-replyd:before{content:"\f3e6"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-rev:before{content:"\f5b2"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-rust:before{content:"\e07a"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-schlix:before{content:"\f3ea"}.fa-scribd:before{content:"\f28a"}.fa-searchengin:before{content:"\f3eb"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-servicestack:before{content:"\f3ec"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shopify:before{content:"\e057"}.fa-shopware:before{content:"\f5b5"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sith:before{content:"\f512"}.fa-sitrox:before{content:"\e44a"}.fa-sketch:before{content:"\f7c6"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-slideshare:before{content:"\f1e7"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-square:before{content:"\f2ad"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spotify:before{content:"\f1bc"}.fa-square-font-awesome:before{content:"\f425"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-sticker-mule:before{content:"\f3f7"}.fa-strava:before{content:"\f428"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-superpowers:before{content:"\f2dd"}.fa-supple:before{content:"\f3f9"}.fa-suse:before{content:"\f7d6"}.fa-swift:before{content:"\f8e1"}.fa-symfony:before{content:"\f83d"}.fa-teamspeak:before{content:"\f4f9"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-the-red-yeti:before{content:"\f69d"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-think-peaks:before{content:"\f731"}.fa-tiktok:before{content:"\e07b"}.fa-trade-federation:before{content:"\f513"}.fa-trello:before{content:"\f181"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-uncharted:before{content:"\e084"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\e049"}.fa-unsplash:before{content:"\e07c"}.fa-untappd:before{content:"\f405"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-vaadin:before{content:"\f408"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-viber:before{content:"\f409"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-vuejs:before{content:"\f41f"}.fa-watchman-monitoring:before{content:"\e087"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-whmcs:before{content:"\f40d"}.fa-wikipedia-w:before{content:"\f266"}.fa-windows:before{content:"\f17a"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wodu:before{content:"\e088"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../fonts/webfonts/fa-regular-400.woff2) format("woff2"),url(../fonts/webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-family:"Font Awesome 6 Free";font-weight:400}:host,:root{--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../fonts/webfonts/fa-solid-900.woff2) format("woff2"),url(../fonts/webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-family:"Font Awesome 6 Free";font-weight:900}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../fonts/webfonts/fa-brands-400.woff2) format("woff2"),url(../fonts/webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../fonts/webfonts/fa-solid-900.woff2) format("woff2"),url(../fonts/webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../fonts/webfonts/fa-regular-400.woff2) format("woff2"),url(../fonts/webfonts/fa-regular-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../fonts/webfonts/fa-solid-900.woff2) format("woff2"),url(../fonts/webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../fonts/webfonts/fa-brands-400.woff2) format("woff2"),url(../fonts/webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../fonts/webfonts/fa-regular-400.woff2) format("woff2"),url(../fonts/webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../fonts/webfonts/fa-v4compatibility.woff2) format("woff2"),url(../fonts/webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f250,u+f252,u+f27a}
\ No newline at end of file
diff --git a/speakr/static/vendor/fonts/webfonts/fa-brands-400.ttf b/speakr/static/vendor/fonts/webfonts/fa-brands-400.ttf
new file mode 100644
index 0000000..227f022
Binary files /dev/null and b/speakr/static/vendor/fonts/webfonts/fa-brands-400.ttf differ
diff --git a/speakr/static/vendor/fonts/webfonts/fa-brands-400.woff2 b/speakr/static/vendor/fonts/webfonts/fa-brands-400.woff2
new file mode 100644
index 0000000..73c5c12
Binary files /dev/null and b/speakr/static/vendor/fonts/webfonts/fa-brands-400.woff2 differ
diff --git a/speakr/static/vendor/fonts/webfonts/fa-regular-400.ttf b/speakr/static/vendor/fonts/webfonts/fa-regular-400.ttf
new file mode 100644
index 0000000..c8ed46d
Binary files /dev/null and b/speakr/static/vendor/fonts/webfonts/fa-regular-400.ttf differ
diff --git a/speakr/static/vendor/fonts/webfonts/fa-regular-400.woff2 b/speakr/static/vendor/fonts/webfonts/fa-regular-400.woff2
new file mode 100644
index 0000000..c9291c7
Binary files /dev/null and b/speakr/static/vendor/fonts/webfonts/fa-regular-400.woff2 differ
diff --git a/speakr/static/vendor/fonts/webfonts/fa-solid-900.ttf b/speakr/static/vendor/fonts/webfonts/fa-solid-900.ttf
new file mode 100644
index 0000000..99b35ad
Binary files /dev/null and b/speakr/static/vendor/fonts/webfonts/fa-solid-900.ttf differ
diff --git a/speakr/static/vendor/fonts/webfonts/fa-solid-900.woff2 b/speakr/static/vendor/fonts/webfonts/fa-solid-900.woff2
new file mode 100644
index 0000000..c7bd59c
Binary files /dev/null and b/speakr/static/vendor/fonts/webfonts/fa-solid-900.woff2 differ
diff --git a/speakr/static/vendor/fonts/webfonts/fa-v4compatibility.ttf b/speakr/static/vendor/fonts/webfonts/fa-v4compatibility.ttf
new file mode 100644
index 0000000..be0afc2
Binary files /dev/null and b/speakr/static/vendor/fonts/webfonts/fa-v4compatibility.ttf differ
diff --git a/speakr/static/vendor/fonts/webfonts/fa-v4compatibility.woff2 b/speakr/static/vendor/fonts/webfonts/fa-v4compatibility.woff2
new file mode 100644
index 0000000..37a9b8c
Binary files /dev/null and b/speakr/static/vendor/fonts/webfonts/fa-v4compatibility.woff2 differ
diff --git a/speakr/static/vendor/js/axios.min.js b/speakr/static/vendor/js/axios.min.js
new file mode 100644
index 0000000..4a6e8c4
--- /dev/null
+++ b/speakr/static/vendor/js/axios.min.js
@@ -0,0 +1,3 @@
+/*! Axios v1.12.2 Copyright (c) 2025 Matt Zabriskie and contributors */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).axios=t()}(this,(function(){"use strict";function e(e){var r,n;function o(r,n){try{var a=e[r](n),s=a.value,u=s instanceof t;Promise.resolve(u?s.v:s).then((function(t){if(u){var n="return"===r?"return":"next";if(!s.k||t.done)return o(n,t);t=e[n](t).value}i(a.done?"return":"normal",t)}),(function(e){o("throw",e)}))}catch(e){i("throw",e)}}function i(e,t){switch(e){case"return":r.resolve({value:t,done:!0});break;case"throw":r.reject(t);break;default:r.resolve({value:t,done:!1})}(r=r.next)?o(r.key,r.arg):n=null}this._invoke=function(e,t){return new Promise((function(i,a){var s={key:e,arg:t,resolve:i,reject:a,next:null};n?n=n.next=s:(r=n=s,o(e,t))}))},"function"!=typeof e.return&&(this.return=void 0)}function t(e,t){this.v=e,this.k=t}function r(e){var r={},n=!1;function o(r,o){return n=!0,o=new Promise((function(t){t(e[r](o))})),{done:!1,value:new t(o,1)}}return r["undefined"!=typeof Symbol&&Symbol.iterator||"@@iterator"]=function(){return this},r.next=function(e){return n?(n=!1,e):o("next",e)},"function"==typeof e.throw&&(r.throw=function(e){if(n)throw n=!1,e;return o("throw",e)}),"function"==typeof e.return&&(r.return=function(e){return n?(n=!1,e):o("return",e)}),r}function n(e){var t,r,n,i=2;for("undefined"!=typeof Symbol&&(r=Symbol.asyncIterator,n=Symbol.iterator);i--;){if(r&&null!=(t=e[r]))return t.call(e);if(n&&null!=(t=e[n]))return new o(t.call(e));r="@@asyncIterator",n="@@iterator"}throw new TypeError("Object is not async iterable")}function o(e){function t(e){if(Object(e)!==e)return Promise.reject(new TypeError(e+" is not an object."));var t=e.done;return Promise.resolve(e.value).then((function(e){return{value:e,done:t}}))}return o=function(e){this.s=e,this.n=e.next},o.prototype={s:null,n:null,next:function(){return t(this.n.apply(this.s,arguments))},return:function(e){var r=this.s.return;return void 0===r?Promise.resolve({value:e,done:!0}):t(r.apply(this.s,arguments))},throw:function(e){var r=this.s.return;return void 0===r?Promise.reject(e):t(r.apply(this.s,arguments))}},new o(e)}function i(e){return new t(e,0)}function a(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function s(e){for(var t=1;t=0;--i){var a=this.tryEntries[i],s=a.completion;if("root"===a.tryLoc)return o("end");if(a.tryLoc<=this.prev){var u=n.call(a,"catchLoc"),c=n.call(a,"finallyLoc");if(u&&c){if(this.prev=0;--r){var o=this.tryEntries[r];if(o.tryLoc<=this.prev&&n.call(o,"finallyLoc")&&this.prev=0;--t){var r=this.tryEntries[t];if(r.finallyLoc===e)return this.complete(r.completion,r.afterLoc),A(r),y}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var r=this.tryEntries[t];if(r.tryLoc===e){var n=r.completion;if("throw"===n.type){var o=n.arg;A(r)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,r,n){return this.delegate={iterator:L(t),resultName:r,nextLoc:n},"next"===this.method&&(this.arg=e),y}},t}function c(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=typeof n)return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:String(t)}function f(e){return f="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},f(e)}function l(t){return function(){return new e(t.apply(this,arguments))}}function p(e,t,r,n,o,i,a){try{var s=e[i](a),u=s.value}catch(e){return void r(e)}s.done?t(u):Promise.resolve(u).then(n,o)}function h(e){return function(){var t=this,r=arguments;return new Promise((function(n,o){var i=e.apply(t,r);function a(e){p(i,n,o,a,s,"next",e)}function s(e){p(i,n,o,a,s,"throw",e)}a(void 0)}))}}function d(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function v(e,t){for(var r=0;re.length)&&(t=e.length);for(var r=0,n=new Array(t);r2&&void 0!==arguments[2]?arguments[2]:{},i=o.allOwnKeys,a=void 0!==i&&i;if(null!=e)if("object"!==f(e)&&(e=[e]),L(e))for(r=0,n=e.length;r0;)if(t===(r=n[o]).toLowerCase())return r;return null}var Q="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:global,Z=function(e){return!N(e)&&e!==Q};var ee,te=(ee="undefined"!=typeof Uint8Array&&R(Uint8Array),function(e){return ee&&e instanceof ee}),re=A("HTMLFormElement"),ne=function(e){var t=Object.prototype.hasOwnProperty;return function(e,r){return t.call(e,r)}}(),oe=A("RegExp"),ie=function(e,t){var r=Object.getOwnPropertyDescriptors(e),n={};$(r,(function(r,o){var i;!1!==(i=t(r,o,e))&&(n[o]=i||r)})),Object.defineProperties(e,n)};var ae,se,ue,ce,fe=A("AsyncFunction"),le=(ae="function"==typeof setImmediate,se=F(Q.postMessage),ae?setImmediate:se?(ue="axios@".concat(Math.random()),ce=[],Q.addEventListener("message",(function(e){var t=e.source,r=e.data;t===Q&&r===ue&&ce.length&&ce.shift()()}),!1),function(e){ce.push(e),Q.postMessage(ue,"*")}):function(e){return setTimeout(e)}),pe="undefined"!=typeof queueMicrotask?queueMicrotask.bind(Q):"undefined"!=typeof process&&process.nextTick||le,he={isArray:L,isArrayBuffer:C,isBuffer:_,isFormData:function(e){var t;return e&&("function"==typeof FormData&&e instanceof FormData||F(e.append)&&("formdata"===(t=T(e))||"object"===t&&F(e.toString)&&"[object FormData]"===e.toString()))},isArrayBufferView:function(e){return"undefined"!=typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&C(e.buffer)},isString:U,isNumber:B,isBoolean:function(e){return!0===e||!1===e},isObject:D,isPlainObject:I,isEmptyObject:function(e){if(!D(e)||_(e))return!1;try{return 0===Object.keys(e).length&&Object.getPrototypeOf(e)===Object.prototype}catch(e){return!1}},isReadableStream:K,isRequest:V,isResponse:G,isHeaders:X,isUndefined:N,isDate:q,isFile:M,isBlob:z,isRegExp:oe,isFunction:F,isStream:function(e){return D(e)&&F(e.pipe)},isURLSearchParams:J,isTypedArray:te,isFileList:H,forEach:$,merge:function e(){for(var t=Z(this)&&this||{},r=t.caseless,n=t.skipUndefined,o={},i=function(t,i){var a=r&&Y(o,i)||i;I(o[a])&&I(t)?o[a]=e(o[a],t):I(t)?o[a]=e({},t):L(t)?o[a]=t.slice():n&&N(t)||(o[a]=t)},a=0,s=arguments.length;a3&&void 0!==arguments[3]?arguments[3]:{},o=n.allOwnKeys;return $(t,(function(t,n){r&&F(t)?e[n]=O(t,r):e[n]=t}),{allOwnKeys:o}),e},trim:function(e){return e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")},stripBOM:function(e){return 65279===e.charCodeAt(0)&&(e=e.slice(1)),e},inherits:function(e,t,r,n){e.prototype=Object.create(t.prototype,n),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),r&&Object.assign(e.prototype,r)},toFlatObject:function(e,t,r,n){var o,i,a,s={};if(t=t||{},null==e)return t;do{for(i=(o=Object.getOwnPropertyNames(e)).length;i-- >0;)a=o[i],n&&!n(a,e,t)||s[a]||(t[a]=e[a],s[a]=!0);e=!1!==r&&R(e)}while(e&&(!r||r(e,t))&&e!==Object.prototype);return t},kindOf:T,kindOfTest:A,endsWith:function(e,t,r){e=String(e),(void 0===r||r>e.length)&&(r=e.length),r-=t.length;var n=e.indexOf(t,r);return-1!==n&&n===r},toArray:function(e){if(!e)return null;if(L(e))return e;var t=e.length;if(!B(t))return null;for(var r=new Array(t);t-- >0;)r[t]=e[t];return r},forEachEntry:function(e,t){for(var r,n=(e&&e[k]).call(e);(r=n.next())&&!r.done;){var o=r.value;t.call(e,o[0],o[1])}},matchAll:function(e,t){for(var r,n=[];null!==(r=e.exec(t));)n.push(r);return n},isHTMLForm:re,hasOwnProperty:ne,hasOwnProp:ne,reduceDescriptors:ie,freezeMethods:function(e){ie(e,(function(t,r){if(F(e)&&-1!==["arguments","caller","callee"].indexOf(r))return!1;var n=e[r];F(n)&&(t.enumerable=!1,"writable"in t?t.writable=!1:t.set||(t.set=function(){throw Error("Can not rewrite read-only method '"+r+"'")}))}))},toObjectSet:function(e,t){var r={},n=function(e){e.forEach((function(e){r[e]=!0}))};return L(e)?n(e):n(String(e).split(t)),r},toCamelCase:function(e){return e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,(function(e,t,r){return t.toUpperCase()+r}))},noop:function(){},toFiniteNumber:function(e,t){return null!=e&&Number.isFinite(e=+e)?e:t},findKey:Y,global:Q,isContextDefined:Z,isSpecCompliantForm:function(e){return!!(e&&F(e.append)&&"FormData"===e[j]&&e[k])},toJSONObject:function(e){var t=new Array(10);return function e(r,n){if(D(r)){if(t.indexOf(r)>=0)return;if(_(r))return r;if(!("toJSON"in r)){t[n]=r;var o=L(r)?[]:{};return $(r,(function(t,r){var i=e(t,n+1);!N(i)&&(o[r]=i)})),t[n]=void 0,o}}return r}(e,0)},isAsyncFn:fe,isThenable:function(e){return e&&(D(e)||F(e))&&F(e.then)&&F(e.catch)},setImmediate:le,asap:pe,isIterable:function(e){return null!=e&&F(e[k])}};function de(e,t,r,n,o){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack,this.message=e,this.name="AxiosError",t&&(this.code=t),r&&(this.config=r),n&&(this.request=n),o&&(this.response=o,this.status=o.status?o.status:null)}he.inherits(de,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:he.toJSONObject(this.config),code:this.code,status:this.status}}});var ve=de.prototype,ye={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach((function(e){ye[e]={value:e}})),Object.defineProperties(de,ye),Object.defineProperty(ve,"isAxiosError",{value:!0}),de.from=function(e,t,r,n,o,i){var a=Object.create(ve);he.toFlatObject(e,a,(function(e){return e!==Error.prototype}),(function(e){return"isAxiosError"!==e}));var s=e&&e.message?e.message:"Error",u=null==t&&e?e.code:t;return de.call(a,s,u,r,n,o),e&&null==a.cause&&Object.defineProperty(a,"cause",{value:e,configurable:!0}),a.name=e&&e.name||"Error",i&&Object.assign(a,i),a};function me(e){return he.isPlainObject(e)||he.isArray(e)}function be(e){return he.endsWith(e,"[]")?e.slice(0,-2):e}function ge(e,t,r){return e?e.concat(t).map((function(e,t){return e=be(e),!r&&t?"["+e+"]":e})).join(r?".":""):t}var we=he.toFlatObject(he,{},null,(function(e){return/^is[A-Z]/.test(e)}));function Ee(e,t,r){if(!he.isObject(e))throw new TypeError("target must be an object");t=t||new FormData;var n=(r=he.toFlatObject(r,{metaTokens:!0,dots:!1,indexes:!1},!1,(function(e,t){return!he.isUndefined(t[e])}))).metaTokens,o=r.visitor||c,i=r.dots,a=r.indexes,s=(r.Blob||"undefined"!=typeof Blob&&Blob)&&he.isSpecCompliantForm(t);if(!he.isFunction(o))throw new TypeError("visitor must be a function");function u(e){if(null===e)return"";if(he.isDate(e))return e.toISOString();if(he.isBoolean(e))return e.toString();if(!s&&he.isBlob(e))throw new de("Blob is not supported. Use a Buffer instead.");return he.isArrayBuffer(e)||he.isTypedArray(e)?s&&"function"==typeof Blob?new Blob([e]):Buffer.from(e):e}function c(e,r,o){var s=e;if(e&&!o&&"object"===f(e))if(he.endsWith(r,"{}"))r=n?r:r.slice(0,-2),e=JSON.stringify(e);else if(he.isArray(e)&&function(e){return he.isArray(e)&&!e.some(me)}(e)||(he.isFileList(e)||he.endsWith(r,"[]"))&&(s=he.toArray(e)))return r=be(r),s.forEach((function(e,n){!he.isUndefined(e)&&null!==e&&t.append(!0===a?ge([r],n,i):null===a?r:r+"[]",u(e))})),!1;return!!me(e)||(t.append(ge(o,r,i),u(e)),!1)}var l=[],p=Object.assign(we,{defaultVisitor:c,convertValue:u,isVisitable:me});if(!he.isObject(e))throw new TypeError("data must be an object");return function e(r,n){if(!he.isUndefined(r)){if(-1!==l.indexOf(r))throw Error("Circular reference detected in "+n.join("."));l.push(r),he.forEach(r,(function(r,i){!0===(!(he.isUndefined(r)||null===r)&&o.call(t,r,he.isString(i)?i.trim():i,n,p))&&e(r,n?n.concat(i):[i])})),l.pop()}}(e),t}function Oe(e){var t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,(function(e){return t[e]}))}function Se(e,t){this._pairs=[],e&&Ee(e,this,t)}var xe=Se.prototype;function Re(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function ke(e,t,r){if(!t)return e;var n=r&&r.encode||Re;he.isFunction(r)&&(r={serialize:r});var o,i=r&&r.serialize;if(o=i?i(t,r):he.isURLSearchParams(t)?t.toString():new Se(t,r).toString(n)){var a=e.indexOf("#");-1!==a&&(e=e.slice(0,a)),e+=(-1===e.indexOf("?")?"?":"&")+o}return e}xe.append=function(e,t){this._pairs.push([e,t])},xe.toString=function(e){var t=e?function(t){return e.call(this,t,Oe)}:Oe;return this._pairs.map((function(e){return t(e[0])+"="+t(e[1])}),"").join("&")};var je=function(){function e(){d(this,e),this.handlers=[]}return y(e,[{key:"use",value:function(e,t,r){return this.handlers.push({fulfilled:e,rejected:t,synchronous:!!r&&r.synchronous,runWhen:r?r.runWhen:null}),this.handlers.length-1}},{key:"eject",value:function(e){this.handlers[e]&&(this.handlers[e]=null)}},{key:"clear",value:function(){this.handlers&&(this.handlers=[])}},{key:"forEach",value:function(e){he.forEach(this.handlers,(function(t){null!==t&&e(t)}))}}]),e}(),Te={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},Ae={isBrowser:!0,classes:{URLSearchParams:"undefined"!=typeof URLSearchParams?URLSearchParams:Se,FormData:"undefined"!=typeof FormData?FormData:null,Blob:"undefined"!=typeof Blob?Blob:null},protocols:["http","https","file","blob","url","data"]},Pe="undefined"!=typeof window&&"undefined"!=typeof document,Le="object"===("undefined"==typeof navigator?"undefined":f(navigator))&&navigator||void 0,Ne=Pe&&(!Le||["ReactNative","NativeScript","NS"].indexOf(Le.product)<0),_e="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope&&"function"==typeof self.importScripts,Ce=Pe&&window.location.href||"http://localhost",Ue=s(s({},Object.freeze({__proto__:null,hasBrowserEnv:Pe,hasStandardBrowserWebWorkerEnv:_e,hasStandardBrowserEnv:Ne,navigator:Le,origin:Ce})),Ae);function Fe(e){function t(e,r,n,o){var i=e[o++];if("__proto__"===i)return!0;var a=Number.isFinite(+i),s=o>=e.length;return i=!i&&he.isArray(n)?n.length:i,s?(he.hasOwnProp(n,i)?n[i]=[n[i],r]:n[i]=r,!a):(n[i]&&he.isObject(n[i])||(n[i]=[]),t(e,r,n[i],o)&&he.isArray(n[i])&&(n[i]=function(e){var t,r,n={},o=Object.keys(e),i=o.length;for(t=0;t-1,i=he.isObject(e);if(i&&he.isHTMLForm(e)&&(e=new FormData(e)),he.isFormData(e))return o?JSON.stringify(Fe(e)):e;if(he.isArrayBuffer(e)||he.isBuffer(e)||he.isStream(e)||he.isFile(e)||he.isBlob(e)||he.isReadableStream(e))return e;if(he.isArrayBufferView(e))return e.buffer;if(he.isURLSearchParams(e))return t.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();if(i){if(n.indexOf("application/x-www-form-urlencoded")>-1)return function(e,t){return Ee(e,new Ue.classes.URLSearchParams,s({visitor:function(e,t,r,n){return Ue.isNode&&he.isBuffer(e)?(this.append(t,e.toString("base64")),!1):n.defaultVisitor.apply(this,arguments)}},t))}(e,this.formSerializer).toString();if((r=he.isFileList(e))||n.indexOf("multipart/form-data")>-1){var a=this.env&&this.env.FormData;return Ee(r?{"files[]":e}:e,a&&new a,this.formSerializer)}}return i||o?(t.setContentType("application/json",!1),function(e,t,r){if(he.isString(e))try{return(t||JSON.parse)(e),he.trim(e)}catch(e){if("SyntaxError"!==e.name)throw e}return(r||JSON.stringify)(e)}(e)):e}],transformResponse:[function(e){var t=this.transitional||Be.transitional,r=t&&t.forcedJSONParsing,n="json"===this.responseType;if(he.isResponse(e)||he.isReadableStream(e))return e;if(e&&he.isString(e)&&(r&&!this.responseType||n)){var o=!(t&&t.silentJSONParsing)&&n;try{return JSON.parse(e,this.parseReviver)}catch(e){if(o){if("SyntaxError"===e.name)throw de.from(e,de.ERR_BAD_RESPONSE,this,null,this.response);throw e}}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Ue.classes.FormData,Blob:Ue.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};he.forEach(["delete","get","head","post","put","patch"],(function(e){Be.headers[e]={}}));var De=Be,Ie=he.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),qe=Symbol("internals");function Me(e){return e&&String(e).trim().toLowerCase()}function ze(e){return!1===e||null==e?e:he.isArray(e)?e.map(ze):String(e)}function He(e,t,r,n,o){return he.isFunction(n)?n.call(this,t,r):(o&&(t=r),he.isString(t)?he.isString(n)?-1!==t.indexOf(n):he.isRegExp(n)?n.test(t):void 0:void 0)}var Je=function(e,t){function r(e){d(this,r),e&&this.set(e)}return y(r,[{key:"set",value:function(e,t,r){var n=this;function o(e,t,r){var o=Me(t);if(!o)throw new Error("header name must be a non-empty string");var i=he.findKey(n,o);(!i||void 0===n[i]||!0===r||void 0===r&&!1!==n[i])&&(n[i||t]=ze(e))}var i=function(e,t){return he.forEach(e,(function(e,r){return o(e,r,t)}))};if(he.isPlainObject(e)||e instanceof this.constructor)i(e,t);else if(he.isString(e)&&(e=e.trim())&&!/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim()))i(function(e){var t,r,n,o={};return e&&e.split("\n").forEach((function(e){n=e.indexOf(":"),t=e.substring(0,n).trim().toLowerCase(),r=e.substring(n+1).trim(),!t||o[t]&&Ie[t]||("set-cookie"===t?o[t]?o[t].push(r):o[t]=[r]:o[t]=o[t]?o[t]+", "+r:r)})),o}(e),t);else if(he.isObject(e)&&he.isIterable(e)){var a,s,u,c={},f=function(e,t){var r="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!r){if(Array.isArray(e)||(r=w(e))||t&&e&&"number"==typeof e.length){r&&(e=r);var n=0,o=function(){};return{s:o,n:function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,a=!0,s=!1;return{s:function(){r=r.call(e)},n:function(){var e=r.next();return a=e.done,e},e:function(e){s=!0,i=e},f:function(){try{a||null==r.return||r.return()}finally{if(s)throw i}}}}(e);try{for(f.s();!(u=f.n()).done;){var l=u.value;if(!he.isArray(l))throw TypeError("Object iterator must return a key-value pair");c[s=l[0]]=(a=c[s])?he.isArray(a)?[].concat(g(a),[l[1]]):[a,l[1]]:l[1]}}catch(e){f.e(e)}finally{f.f()}i(c,t)}else null!=e&&o(t,e,r);return this}},{key:"get",value:function(e,t){if(e=Me(e)){var r=he.findKey(this,e);if(r){var n=this[r];if(!t)return n;if(!0===t)return function(e){for(var t,r=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;t=n.exec(e);)r[t[1]]=t[2];return r}(n);if(he.isFunction(t))return t.call(this,n,r);if(he.isRegExp(t))return t.exec(n);throw new TypeError("parser must be boolean|regexp|function")}}}},{key:"has",value:function(e,t){if(e=Me(e)){var r=he.findKey(this,e);return!(!r||void 0===this[r]||t&&!He(0,this[r],r,t))}return!1}},{key:"delete",value:function(e,t){var r=this,n=!1;function o(e){if(e=Me(e)){var o=he.findKey(r,e);!o||t&&!He(0,r[o],o,t)||(delete r[o],n=!0)}}return he.isArray(e)?e.forEach(o):o(e),n}},{key:"clear",value:function(e){for(var t=Object.keys(this),r=t.length,n=!1;r--;){var o=t[r];e&&!He(0,this[o],o,e,!0)||(delete this[o],n=!0)}return n}},{key:"normalize",value:function(e){var t=this,r={};return he.forEach(this,(function(n,o){var i=he.findKey(r,o);if(i)return t[i]=ze(n),void delete t[o];var a=e?function(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(function(e,t,r){return t.toUpperCase()+r}))}(o):String(o).trim();a!==o&&delete t[o],t[a]=ze(n),r[a]=!0})),this}},{key:"concat",value:function(){for(var e,t=arguments.length,r=new Array(t),n=0;n1?r-1:0),o=1;o1&&void 0!==arguments[1]?arguments[1]:Date.now();o=i,r=null,n&&(clearTimeout(n),n=null),e.apply(void 0,g(t))};return[function(){for(var e=Date.now(),t=e-o,s=arguments.length,u=new Array(s),c=0;c=i?a(u,e):(r=u,n||(n=setTimeout((function(){n=null,a(r)}),i-t)))},function(){return r&&a(r)}]}he.inherits(Ge,de,{__CANCEL__:!0});var Qe=function(e,t){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:3,n=0,o=$e(50,250);return Ye((function(r){var i=r.loaded,a=r.lengthComputable?r.total:void 0,s=i-n,u=o(s);n=i;var c=m({loaded:i,total:a,progress:a?i/a:void 0,bytes:s,rate:u||void 0,estimated:u&&a&&i<=a?(a-i)/u:void 0,event:r,lengthComputable:null!=a},t?"download":"upload",!0);e(c)}),r)},Ze=function(e,t){var r=null!=e;return[function(n){return t[0]({lengthComputable:r,total:e,loaded:n})},t[1]]},et=function(e){return function(){for(var t=arguments.length,r=new Array(t),n=0;n1?t-1:0),n=1;n1?"since :\n"+u.map(xt).join("\n"):" "+xt(u[0]):"as no adapter specified"),"ERR_NOT_SUPPORT")}return n};function jt(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new Ge(null,e)}function Tt(e){return jt(e),e.headers=We.from(e.headers),e.data=Ke.call(e,e.transformRequest),-1!==["post","put","patch"].indexOf(e.method)&&e.headers.setContentType("application/x-www-form-urlencoded",!1),kt(e.adapter||De.adapter,e)(e).then((function(t){return jt(e),t.data=Ke.call(e,e.transformResponse,t),t.headers=We.from(t.headers),t}),(function(t){return Ve(t)||(jt(e),t&&t.response&&(t.response.data=Ke.call(e,e.transformResponse,t.response),t.response.headers=We.from(t.response.headers))),Promise.reject(t)}))}var At="1.12.2",Pt={};["object","boolean","number","function","string","symbol"].forEach((function(e,t){Pt[e]=function(r){return f(r)===e||"a"+(t<1?"n ":" ")+e}}));var Lt={};Pt.transitional=function(e,t,r){function n(e,t){return"[Axios v1.12.2] Transitional option '"+e+"'"+t+(r?". "+r:"")}return function(r,o,i){if(!1===e)throw new de(n(o," has been removed"+(t?" in "+t:"")),de.ERR_DEPRECATED);return t&&!Lt[o]&&(Lt[o]=!0,console.warn(n(o," has been deprecated since v"+t+" and will be removed in the near future"))),!e||e(r,o,i)}},Pt.spelling=function(e){return function(t,r){return console.warn("".concat(r," is likely a misspelling of ").concat(e)),!0}};var Nt={assertOptions:function(e,t,r){if("object"!==f(e))throw new de("options must be an object",de.ERR_BAD_OPTION_VALUE);for(var n=Object.keys(e),o=n.length;o-- >0;){var i=n[o],a=t[i];if(a){var s=e[i],u=void 0===s||a(s,i,e);if(!0!==u)throw new de("option "+i+" must be "+u,de.ERR_BAD_OPTION_VALUE)}else if(!0!==r)throw new de("Unknown option "+i,de.ERR_BAD_OPTION)}},validators:Pt},_t=Nt.validators,Ct=function(){function e(t){d(this,e),this.defaults=t||{},this.interceptors={request:new je,response:new je}}var t;return y(e,[{key:"request",value:(t=h(u().mark((function e(t,r){var n,o;return u().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.prev=0,e.next=3,this._request(t,r);case 3:return e.abrupt("return",e.sent);case 6:if(e.prev=6,e.t0=e.catch(0),e.t0 instanceof Error){n={},Error.captureStackTrace?Error.captureStackTrace(n):n=new Error,o=n.stack?n.stack.replace(/^.+\n/,""):"";try{e.t0.stack?o&&!String(e.t0.stack).endsWith(o.replace(/^.+\n.+\n/,""))&&(e.t0.stack+="\n"+o):e.t0.stack=o}catch(e){}}throw e.t0;case 10:case"end":return e.stop()}}),e,this,[[0,6]])}))),function(e,r){return t.apply(this,arguments)})},{key:"_request",value:function(e,t){"string"==typeof e?(t=t||{}).url=e:t=e||{};var r=t=it(this.defaults,t),n=r.transitional,o=r.paramsSerializer,i=r.headers;void 0!==n&&Nt.assertOptions(n,{silentJSONParsing:_t.transitional(_t.boolean),forcedJSONParsing:_t.transitional(_t.boolean),clarifyTimeoutError:_t.transitional(_t.boolean)},!1),null!=o&&(he.isFunction(o)?t.paramsSerializer={serialize:o}:Nt.assertOptions(o,{encode:_t.function,serialize:_t.function},!0)),void 0!==t.allowAbsoluteUrls||(void 0!==this.defaults.allowAbsoluteUrls?t.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:t.allowAbsoluteUrls=!0),Nt.assertOptions(t,{baseUrl:_t.spelling("baseURL"),withXsrfToken:_t.spelling("withXSRFToken")},!0),t.method=(t.method||this.defaults.method||"get").toLowerCase();var a=i&&he.merge(i.common,i[t.method]);i&&he.forEach(["delete","get","head","post","put","patch","common"],(function(e){delete i[e]})),t.headers=We.concat(a,i);var s=[],u=!0;this.interceptors.request.forEach((function(e){"function"==typeof e.runWhen&&!1===e.runWhen(t)||(u=u&&e.synchronous,s.unshift(e.fulfilled,e.rejected))}));var c,f=[];this.interceptors.response.forEach((function(e){f.push(e.fulfilled,e.rejected)}));var l,p=0;if(!u){var h=[Tt.bind(this),void 0];for(h.unshift.apply(h,s),h.push.apply(h,f),l=h.length,c=Promise.resolve(t);p0;)n._listeners[t](e);n._listeners=null}})),this.promise.then=function(e){var t,r=new Promise((function(e){n.subscribe(e),t=e})).then(e);return r.cancel=function(){n.unsubscribe(t)},r},t((function(e,t,o){n.reason||(n.reason=new Ge(e,t,o),r(n.reason))}))}return y(e,[{key:"throwIfRequested",value:function(){if(this.reason)throw this.reason}},{key:"subscribe",value:function(e){this.reason?e(this.reason):this._listeners?this._listeners.push(e):this._listeners=[e]}},{key:"unsubscribe",value:function(e){if(this._listeners){var t=this._listeners.indexOf(e);-1!==t&&this._listeners.splice(t,1)}}},{key:"toAbortSignal",value:function(){var e=this,t=new AbortController,r=function(e){t.abort(e)};return this.subscribe(r),t.signal.unsubscribe=function(){return e.unsubscribe(r)},t.signal}}],[{key:"source",value:function(){var t;return{token:new e((function(e){t=e})),cancel:t}}}]),e}(),Bt=Ft;var Dt={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(Dt).forEach((function(e){var t=b(e,2),r=t[0],n=t[1];Dt[n]=r}));var It=Dt;var qt=function e(t){var r=new Ut(t),n=O(Ut.prototype.request,r);return he.extend(n,Ut.prototype,r,{allOwnKeys:!0}),he.extend(n,r,null,{allOwnKeys:!0}),n.create=function(r){return e(it(t,r))},n}(De);return qt.Axios=Ut,qt.CanceledError=Ge,qt.CancelToken=Bt,qt.isCancel=Ve,qt.VERSION=At,qt.toFormData=Ee,qt.AxiosError=de,qt.Cancel=qt.CanceledError,qt.all=function(e){return Promise.all(e)},qt.spread=function(e){return function(t){return e.apply(null,t)}},qt.isAxiosError=function(e){return he.isObject(e)&&!0===e.isAxiosError},qt.mergeConfig=it,qt.AxiosHeaders=We,qt.formToJSON=function(e){return Fe(he.isHTMLForm(e)?new FormData(e):e)},qt.getAdapter=kt,qt.HttpStatusCode=It,qt.default=qt,qt}));
+//# sourceMappingURL=axios.min.js.map
diff --git a/speakr/static/vendor/js/easymde.min.js b/speakr/static/vendor/js/easymde.min.js
new file mode 100644
index 0000000..0799a23
--- /dev/null
+++ b/speakr/static/vendor/js/easymde.min.js
@@ -0,0 +1,7 @@
+/**
+ * easymde v2.20.0
+ * Copyright Jeroen Akkerman
+ * @link https://github.com/ionaru/easy-markdown-editor
+ * @license MIT
+ */
+!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).EasyMDE=e()}}((function(){return function e(t,n,i){function r(a,l){if(!n[a]){if(!t[a]){var s="function"==typeof require&&require;if(!l&&s)return s(a,!0);if(o)return o(a,!0);var u=new Error("Cannot find module '"+a+"'");throw u.code="MODULE_NOT_FOUND",u}var c=n[a]={exports:{}};t[a][0].call(c.exports,(function(e){return r(t[a][1][e]||e)}),c,c.exports,e,t,n,i)}return n[a].exports}for(var o="function"==typeof require&&require,a=0;a[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/,n=/^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/,i=/[*+-]\s/;function r(e,n){var i=n.line,r=0,o=0,a=t.exec(e.getLine(i)),l=a[1];do{var s=i+(r+=1),u=e.getLine(s),c=t.exec(u);if(c){var d=c[1],h=parseInt(a[3],10)+r-o,f=parseInt(c[3],10),p=f;if(l!==d||isNaN(f)){if(l.length>d.length)return;if(l.lengthf&&(p=h+1),e.replaceRange(u.replace(t,d+p+c[4]+c[5]),{line:s,ch:0},{line:s,ch:u.length})}}while(c)}e.commands.newlineAndIndentContinueMarkdownList=function(o){if(o.getOption("disableInput"))return e.Pass;for(var a=o.listSelections(),l=[],s=0;s\s*$/.test(p),x=!/>\s*$/.test(p);(v||x)&&o.replaceRange("",{line:u.line,ch:0},{line:u.line,ch:u.ch+1}),l[s]="\n"}else{var y=m[1],b=m[5],D=!(i.test(m[2])||m[2].indexOf(">")>=0),C=D?parseInt(m[3],10)+1+m[4]:m[2].replace("x"," ");l[s]="\n"+y+C+b,D&&r(o,u)}}o.replaceSelections(l)}})("object"==typeof n&&"object"==typeof t?e("../../lib/codemirror"):CodeMirror)},{"../../lib/codemirror":10}],7:[function(e,t,n){(function(e){"use strict";e.overlayMode=function(t,n,i){return{startState:function(){return{base:e.startState(t),overlay:e.startState(n),basePos:0,baseCur:null,overlayPos:0,overlayCur:null,streamSeen:null}},copyState:function(i){return{base:e.copyState(t,i.base),overlay:e.copyState(n,i.overlay),basePos:i.basePos,baseCur:null,overlayPos:i.overlayPos,overlayCur:null}},token:function(e,r){return(e!=r.streamSeen||Math.min(r.basePos,r.overlayPos)c);d++){var h=e.getLine(u++);l=null==l?h:l+"\n"+h}s*=2,t.lastIndex=n.ch;var f=t.exec(l);if(f){var p=l.slice(0,f.index).split("\n"),m=f[0].split("\n"),g=n.line+p.length-1,v=p[p.length-1].length;return{from:i(g,v),to:i(g+m.length-1,1==m.length?v+m[0].length:m[m.length-1].length),match:f}}}}function s(e,t,n){for(var i,r=0;r<=e.length;){t.lastIndex=r;var o=t.exec(e);if(!o)break;var a=o.index+o[0].length;if(a>e.length-n)break;(!i||a>i.index+i[0].length)&&(i=o),r=o.index+1}return i}function u(e,t,n){t=r(t,"g");for(var o=n.line,a=n.ch,l=e.firstLine();o>=l;o--,a=-1){var u=e.getLine(o),c=s(u,t,a<0?0:u.length-a);if(c)return{from:i(o,c.index),to:i(o,c.index+c[0].length),match:c}}}function c(e,t,n){if(!o(t))return u(e,t,n);t=r(t,"gm");for(var a,l=1,c=e.getLine(n.line).length-n.ch,d=n.line,h=e.firstLine();d>=h;){for(var f=0;f=h;f++){var p=e.getLine(d--);a=null==a?p:p+"\n"+a}l*=2;var m=s(a,t,c);if(m){var g=a.slice(0,m.index).split("\n"),v=m[0].split("\n"),x=d+g.length,y=g[g.length-1].length;return{from:i(x,y),to:i(x+v.length-1,1==v.length?y+v[0].length:v[v.length-1].length),match:m}}}}function d(e,t,n,i){if(e.length==t.length)return n;for(var r=0,o=n+Math.max(0,e.length-t.length);;){if(r==o)return r;var a=r+o>>1,l=i(e.slice(0,a)).length;if(l==n)return a;l>n?o=a:r=a+1}}function h(e,r,o,a){if(!r.length)return null;var l=a?t:n,s=l(r).split(/\r|\n\r?/);e:for(var u=o.line,c=o.ch,h=e.lastLine()+1-s.length;u<=h;u++,c=0){var f=e.getLine(u).slice(c),p=l(f);if(1==s.length){var m=p.indexOf(s[0]);if(-1==m)continue e;return o=d(f,p,m,l)+c,{from:i(u,d(f,p,m,l)+c),to:i(u,d(f,p,m+s[0].length,l)+c)}}var g=p.length-s[0].length;if(p.slice(g)==s[0]){for(var v=1;v=h;u--,c=-1){var f=e.getLine(u);c>-1&&(f=f.slice(0,c));var p=l(f);if(1==s.length){var m=p.lastIndexOf(s[0]);if(-1==m)continue e;return{from:i(u,d(f,p,m,l)),to:i(u,d(f,p,m+s[0].length,l))}}var g=s[s.length-1];if(p.slice(0,g.length)==g){var v=1;for(o=u-s.length+1;v(this.doc.getLine(n.line)||"").length&&(n.ch=0,n.line++)),0!=e.cmpPos(n,this.doc.clipPos(n))))return this.atOccurrence=!1;var r=this.matches(t,n);if(this.afterEmptyMatch=r&&0==e.cmpPos(r.from,r.to),r)return this.pos=r,this.atOccurrence=!0,this.pos.match||!0;var o=i(t?this.doc.firstLine():this.doc.lastLine()+1,0);return this.pos={from:o,to:o},this.atOccurrence=!1},from:function(){if(this.atOccurrence)return this.pos.from},to:function(){if(this.atOccurrence)return this.pos.to},replace:function(t,n){if(this.atOccurrence){var r=e.splitLines(t);this.doc.replaceRange(r,this.pos.from,this.pos.to,n),this.pos.to=i(this.pos.from.line+r.length-1,r[r.length-1].length+(1==r.length?this.pos.from.ch:0))}}},e.defineExtension("getSearchCursor",(function(e,t,n){return new p(this.doc,e,t,n)})),e.defineDocExtension("getSearchCursor",(function(e,t,n){return new p(this,e,t,n)})),e.defineExtension("selectMatches",(function(t,n){for(var i=[],r=this.getSearchCursor(t,this.getCursor("from"),n);r.findNext()&&!(e.cmpPos(r.to(),this.getCursor("to"))>0);)i.push({anchor:r.from(),head:r.to()});i.length&&this.setSelections(i,0)}))})("object"==typeof n&&"object"==typeof t?e("../../lib/codemirror"):CodeMirror)},{"../../lib/codemirror":10}],9:[function(e,t,n){(function(e){"use strict";function t(e){e.state.markedSelection&&e.operation((function(){!function(e){if(!e.somethingSelected())return a(e);if(e.listSelections().length>1)return l(e);var t=e.getCursor("start"),n=e.getCursor("end"),i=e.state.markedSelection;if(!i.length)return o(e,t,n);var s=i[0].find(),u=i[i.length-1].find();if(!s||!u||n.line-t.line<=8||r(t,u.to)>=0||r(n,s.from)<=0)return l(e);for(;r(t,s.from)>0;)i.shift().clear(),s=i[0].find();for(r(t,s.from)<0&&(s.to.line-t.line<8?(i.shift().clear(),o(e,t,s.to,0)):o(e,t,s.from,0));r(n,u.to)<0;)i.pop().clear(),u=i[i.length-1].find();r(n,u.to)>0&&(n.line-u.from.line<8?(i.pop().clear(),o(e,u.from,n)):o(e,u.to,n))}(e)}))}function n(e){e.state.markedSelection&&e.state.markedSelection.length&&e.operation((function(){a(e)}))}e.defineOption("styleSelectedText",!1,(function(i,r,o){var s=o&&o!=e.Init;r&&!s?(i.state.markedSelection=[],i.state.markedSelectionStyle="string"==typeof r?r:"CodeMirror-selectedtext",l(i),i.on("cursorActivity",t),i.on("change",n)):!r&&s&&(i.off("cursorActivity",t),i.off("change",n),a(i),i.state.markedSelection=i.state.markedSelectionStyle=null)}));var i=e.Pos,r=e.cmpPos;function o(e,t,n,o){if(0!=r(t,n))for(var a=e.state.markedSelection,l=e.state.markedSelectionStyle,s=t.line;;){var u=s==t.line?t:i(s,0),c=s+8,d=c>=n.line,h=d?n:i(c,0),f=e.markText(u,h,{className:l});if(null==o?a.push(f):a.splice(o++,0,f),d)break;s=c}}function a(e){for(var t=e.state.markedSelection,n=0;n2),v=/Android/.test(e),x=g||v||/webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(e),y=g||/Mac/.test(t),b=/\bCrOS\b/.test(e),D=/win/i.test(t),C=h&&e.match(/Version\/(\d*\.\d*)/);C&&(C=Number(C[1])),C&&C>=15&&(h=!1,s=!0);var w=y&&(u||h&&(null==C||C<12.11)),k=n||a&&l>=9;function S(e){return new RegExp("(^|\\s)"+e+"(?:$|\\s)\\s*")}var F,A=function(e,t){var n=e.className,i=S(t).exec(n);if(i){var r=n.slice(i.index+i[0].length);e.className=n.slice(0,i.index)+(r?i[1]+r:"")}};function E(e){for(var t=e.childNodes.length;t>0;--t)e.removeChild(e.firstChild);return e}function L(e,t){return E(e).appendChild(t)}function T(e,t,n,i){var r=document.createElement(e);if(n&&(r.className=n),i&&(r.style.cssText=i),"string"==typeof t)r.appendChild(document.createTextNode(t));else if(t)for(var o=0;o=t)return a+(t-o);a+=l-o,a+=n-a%n,o=l+1}}g?z=function(e){e.selectionStart=0,e.selectionEnd=e.value.length}:a&&(z=function(e){try{e.select()}catch(e){}});var j=function(){this.id=null,this.f=null,this.time=0,this.handler=P(this.onTimeout,this)};function q(e,t){for(var n=0;n=t)return i+Math.min(a,t-r);if(r+=o-i,i=o+1,(r+=n-r%n)>=t)return i}}var K=[""];function Z(e){for(;K.length<=e;)K.push(Y(K)+" ");return K[e]}function Y(e){return e[e.length-1]}function Q(e,t){for(var n=[],i=0;i""&&(e.toUpperCase()!=e.toLowerCase()||te.test(e))}function ie(e,t){return t?!!(t.source.indexOf("\\w")>-1&&ne(e))||t.test(e):ne(e)}function re(e){for(var t in e)if(e.hasOwnProperty(t)&&e[t])return!1;return!0}var oe=/[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0ac1-\u0ac5\u0ac7\u0ac8\u0acd\u0ae2\u0ae3\u0b01\u0b3c\u0b3e\u0b3f\u0b41-\u0b44\u0b4d\u0b56\u0b57\u0b62\u0b63\u0b82\u0bbe\u0bc0\u0bcd\u0bd7\u0c3e-\u0c40\u0c46-\u0c48\u0c4a-\u0c4d\u0c55\u0c56\u0c62\u0c63\u0cbc\u0cbf\u0cc2\u0cc6\u0ccc\u0ccd\u0cd5\u0cd6\u0ce2\u0ce3\u0d3e\u0d41-\u0d44\u0d4d\u0d57\u0d62\u0d63\u0dca\u0dcf\u0dd2-\u0dd4\u0dd6\u0ddf\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0eb1\u0eb4-\u0eb9\u0ebb\u0ebc\u0ec8-\u0ecd\u0f18\u0f19\u0f35\u0f37\u0f39\u0f71-\u0f7e\u0f80-\u0f84\u0f86\u0f87\u0f90-\u0f97\u0f99-\u0fbc\u0fc6\u102d-\u1030\u1032-\u1037\u1039\u103a\u103d\u103e\u1058\u1059\u105e-\u1060\u1071-\u1074\u1082\u1085\u1086\u108d\u109d\u135f\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17b7-\u17bd\u17c6\u17c9-\u17d3\u17dd\u180b-\u180d\u18a9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193b\u1a17\u1a18\u1a56\u1a58-\u1a5e\u1a60\u1a62\u1a65-\u1a6c\u1a73-\u1a7c\u1a7f\u1b00-\u1b03\u1b34\u1b36-\u1b3a\u1b3c\u1b42\u1b6b-\u1b73\u1b80\u1b81\u1ba2-\u1ba5\u1ba8\u1ba9\u1c2c-\u1c33\u1c36\u1c37\u1cd0-\u1cd2\u1cd4-\u1ce0\u1ce2-\u1ce8\u1ced\u1dc0-\u1de6\u1dfd-\u1dff\u200c\u200d\u20d0-\u20f0\u2cef-\u2cf1\u2de0-\u2dff\u302a-\u302f\u3099\u309a\ua66f-\ua672\ua67c\ua67d\ua6f0\ua6f1\ua802\ua806\ua80b\ua825\ua826\ua8c4\ua8e0-\ua8f1\ua926-\ua92d\ua947-\ua951\ua980-\ua982\ua9b3\ua9b6-\ua9b9\ua9bc\uaa29-\uaa2e\uaa31\uaa32\uaa35\uaa36\uaa43\uaa4c\uaab0\uaab2-\uaab4\uaab7\uaab8\uaabe\uaabf\uaac1\uabe5\uabe8\uabed\udc00-\udfff\ufb1e\ufe00-\ufe0f\ufe20-\ufe26\uff9e\uff9f]/;function ae(e){return e.charCodeAt(0)>=768&&oe.test(e)}function le(e,t,n){for(;(n<0?t>0:tn?-1:1;;){if(t==n)return t;var r=(t+n)/2,o=i<0?Math.ceil(r):Math.floor(r);if(o==t)return e(o)?t:n;e(o)?n=o:t=o+i}}var ue=null;function ce(e,t,n){var i;ue=null;for(var r=0;rt)return r;o.to==t&&(o.from!=o.to&&"before"==n?i=r:ue=r),o.from==t&&(o.from!=o.to&&"before"!=n?i=r:ue=r)}return null!=i?i:ue}var de=function(){var e=/[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/,t=/[stwN]/,n=/[LRr]/,i=/[Lb1n]/,r=/[1n]/;function o(e,t,n){this.level=e,this.from=t,this.to=n}return function(a,l){var s="ltr"==l?"L":"R";if(0==a.length||"ltr"==l&&!e.test(a))return!1;for(var u,c=a.length,d=[],h=0;h-1&&(i[t]=r.slice(0,o).concat(r.slice(o+1)))}}}function ve(e,t){var n=me(e,t);if(n.length)for(var i=Array.prototype.slice.call(arguments,2),r=0;r0}function De(e){e.prototype.on=function(e,t){pe(this,e,t)},e.prototype.off=function(e,t){ge(this,e,t)}}function Ce(e){e.preventDefault?e.preventDefault():e.returnValue=!1}function we(e){e.stopPropagation?e.stopPropagation():e.cancelBubble=!0}function ke(e){return null!=e.defaultPrevented?e.defaultPrevented:0==e.returnValue}function Se(e){Ce(e),we(e)}function Fe(e){return e.target||e.srcElement}function Ae(e){var t=e.which;return null==t&&(1&e.button?t=1:2&e.button?t=3:4&e.button&&(t=2)),y&&e.ctrlKey&&1==t&&(t=3),t}var Ee,Le,Te=function(){if(a&&l<9)return!1;var e=T("div");return"draggable"in e||"dragDrop"in e}();function Me(e){if(null==Ee){var t=T("span","");L(e,T("span",[t,document.createTextNode("x")])),0!=e.firstChild.offsetHeight&&(Ee=t.offsetWidth<=1&&t.offsetHeight>2&&!(a&&l<8))}var n=Ee?T("span",""):T("span"," ",null,"display: inline-block; width: 1px; margin-right: -1px");return n.setAttribute("cm-text",""),n}function Be(e){if(null!=Le)return Le;var t=L(e,document.createTextNode("AخA")),n=F(t,0,1).getBoundingClientRect(),i=F(t,1,2).getBoundingClientRect();return E(e),!(!n||n.left==n.right)&&(Le=i.right-n.right<3)}var Ne,Oe=3!="\n\nb".split(/\n/).length?function(e){for(var t=0,n=[],i=e.length;t<=i;){var r=e.indexOf("\n",t);-1==r&&(r=e.length);var o=e.slice(t,"\r"==e.charAt(r-1)?r-1:r),a=o.indexOf("\r");-1!=a?(n.push(o.slice(0,a)),t+=a+1):(n.push(o),t=r+1)}return n}:function(e){return e.split(/\r\n?|\n/)},Ie=window.getSelection?function(e){try{return e.selectionStart!=e.selectionEnd}catch(e){return!1}}:function(e){var t;try{t=e.ownerDocument.selection.createRange()}catch(e){}return!(!t||t.parentElement()!=e)&&0!=t.compareEndPoints("StartToEnd",t)},ze="oncopy"in(Ne=T("div"))||(Ne.setAttribute("oncopy","return;"),"function"==typeof Ne.oncopy),He=null;var Re={},Pe={};function _e(e,t){arguments.length>2&&(t.dependencies=Array.prototype.slice.call(arguments,2)),Re[e]=t}function We(e){if("string"==typeof e&&Pe.hasOwnProperty(e))e=Pe[e];else if(e&&"string"==typeof e.name&&Pe.hasOwnProperty(e.name)){var t=Pe[e.name];"string"==typeof t&&(t={name:t}),(e=ee(t,e)).name=t.name}else{if("string"==typeof e&&/^[\w\-]+\/[\w\-]+\+xml$/.test(e))return We("application/xml");if("string"==typeof e&&/^[\w\-]+\/[\w\-]+\+json$/.test(e))return We("application/json")}return"string"==typeof e?{name:e}:e||{name:"null"}}function je(e,t){t=We(t);var n=Re[t.name];if(!n)return je(e,"text/plain");var i=n(e,t);if(qe.hasOwnProperty(t.name)){var r=qe[t.name];for(var o in r)r.hasOwnProperty(o)&&(i.hasOwnProperty(o)&&(i["_"+o]=i[o]),i[o]=r[o])}if(i.name=t.name,t.helperType&&(i.helperType=t.helperType),t.modeProps)for(var a in t.modeProps)i[a]=t.modeProps[a];return i}var qe={};function Ue(e,t){_(t,qe.hasOwnProperty(e)?qe[e]:qe[e]={})}function $e(e,t){if(!0===t)return t;if(e.copyState)return e.copyState(t);var n={};for(var i in t){var r=t[i];r instanceof Array&&(r=r.concat([])),n[i]=r}return n}function Ge(e,t){for(var n;e.innerMode&&(n=e.innerMode(t))&&n.mode!=e;)t=n.state,e=n.mode;return n||{mode:e,state:t}}function Ve(e,t,n){return!e.startState||e.startState(t,n)}var Xe=function(e,t,n){this.pos=this.start=0,this.string=e,this.tabSize=t||8,this.lastColumnPos=this.lastColumnValue=0,this.lineStart=0,this.lineOracle=n};function Ke(e,t){if((t-=e.first)<0||t>=e.size)throw new Error("There is no line "+(t+e.first)+" in the document.");for(var n=e;!n.lines;)for(var i=0;;++i){var r=n.children[i],o=r.chunkSize();if(t=e.first&&tn?it(n,Ke(e,n).text.length):function(e,t){var n=e.ch;return null==n||n>t?it(e.line,t):n<0?it(e.line,0):e}(t,Ke(e,t.line).text.length)}function dt(e,t){for(var n=[],i=0;i=this.string.length},Xe.prototype.sol=function(){return this.pos==this.lineStart},Xe.prototype.peek=function(){return this.string.charAt(this.pos)||void 0},Xe.prototype.next=function(){if(this.post},Xe.prototype.eatSpace=function(){for(var e=this.pos;/[\s\u00a0]/.test(this.string.charAt(this.pos));)++this.pos;return this.pos>e},Xe.prototype.skipToEnd=function(){this.pos=this.string.length},Xe.prototype.skipTo=function(e){var t=this.string.indexOf(e,this.pos);if(t>-1)return this.pos=t,!0},Xe.prototype.backUp=function(e){this.pos-=e},Xe.prototype.column=function(){return this.lastColumnPos0?null:(i&&!1!==t&&(this.pos+=i[0].length),i)}var r=function(e){return n?e.toLowerCase():e};if(r(this.string.substr(this.pos,e.length))==r(e))return!1!==t&&(this.pos+=e.length),!0},Xe.prototype.current=function(){return this.string.slice(this.start,this.pos)},Xe.prototype.hideFirstChars=function(e,t){this.lineStart+=e;try{return t()}finally{this.lineStart-=e}},Xe.prototype.lookAhead=function(e){var t=this.lineOracle;return t&&t.lookAhead(e)},Xe.prototype.baseToken=function(){var e=this.lineOracle;return e&&e.baseToken(this.pos)};var ht=function(e,t){this.state=e,this.lookAhead=t},ft=function(e,t,n,i){this.state=t,this.doc=e,this.line=n,this.maxLookAhead=i||0,this.baseTokens=null,this.baseTokenPos=1};function pt(e,t,n,i){var r=[e.state.modeGen],o={};wt(e,t.text,e.doc.mode,n,(function(e,t){return r.push(e,t)}),o,i);for(var a=n.state,l=function(i){n.baseTokens=r;var l=e.state.overlays[i],s=1,u=0;n.state=!0,wt(e,t.text,l.mode,n,(function(e,t){for(var n=s;ue&&r.splice(s,1,e,r[s+1],i),s+=2,u=Math.min(e,i)}if(t)if(l.opaque)r.splice(n,s-n,e,"overlay "+t),s=n+2;else for(;ne.options.maxHighlightLength&&$e(e.doc.mode,i.state),o=pt(e,t,i);r&&(i.state=r),t.stateAfter=i.save(!r),t.styles=o.styles,o.classes?t.styleClasses=o.classes:t.styleClasses&&(t.styleClasses=null),n===e.doc.highlightFrontier&&(e.doc.modeFrontier=Math.max(e.doc.modeFrontier,++e.doc.highlightFrontier))}return t.styles}function gt(e,t,n){var i=e.doc,r=e.display;if(!i.mode.startState)return new ft(i,!0,t);var o=function(e,t,n){for(var i,r,o=e.doc,a=n?-1:t-(e.doc.mode.innerMode?1e3:100),l=t;l>a;--l){if(l<=o.first)return o.first;var s=Ke(o,l-1),u=s.stateAfter;if(u&&(!n||l+(u instanceof ht?u.lookAhead:0)<=o.modeFrontier))return l;var c=W(s.text,null,e.options.tabSize);(null==r||i>c)&&(r=l-1,i=c)}return r}(e,t,n),a=o>i.first&&Ke(i,o-1).stateAfter,l=a?ft.fromSaved(i,a,o):new ft(i,Ve(i.mode),o);return i.iter(o,t,(function(n){vt(e,n.text,l);var i=l.line;n.stateAfter=i==t-1||i%5==0||i>=r.viewFrom&&it.start)return o}throw new Error("Mode "+e.name+" failed to advance stream.")}ft.prototype.lookAhead=function(e){var t=this.doc.getLine(this.line+e);return null!=t&&e>this.maxLookAhead&&(this.maxLookAhead=e),t},ft.prototype.baseToken=function(e){if(!this.baseTokens)return null;for(;this.baseTokens[this.baseTokenPos]<=e;)this.baseTokenPos+=2;var t=this.baseTokens[this.baseTokenPos+1];return{type:t&&t.replace(/( |^)overlay .*/,""),size:this.baseTokens[this.baseTokenPos]-e}},ft.prototype.nextLine=function(){this.line++,this.maxLookAhead>0&&this.maxLookAhead--},ft.fromSaved=function(e,t,n){return t instanceof ht?new ft(e,$e(e.mode,t.state),n,t.lookAhead):new ft(e,$e(e.mode,t),n)},ft.prototype.save=function(e){var t=!1!==e?$e(this.doc.mode,this.state):this.state;return this.maxLookAhead>0?new ht(t,this.maxLookAhead):t};var bt=function(e,t,n){this.start=e.start,this.end=e.pos,this.string=e.current(),this.type=t||null,this.state=n};function Dt(e,t,n,i){var r,o,a=e.doc,l=a.mode,s=Ke(a,(t=ct(a,t)).line),u=gt(e,t.line,n),c=new Xe(s.text,e.options.tabSize,u);for(i&&(o=[]);(i||c.pose.options.maxHighlightLength?(l=!1,a&&vt(e,t,i,d.pos),d.pos=t.length,s=null):s=Ct(yt(n,d,i.state,h),o),h){var f=h[0].name;f&&(s="m-"+(s?f+" "+s:f))}if(!l||c!=s){for(;u=t:o.to>t);(i||(i=[])).push(new Ft(a,o.from,l?null:o.to))}}return i}(n,r,a),s=function(e,t,n){var i;if(e)for(var r=0;r=t:o.to>t)||o.from==t&&"bookmark"==a.type&&(!n||o.marker.insertLeft)){var l=null==o.from||(a.inclusiveLeft?o.from<=t:o.from0&&l)for(var y=0;yt)&&(!n||It(n,o.marker)<0)&&(n=o.marker)}return n}function _t(e,t,n,i,r){var o=Ke(e,t),a=St&&o.markedSpans;if(a)for(var l=0;l=0&&d<=0||c<=0&&d>=0)&&(c<=0&&(s.marker.inclusiveRight&&r.inclusiveLeft?rt(u.to,n)>=0:rt(u.to,n)>0)||c>=0&&(s.marker.inclusiveRight&&r.inclusiveLeft?rt(u.from,i)<=0:rt(u.from,i)<0)))return!0}}}function Wt(e){for(var t;t=Ht(e);)e=t.find(-1,!0).line;return e}function jt(e,t){var n=Ke(e,t),i=Wt(n);return n==i?t:Je(i)}function qt(e,t){if(t>e.lastLine())return t;var n,i=Ke(e,t);if(!Ut(e,i))return t;for(;n=Rt(i);)i=n.find(1,!0).line;return Je(i)+1}function Ut(e,t){var n=St&&t.markedSpans;if(n)for(var i=void 0,r=0;rt.maxLineLength&&(t.maxLineLength=n,t.maxLine=e)}))}var Kt=function(e,t,n){this.text=e,Bt(this,t),this.height=n?n(this):1};function Zt(e){e.parent=null,Mt(e)}Kt.prototype.lineNo=function(){return Je(this)},De(Kt);var Yt={},Qt={};function Jt(e,t){if(!e||/^\s*$/.test(e))return null;var n=t.addModeClass?Qt:Yt;return n[e]||(n[e]=e.replace(/\S+/g,"cm-$&"))}function en(e,t){var n=M("span",null,null,s?"padding-right: .1px":null),i={pre:M("pre",[n],"CodeMirror-line"),content:n,col:0,pos:0,cm:e,trailingSpace:!1,splitSpaces:e.getOption("lineWrapping")};t.measure={};for(var r=0;r<=(t.rest?t.rest.length:0);r++){var o=r?t.rest[r-1]:t.line,a=void 0;i.pos=0,i.addToken=nn,Be(e.display.measure)&&(a=he(o,e.doc.direction))&&(i.addToken=rn(i.addToken,a)),i.map=[],an(o,i,mt(e,o,t!=e.display.externalMeasured&&Je(o))),o.styleClasses&&(o.styleClasses.bgClass&&(i.bgClass=I(o.styleClasses.bgClass,i.bgClass||"")),o.styleClasses.textClass&&(i.textClass=I(o.styleClasses.textClass,i.textClass||""))),0==i.map.length&&i.map.push(0,0,i.content.appendChild(Me(e.display.measure))),0==r?(t.measure.map=i.map,t.measure.cache={}):((t.measure.maps||(t.measure.maps=[])).push(i.map),(t.measure.caches||(t.measure.caches=[])).push({}))}if(s){var l=i.content.lastChild;(/\bcm-tab\b/.test(l.className)||l.querySelector&&l.querySelector(".cm-tab"))&&(i.content.className="cm-tab-wrap-hack")}return ve(e,"renderLine",e,t.line,i.pre),i.pre.className&&(i.textClass=I(i.pre.className,i.textClass||"")),i}function tn(e){var t=T("span","•","cm-invalidchar");return t.title="\\u"+e.charCodeAt(0).toString(16),t.setAttribute("aria-label",t.title),t}function nn(e,t,n,i,r,o,s){if(t){var u,c=e.splitSpaces?function(e,t){if(e.length>1&&!/ /.test(e))return e;for(var n=t,i="",r=0;ru&&d.from<=u);h++);if(d.to>=c)return e(n,i,r,o,a,l,s);e(n,i.slice(0,d.to-u),r,o,null,l,s),o=null,i=i.slice(d.to-u),u=d.to}}}function on(e,t,n,i){var r=!i&&n.widgetNode;r&&e.map.push(e.pos,e.pos+t,r),!i&&e.cm.display.input.needsContentAttribute&&(r||(r=e.content.appendChild(document.createElement("span"))),r.setAttribute("cm-marker",n.id)),r&&(e.cm.display.input.setUneditable(r),e.content.appendChild(r)),e.pos+=t,e.trailingSpace=!1}function an(e,t,n){var i=e.markedSpans,r=e.text,o=0;if(i)for(var a,l,s,u,c,d,h,f=r.length,p=0,m=1,g="",v=0;;){if(v==p){s=u=c=l="",h=null,d=null,v=1/0;for(var x=[],y=void 0,b=0;bp||C.collapsed&&D.to==p&&D.from==p)){if(null!=D.to&&D.to!=p&&v>D.to&&(v=D.to,u=""),C.className&&(s+=" "+C.className),C.css&&(l=(l?l+";":"")+C.css),C.startStyle&&D.from==p&&(c+=" "+C.startStyle),C.endStyle&&D.to==v&&(y||(y=[])).push(C.endStyle,D.to),C.title&&((h||(h={})).title=C.title),C.attributes)for(var w in C.attributes)(h||(h={}))[w]=C.attributes[w];C.collapsed&&(!d||It(d.marker,C)<0)&&(d=D)}else D.from>p&&v>D.from&&(v=D.from)}if(y)for(var k=0;k=f)break;for(var F=Math.min(f,v);;){if(g){var A=p+g.length;if(!d){var E=A>F?g.slice(0,F-p):g;t.addToken(t,E,a?a+s:s,c,p+E.length==v?u:"",l,h)}if(A>=F){g=g.slice(F-p),p=F;break}p=A,c=""}g=r.slice(o,o=n[m++]),a=Jt(n[m++],t.cm.options)}}else for(var L=1;Ln)return{map:e.measure.maps[r],cache:e.measure.caches[r],before:!0}}}function Nn(e,t,n,i){return zn(e,In(e,t),n,i)}function On(e,t){if(t>=e.display.viewFrom&&t=n.lineN&&t2&&o.push((s.bottom+u.top)/2-n.top)}}o.push(n.bottom-n.top)}}(e,t.view,t.rect),t.hasHeights=!0),o=function(e,t,n,i){var r,o=Pn(t.map,n,i),s=o.node,u=o.start,c=o.end,d=o.collapse;if(3==s.nodeType){for(var h=0;h<4;h++){for(;u&&ae(t.line.text.charAt(o.coverStart+u));)--u;for(;o.coverStart+c1}(e))return t;var n=screen.logicalXDPI/screen.deviceXDPI,i=screen.logicalYDPI/screen.deviceYDPI;return{left:t.left*n,right:t.right*n,top:t.top*i,bottom:t.bottom*i}}(e.display.measure,r))}else{var f;u>0&&(d=i="right"),r=e.options.lineWrapping&&(f=s.getClientRects()).length>1?f["right"==i?f.length-1:0]:s.getBoundingClientRect()}if(a&&l<9&&!u&&(!r||!r.left&&!r.right)){var p=s.parentNode.getClientRects()[0];r=p?{left:p.left,right:p.left+li(e.display),top:p.top,bottom:p.bottom}:Rn}for(var m=r.top-t.rect.top,g=r.bottom-t.rect.top,v=(m+g)/2,x=t.view.measure.heights,y=0;yt)&&(r=(o=s-l)-1,t>=s&&(a="right")),null!=r){if(i=e[u+2],l==s&&n==(i.insertLeft?"left":"right")&&(a=n),"left"==n&&0==r)for(;u&&e[u-2]==e[u-3]&&e[u-1].insertLeft;)i=e[2+(u-=3)],a="left";if("right"==n&&r==s-l)for(;u=0&&(n=e[r]).left==n.right;r--);return n}function Wn(e){if(e.measure&&(e.measure.cache={},e.measure.heights=null,e.rest))for(var t=0;t=i.text.length?(s=i.text.length,u="before"):s<=0&&(s=0,u="after"),!l)return a("before"==u?s-1:s,"before"==u);function c(e,t,n){return a(n?e-1:e,1==l[t].level!=n)}var d=ce(l,s,u),h=ue,f=c(s,d,"before"==u);return null!=h&&(f.other=c(s,h,"before"!=u)),f}function Yn(e,t){var n=0;t=ct(e.doc,t),e.options.lineWrapping||(n=li(e.display)*t.ch);var i=Ke(e.doc,t.line),r=Gt(i)+Fn(e.display);return{left:n,right:n,top:r,bottom:r+i.height}}function Qn(e,t,n,i,r){var o=it(e,t,n);return o.xRel=r,i&&(o.outside=i),o}function Jn(e,t,n){var i=e.doc;if((n+=e.display.viewOffset)<0)return Qn(i.first,0,null,-1,-1);var r=et(i,n),o=i.first+i.size-1;if(r>o)return Qn(i.first+i.size-1,Ke(i,o).text.length,null,1,1);t<0&&(t=0);for(var a=Ke(i,r);;){var l=ii(e,a,r,t,n),s=Pt(a,l.ch+(l.xRel>0||l.outside>0?1:0));if(!s)return l;var u=s.find(1);if(u.line==r)return u;a=Ke(i,r=u.line)}}function ei(e,t,n,i){i-=Gn(t);var r=t.text.length,o=se((function(t){return zn(e,n,t-1).bottom<=i}),r,0);return{begin:o,end:r=se((function(t){return zn(e,n,t).top>i}),o,r)}}function ti(e,t,n,i){return n||(n=In(e,t)),ei(e,t,n,Vn(e,t,zn(e,n,i),"line").top)}function ni(e,t,n,i){return!(e.bottom<=n)&&(e.top>n||(i?e.left:e.right)>t)}function ii(e,t,n,i,r){r-=Gt(t);var o=In(e,t),a=Gn(t),l=0,s=t.text.length,u=!0,c=he(t,e.doc.direction);if(c){var d=(e.options.lineWrapping?oi:ri)(e,t,n,o,c,i,r);l=(u=1!=d.level)?d.from:d.to-1,s=u?d.to:d.from-1}var h,f,p=null,m=null,g=se((function(t){var n=zn(e,o,t);return n.top+=a,n.bottom+=a,!!ni(n,i,r,!1)&&(n.top<=r&&n.left<=i&&(p=t,m=n),!0)}),l,s),v=!1;if(m){var x=i-m.left=b.bottom?1:0}return Qn(n,g=le(t.text,g,1),f,v,i-h)}function ri(e,t,n,i,r,o,a){var l=se((function(l){var s=r[l],u=1!=s.level;return ni(Zn(e,it(n,u?s.to:s.from,u?"before":"after"),"line",t,i),o,a,!0)}),0,r.length-1),s=r[l];if(l>0){var u=1!=s.level,c=Zn(e,it(n,u?s.from:s.to,u?"after":"before"),"line",t,i);ni(c,o,a,!0)&&c.top>a&&(s=r[l-1])}return s}function oi(e,t,n,i,r,o,a){var l=ei(e,t,i,a),s=l.begin,u=l.end;/\s/.test(t.text.charAt(u-1))&&u--;for(var c=null,d=null,h=0;h=u||f.to<=s)){var p=zn(e,i,1!=f.level?Math.min(u,f.to)-1:Math.max(s,f.from)).right,m=pm)&&(c=f,d=m)}}return c||(c=r[r.length-1]),c.fromu&&(c={from:c.from,to:u,level:c.level}),c}function ai(e){if(null!=e.cachedTextHeight)return e.cachedTextHeight;if(null==Hn){Hn=T("pre",null,"CodeMirror-line-like");for(var t=0;t<49;++t)Hn.appendChild(document.createTextNode("x")),Hn.appendChild(T("br"));Hn.appendChild(document.createTextNode("x"))}L(e.measure,Hn);var n=Hn.offsetHeight/50;return n>3&&(e.cachedTextHeight=n),E(e.measure),n||1}function li(e){if(null!=e.cachedCharWidth)return e.cachedCharWidth;var t=T("span","xxxxxxxxxx"),n=T("pre",[t],"CodeMirror-line-like");L(e.measure,n);var i=t.getBoundingClientRect(),r=(i.right-i.left)/10;return r>2&&(e.cachedCharWidth=r),r||10}function si(e){for(var t=e.display,n={},i={},r=t.gutters.clientLeft,o=t.gutters.firstChild,a=0;o;o=o.nextSibling,++a){var l=e.display.gutterSpecs[a].className;n[l]=o.offsetLeft+o.clientLeft+r,i[l]=o.clientWidth}return{fixedPos:ui(t),gutterTotalWidth:t.gutters.offsetWidth,gutterLeft:n,gutterWidth:i,wrapperWidth:t.wrapper.clientWidth}}function ui(e){return e.scroller.getBoundingClientRect().left-e.sizer.getBoundingClientRect().left}function ci(e){var t=ai(e.display),n=e.options.lineWrapping,i=n&&Math.max(5,e.display.scroller.clientWidth/li(e.display)-3);return function(r){if(Ut(e.doc,r))return 0;var o=0;if(r.widgets)for(var a=0;a0&&(s=Ke(e.doc,u.line).text).length==u.ch){var c=W(s,s.length,e.options.tabSize)-s.length;u=it(u.line,Math.max(0,Math.round((o-En(e.display).left)/li(e.display))-c))}return u}function fi(e,t){if(t>=e.display.viewTo)return null;if((t-=e.display.viewFrom)<0)return null;for(var n=e.display.view,i=0;it)&&(r.updateLineNumbers=t),e.curOp.viewChanged=!0,t>=r.viewTo)St&&jt(e.doc,t)r.viewFrom?gi(e):(r.viewFrom+=i,r.viewTo+=i);else if(t<=r.viewFrom&&n>=r.viewTo)gi(e);else if(t<=r.viewFrom){var o=vi(e,n,n+i,1);o?(r.view=r.view.slice(o.index),r.viewFrom=o.lineN,r.viewTo+=i):gi(e)}else if(n>=r.viewTo){var a=vi(e,t,t,-1);a?(r.view=r.view.slice(0,a.index),r.viewTo=a.lineN):gi(e)}else{var l=vi(e,t,t,-1),s=vi(e,n,n+i,1);l&&s?(r.view=r.view.slice(0,l.index).concat(sn(e,l.lineN,s.lineN)).concat(r.view.slice(s.index)),r.viewTo+=i):gi(e)}var u=r.externalMeasured;u&&(n=r.lineN&&t=i.viewTo)){var o=i.view[fi(e,t)];if(null!=o.node){var a=o.changes||(o.changes=[]);-1==q(a,n)&&a.push(n)}}}function gi(e){e.display.viewFrom=e.display.viewTo=e.doc.first,e.display.view=[],e.display.viewOffset=0}function vi(e,t,n,i){var r,o=fi(e,t),a=e.display.view;if(!St||n==e.doc.first+e.doc.size)return{index:o,lineN:n};for(var l=e.display.viewFrom,s=0;s0){if(o==a.length-1)return null;r=l+a[o].size-t,o++}else r=l-t;t+=r,n+=r}for(;jt(e.doc,n)!=n;){if(o==(i<0?0:a.length-1))return null;n+=i*a[o-(i<0?1:0)].size,o+=i}return{index:o,lineN:n}}function xi(e){for(var t=e.display.view,n=0,i=0;i=e.display.viewTo||s.to().line0?a:e.defaultCharWidth())+"px"}if(i.other){var l=n.appendChild(T("div"," ","CodeMirror-cursor CodeMirror-secondarycursor"));l.style.display="",l.style.left=i.other.left+"px",l.style.top=i.other.top+"px",l.style.height=.85*(i.other.bottom-i.other.top)+"px"}}function Ci(e,t){return e.top-t.top||e.left-t.left}function wi(e,t,n){var i=e.display,r=e.doc,o=document.createDocumentFragment(),a=En(e.display),l=a.left,s=Math.max(i.sizerWidth,Tn(e)-i.sizer.offsetLeft)-a.right,u="ltr"==r.direction;function c(e,t,n,i){t<0&&(t=0),t=Math.round(t),i=Math.round(i),o.appendChild(T("div",null,"CodeMirror-selected","position: absolute; left: "+e+"px;\n top: "+t+"px; width: "+(null==n?s-e:n)+"px;\n height: "+(i-t)+"px"))}function d(t,n,i){var o,a,d=Ke(r,t),h=d.text.length;function f(n,i){return Kn(e,it(t,n),"div",d,i)}function p(t,n,i){var r=ti(e,d,null,t),o="ltr"==n==("after"==i)?"left":"right";return f("after"==i?r.begin:r.end-(/\s/.test(d.text.charAt(r.end-1))?2:1),o)[o]}var m=he(d,r.direction);return function(e,t,n,i){if(!e)return i(t,n,"ltr",0);for(var r=!1,o=0;ot||t==n&&a.to==t)&&(i(Math.max(a.from,t),Math.min(a.to,n),1==a.level?"rtl":"ltr",o),r=!0)}r||i(t,n,"ltr")}(m,n||0,null==i?h:i,(function(e,t,r,d){var g="ltr"==r,v=f(e,g?"left":"right"),x=f(t-1,g?"right":"left"),y=null==n&&0==e,b=null==i&&t==h,D=0==d,C=!m||d==m.length-1;if(x.top-v.top<=3){var w=(u?b:y)&&C,k=(u?y:b)&&D?l:(g?v:x).left,S=w?s:(g?x:v).right;c(k,v.top,S-k,v.bottom)}else{var F,A,E,L;g?(F=u&&y&&D?l:v.left,A=u?s:p(e,r,"before"),E=u?l:p(t,r,"after"),L=u&&b&&C?s:x.right):(F=u?p(e,r,"before"):l,A=!u&&y&&D?s:v.right,E=!u&&b&&C?l:x.left,L=u?p(t,r,"after"):s),c(F,v.top,A-F,v.bottom),v.bottom0?t.blinker=setInterval((function(){e.hasFocus()||Ei(e),t.cursorDiv.style.visibility=(n=!n)?"":"hidden"}),e.options.cursorBlinkRate):e.options.cursorBlinkRate<0&&(t.cursorDiv.style.visibility="hidden")}}function Si(e){e.hasFocus()||(e.display.input.focus(),e.state.focused||Ai(e))}function Fi(e){e.state.delayingBlurEvent=!0,setTimeout((function(){e.state.delayingBlurEvent&&(e.state.delayingBlurEvent=!1,e.state.focused&&Ei(e))}),100)}function Ai(e,t){e.state.delayingBlurEvent&&!e.state.draggingText&&(e.state.delayingBlurEvent=!1),"nocursor"!=e.options.readOnly&&(e.state.focused||(ve(e,"focus",e,t),e.state.focused=!0,O(e.display.wrapper,"CodeMirror-focused"),e.curOp||e.display.selForContextMenu==e.doc.sel||(e.display.input.reset(),s&&setTimeout((function(){return e.display.input.reset(!0)}),20)),e.display.input.receivedFocus()),ki(e))}function Ei(e,t){e.state.delayingBlurEvent||(e.state.focused&&(ve(e,"blur",e,t),e.state.focused=!1,A(e.display.wrapper,"CodeMirror-focused")),clearInterval(e.display.blinker),setTimeout((function(){e.state.focused||(e.display.shift=!1)}),150))}function Li(e){for(var t=e.display,n=t.lineDiv.offsetTop,i=Math.max(0,t.scroller.getBoundingClientRect().top),r=t.lineDiv.getBoundingClientRect().top,o=0,s=0;s.005||m<-.005)&&(re.display.sizerWidth){var v=Math.ceil(h/li(e.display));v>e.display.maxLineLength&&(e.display.maxLineLength=v,e.display.maxLine=u.line,e.display.maxLineChanged=!0)}}}Math.abs(o)>2&&(t.scroller.scrollTop+=o)}function Ti(e){if(e.widgets)for(var t=0;t=a&&(o=et(t,Gt(Ke(t,s))-e.wrapper.clientHeight),a=s)}return{from:o,to:Math.max(a,o+1)}}function Bi(e,t){var n=e.display,i=ai(e.display);t.top<0&&(t.top=0);var r=e.curOp&&null!=e.curOp.scrollTop?e.curOp.scrollTop:n.scroller.scrollTop,o=Mn(e),a={};t.bottom-t.top>o&&(t.bottom=t.top+o);var l=e.doc.height+An(n),s=t.topl-i;if(t.topr+o){var c=Math.min(t.top,(u?l:t.bottom)-o);c!=r&&(a.scrollTop=c)}var d=e.options.fixedGutter?0:n.gutters.offsetWidth,h=e.curOp&&null!=e.curOp.scrollLeft?e.curOp.scrollLeft:n.scroller.scrollLeft-d,f=Tn(e)-n.gutters.offsetWidth,p=t.right-t.left>f;return p&&(t.right=t.left+f),t.left<10?a.scrollLeft=0:t.leftf+h-3&&(a.scrollLeft=t.right+(p?0:10)-f),a}function Ni(e,t){null!=t&&(zi(e),e.curOp.scrollTop=(null==e.curOp.scrollTop?e.doc.scrollTop:e.curOp.scrollTop)+t)}function Oi(e){zi(e);var t=e.getCursor();e.curOp.scrollToPos={from:t,to:t,margin:e.options.cursorScrollMargin}}function Ii(e,t,n){null==t&&null==n||zi(e),null!=t&&(e.curOp.scrollLeft=t),null!=n&&(e.curOp.scrollTop=n)}function zi(e){var t=e.curOp.scrollToPos;t&&(e.curOp.scrollToPos=null,Hi(e,Yn(e,t.from),Yn(e,t.to),t.margin))}function Hi(e,t,n,i){var r=Bi(e,{left:Math.min(t.left,n.left),top:Math.min(t.top,n.top)-i,right:Math.max(t.right,n.right),bottom:Math.max(t.bottom,n.bottom)+i});Ii(e,r.scrollLeft,r.scrollTop)}function Ri(e,t){Math.abs(e.doc.scrollTop-t)<2||(n||hr(e,{top:t}),Pi(e,t,!0),n&&hr(e),ar(e,100))}function Pi(e,t,n){t=Math.max(0,Math.min(e.display.scroller.scrollHeight-e.display.scroller.clientHeight,t)),(e.display.scroller.scrollTop!=t||n)&&(e.doc.scrollTop=t,e.display.scrollbars.setScrollTop(t),e.display.scroller.scrollTop!=t&&(e.display.scroller.scrollTop=t))}function _i(e,t,n,i){t=Math.max(0,Math.min(t,e.display.scroller.scrollWidth-e.display.scroller.clientWidth)),(n?t==e.doc.scrollLeft:Math.abs(e.doc.scrollLeft-t)<2)&&!i||(e.doc.scrollLeft=t,mr(e),e.display.scroller.scrollLeft!=t&&(e.display.scroller.scrollLeft=t),e.display.scrollbars.setScrollLeft(t))}function Wi(e){var t=e.display,n=t.gutters.offsetWidth,i=Math.round(e.doc.height+An(e.display));return{clientHeight:t.scroller.clientHeight,viewHeight:t.wrapper.clientHeight,scrollWidth:t.scroller.scrollWidth,clientWidth:t.scroller.clientWidth,viewWidth:t.wrapper.clientWidth,barLeft:e.options.fixedGutter?n:0,docHeight:i,scrollHeight:i+Ln(e)+t.barHeight,nativeBarWidth:t.nativeBarWidth,gutterWidth:n}}var ji=function(e,t,n){this.cm=n;var i=this.vert=T("div",[T("div",null,null,"min-width: 1px")],"CodeMirror-vscrollbar"),r=this.horiz=T("div",[T("div",null,null,"height: 100%; min-height: 1px")],"CodeMirror-hscrollbar");i.tabIndex=r.tabIndex=-1,e(i),e(r),pe(i,"scroll",(function(){i.clientHeight&&t(i.scrollTop,"vertical")})),pe(r,"scroll",(function(){r.clientWidth&&t(r.scrollLeft,"horizontal")})),this.checkedZeroWidth=!1,a&&l<8&&(this.horiz.style.minHeight=this.vert.style.minWidth="18px")};ji.prototype.update=function(e){var t=e.scrollWidth>e.clientWidth+1,n=e.scrollHeight>e.clientHeight+1,i=e.nativeBarWidth;if(n){this.vert.style.display="block",this.vert.style.bottom=t?i+"px":"0";var r=e.viewHeight-(t?i:0);this.vert.firstChild.style.height=Math.max(0,e.scrollHeight-e.clientHeight+r)+"px"}else this.vert.scrollTop=0,this.vert.style.display="",this.vert.firstChild.style.height="0";if(t){this.horiz.style.display="block",this.horiz.style.right=n?i+"px":"0",this.horiz.style.left=e.barLeft+"px";var o=e.viewWidth-e.barLeft-(n?i:0);this.horiz.firstChild.style.width=Math.max(0,e.scrollWidth-e.clientWidth+o)+"px"}else this.horiz.style.display="",this.horiz.firstChild.style.width="0";return!this.checkedZeroWidth&&e.clientHeight>0&&(0==i&&this.zeroWidthHack(),this.checkedZeroWidth=!0),{right:n?i:0,bottom:t?i:0}},ji.prototype.setScrollLeft=function(e){this.horiz.scrollLeft!=e&&(this.horiz.scrollLeft=e),this.disableHoriz&&this.enableZeroWidthBar(this.horiz,this.disableHoriz,"horiz")},ji.prototype.setScrollTop=function(e){this.vert.scrollTop!=e&&(this.vert.scrollTop=e),this.disableVert&&this.enableZeroWidthBar(this.vert,this.disableVert,"vert")},ji.prototype.zeroWidthHack=function(){var e=y&&!p?"12px":"18px";this.horiz.style.height=this.vert.style.width=e,this.horiz.style.visibility=this.vert.style.visibility="hidden",this.disableHoriz=new j,this.disableVert=new j},ji.prototype.enableZeroWidthBar=function(e,t,n){e.style.visibility="",t.set(1e3,(function i(){var r=e.getBoundingClientRect();("vert"==n?document.elementFromPoint(r.right-1,(r.top+r.bottom)/2):document.elementFromPoint((r.right+r.left)/2,r.bottom-1))!=e?e.style.visibility="hidden":t.set(1e3,i)}))},ji.prototype.clear=function(){var e=this.horiz.parentNode;e.removeChild(this.horiz),e.removeChild(this.vert)};var qi=function(){};function Ui(e,t){t||(t=Wi(e));var n=e.display.barWidth,i=e.display.barHeight;$i(e,t);for(var r=0;r<4&&n!=e.display.barWidth||i!=e.display.barHeight;r++)n!=e.display.barWidth&&e.options.lineWrapping&&Li(e),$i(e,Wi(e)),n=e.display.barWidth,i=e.display.barHeight}function $i(e,t){var n=e.display,i=n.scrollbars.update(t);n.sizer.style.paddingRight=(n.barWidth=i.right)+"px",n.sizer.style.paddingBottom=(n.barHeight=i.bottom)+"px",n.heightForcer.style.borderBottom=i.bottom+"px solid transparent",i.right&&i.bottom?(n.scrollbarFiller.style.display="block",n.scrollbarFiller.style.height=i.bottom+"px",n.scrollbarFiller.style.width=i.right+"px"):n.scrollbarFiller.style.display="",i.bottom&&e.options.coverGutterNextToScrollbar&&e.options.fixedGutter?(n.gutterFiller.style.display="block",n.gutterFiller.style.height=i.bottom+"px",n.gutterFiller.style.width=t.gutterWidth+"px"):n.gutterFiller.style.display=""}qi.prototype.update=function(){return{bottom:0,right:0}},qi.prototype.setScrollLeft=function(){},qi.prototype.setScrollTop=function(){},qi.prototype.clear=function(){};var Gi={native:ji,null:qi};function Vi(e){e.display.scrollbars&&(e.display.scrollbars.clear(),e.display.scrollbars.addClass&&A(e.display.wrapper,e.display.scrollbars.addClass)),e.display.scrollbars=new Gi[e.options.scrollbarStyle]((function(t){e.display.wrapper.insertBefore(t,e.display.scrollbarFiller),pe(t,"mousedown",(function(){e.state.focused&&setTimeout((function(){return e.display.input.focus()}),0)})),t.setAttribute("cm-not-content","true")}),(function(t,n){"horizontal"==n?_i(e,t):Ri(e,t)}),e),e.display.scrollbars.addClass&&O(e.display.wrapper,e.display.scrollbars.addClass)}var Xi=0;function Ki(e){var t;e.curOp={cm:e,viewChanged:!1,startHeight:e.doc.height,forceUpdate:!1,updateInput:0,typing:!1,changeObjs:null,cursorActivityHandlers:null,cursorActivityCalled:0,selectionChanged:!1,updateMaxLine:!1,scrollLeft:null,scrollTop:null,scrollToPos:null,focus:!1,id:++Xi,markArrays:null},t=e.curOp,un?un.ops.push(t):t.ownsGroup=un={ops:[t],delayedCallbacks:[]}}function Zi(e){var t=e.curOp;t&&function(e,t){var n=e.ownsGroup;if(n)try{!function(e){var t=e.delayedCallbacks,n=0;do{for(;n=n.viewTo)||n.maxLineChanged&&t.options.lineWrapping,e.update=e.mustUpdate&&new sr(t,e.mustUpdate&&{top:e.scrollTop,ensure:e.scrollToPos},e.forceUpdate)}function Qi(e){e.updatedDisplay=e.mustUpdate&&cr(e.cm,e.update)}function Ji(e){var t=e.cm,n=t.display;e.updatedDisplay&&Li(t),e.barMeasure=Wi(t),n.maxLineChanged&&!t.options.lineWrapping&&(e.adjustWidthTo=Nn(t,n.maxLine,n.maxLine.text.length).left+3,t.display.sizerWidth=e.adjustWidthTo,e.barMeasure.scrollWidth=Math.max(n.scroller.clientWidth,n.sizer.offsetLeft+e.adjustWidthTo+Ln(t)+t.display.barWidth),e.maxScrollLeft=Math.max(0,n.sizer.offsetLeft+e.adjustWidthTo-Tn(t))),(e.updatedDisplay||e.selectionChanged)&&(e.preparedSelection=n.input.prepareSelection())}function er(e){var t=e.cm;null!=e.adjustWidthTo&&(t.display.sizer.style.minWidth=e.adjustWidthTo+"px",e.maxScrollLeft1&&(a=!0)),null!=u.scrollLeft&&(_i(e,u.scrollLeft),Math.abs(e.doc.scrollLeft-d)>1&&(a=!0)),!a)break}return r}(t,ct(i,e.scrollToPos.from),ct(i,e.scrollToPos.to),e.scrollToPos.margin);!function(e,t){if(!xe(e,"scrollCursorIntoView")){var n=e.display,i=n.sizer.getBoundingClientRect(),r=null,o=n.wrapper.ownerDocument;if(t.top+i.top<0?r=!0:t.bottom+i.top>(o.defaultView.innerHeight||o.documentElement.clientHeight)&&(r=!1),null!=r&&!m){var a=T("div","",null,"position: absolute;\n top: "+(t.top-n.viewOffset-Fn(e.display))+"px;\n height: "+(t.bottom-t.top+Ln(e)+n.barHeight)+"px;\n left: "+t.left+"px; width: "+Math.max(2,t.right-t.left)+"px;");e.display.lineSpace.appendChild(a),a.scrollIntoView(r),e.display.lineSpace.removeChild(a)}}}(t,r)}var o=e.maybeHiddenMarkers,a=e.maybeUnhiddenMarkers;if(o)for(var l=0;l=e.display.viewTo)){var n=+new Date+e.options.workTime,i=gt(e,t.highlightFrontier),r=[];t.iter(i.line,Math.min(t.first+t.size,e.display.viewTo+500),(function(o){if(i.line>=e.display.viewFrom){var a=o.styles,l=o.text.length>e.options.maxHighlightLength?$e(t.mode,i.state):null,s=pt(e,o,i,!0);l&&(i.state=l),o.styles=s.styles;var u=o.styleClasses,c=s.classes;c?o.styleClasses=c:u&&(o.styleClasses=null);for(var d=!a||a.length!=o.styles.length||u!=c&&(!u||!c||u.bgClass!=c.bgClass||u.textClass!=c.textClass),h=0;!d&&hn)return ar(e,e.options.workDelay),!0})),t.highlightFrontier=i.line,t.modeFrontier=Math.max(t.modeFrontier,i.line),r.length&&nr(e,(function(){for(var t=0;t=n.viewFrom&&t.visible.to<=n.viewTo&&(null==n.updateLineNumbers||n.updateLineNumbers>=n.viewTo)&&n.renderedView==n.view&&0==xi(e))return!1;gr(e)&&(gi(e),t.dims=si(e));var r=i.first+i.size,o=Math.max(t.visible.from-e.options.viewportMargin,i.first),a=Math.min(r,t.visible.to+e.options.viewportMargin);n.viewFroma&&n.viewTo-a<20&&(a=Math.min(r,n.viewTo)),St&&(o=jt(e.doc,o),a=qt(e.doc,a));var l=o!=n.viewFrom||a!=n.viewTo||n.lastWrapHeight!=t.wrapperHeight||n.lastWrapWidth!=t.wrapperWidth;!function(e,t,n){var i=e.display;0==i.view.length||t>=i.viewTo||n<=i.viewFrom?(i.view=sn(e,t,n),i.viewFrom=t):(i.viewFrom>t?i.view=sn(e,t,i.viewFrom).concat(i.view):i.viewFromn&&(i.view=i.view.slice(0,fi(e,n)))),i.viewTo=n}(e,o,a),n.viewOffset=Gt(Ke(e.doc,n.viewFrom)),e.display.mover.style.top=n.viewOffset+"px";var u=xi(e);if(!l&&0==u&&!t.force&&n.renderedView==n.view&&(null==n.updateLineNumbers||n.updateLineNumbers>=n.viewTo))return!1;var c=ur(e);return u>4&&(n.lineDiv.style.display="none"),function(e,t,n){var i=e.display,r=e.options.lineNumbers,o=i.lineDiv,a=o.firstChild;function l(t){var n=t.nextSibling;return s&&y&&e.display.currentWheelTarget==t?t.style.display="none":t.parentNode.removeChild(t),n}for(var u=i.view,c=i.viewFrom,d=0;d-1&&(f=!1),fn(e,h,c,n)),f&&(E(h.lineNumber),h.lineNumber.appendChild(document.createTextNode(nt(e.options,c)))),a=h.node.nextSibling}else{var p=bn(e,h,c,n);o.insertBefore(p,a)}c+=h.size}for(;a;)a=l(a)}(e,n.updateLineNumbers,t.dims),u>4&&(n.lineDiv.style.display=""),n.renderedView=n.view,function(e){if(e&&e.activeElt&&e.activeElt!=N(e.activeElt.ownerDocument)&&(e.activeElt.focus(),!/^(INPUT|TEXTAREA)$/.test(e.activeElt.nodeName)&&e.anchorNode&&B(document.body,e.anchorNode)&&B(document.body,e.focusNode))){var t=e.activeElt.ownerDocument,n=t.defaultView.getSelection(),i=t.createRange();i.setEnd(e.anchorNode,e.anchorOffset),i.collapse(!1),n.removeAllRanges(),n.addRange(i),n.extend(e.focusNode,e.focusOffset)}}(c),E(n.cursorDiv),E(n.selectionDiv),n.gutters.style.height=n.sizer.style.minHeight=0,l&&(n.lastWrapHeight=t.wrapperHeight,n.lastWrapWidth=t.wrapperWidth,ar(e,400)),n.updateLineNumbers=null,!0}function dr(e,t){for(var n=t.viewport,i=!0;;i=!1){if(i&&e.options.lineWrapping&&t.oldDisplayWidth!=Tn(e))i&&(t.visible=Mi(e.display,e.doc,n));else if(n&&null!=n.top&&(n={top:Math.min(e.doc.height+An(e.display)-Mn(e),n.top)}),t.visible=Mi(e.display,e.doc,n),t.visible.from>=e.display.viewFrom&&t.visible.to<=e.display.viewTo)break;if(!cr(e,t))break;Li(e);var r=Wi(e);yi(e),Ui(e,r),pr(e,r),t.force=!1}t.signal(e,"update",e),e.display.viewFrom==e.display.reportedViewFrom&&e.display.viewTo==e.display.reportedViewTo||(t.signal(e,"viewportChange",e,e.display.viewFrom,e.display.viewTo),e.display.reportedViewFrom=e.display.viewFrom,e.display.reportedViewTo=e.display.viewTo)}function hr(e,t){var n=new sr(e,t);if(cr(e,n)){Li(e),dr(e,n);var i=Wi(e);yi(e),Ui(e,i),pr(e,i),n.finish()}}function fr(e){var t=e.gutters.offsetWidth;e.sizer.style.marginLeft=t+"px",dn(e,"gutterChanged",e)}function pr(e,t){e.display.sizer.style.minHeight=t.docHeight+"px",e.display.heightForcer.style.top=t.docHeight+"px",e.display.gutters.style.height=t.docHeight+e.display.barHeight+Ln(e)+"px"}function mr(e){var t=e.display,n=t.view;if(t.alignWidgets||t.gutters.firstChild&&e.options.fixedGutter){for(var i=ui(t)-t.scroller.scrollLeft+e.doc.scrollLeft,r=t.gutters.offsetWidth,o=i+"px",a=0;a=105&&(o.wrapper.style.clipPath="inset(0px)"),o.wrapper.setAttribute("translate","no"),a&&l<8&&(o.gutters.style.zIndex=-1,o.scroller.style.paddingRight=0),s||n&&x||(o.scroller.draggable=!0),e&&(e.appendChild?e.appendChild(o.wrapper):e(o.wrapper)),o.viewFrom=o.viewTo=t.first,o.reportedViewFrom=o.reportedViewTo=t.first,o.view=[],o.renderedView=null,o.externalMeasured=null,o.viewOffset=0,o.lastWrapHeight=o.lastWrapWidth=0,o.updateLineNumbers=null,o.nativeBarWidth=o.barHeight=o.barWidth=0,o.scrollbarsClipped=!1,o.lineNumWidth=o.lineNumInnerWidth=o.lineNumChars=null,o.alignWidgets=!1,o.cachedCharWidth=o.cachedTextHeight=o.cachedPaddingH=null,o.maxLine=null,o.maxLineLength=0,o.maxLineChanged=!1,o.wheelDX=o.wheelDY=o.wheelStartX=o.wheelStartY=null,o.shift=!1,o.selForContextMenu=null,o.activeTouch=null,o.gutterSpecs=vr(r.gutters,r.lineNumbers),xr(o),i.init(o)}sr.prototype.signal=function(e,t){be(e,t)&&this.events.push(arguments)},sr.prototype.finish=function(){for(var e=0;eu.clientWidth,p=u.scrollHeight>u.clientHeight;if(r&&f||o&&p){if(o&&y&&s)e:for(var m=t.target,g=l.view;m!=u;m=m.parentNode)for(var v=0;v=0&&rt(e,i.to())<=0)return n}return-1};var Ar=function(e,t){this.anchor=e,this.head=t};function Er(e,t,n){var i=e&&e.options.selectionsMayTouch,r=t[n];t.sort((function(e,t){return rt(e.from(),t.from())})),n=q(t,r);for(var o=1;o0:s>=0){var u=st(l.from(),a.from()),c=lt(l.to(),a.to()),d=l.empty()?a.from()==a.head:l.from()==l.head;o<=n&&--n,t.splice(--o,2,new Ar(d?c:u,d?u:c))}}return new Fr(t,n)}function Lr(e,t){return new Fr([new Ar(e,t||e)],0)}function Tr(e){return e.text?it(e.from.line+e.text.length-1,Y(e.text).length+(1==e.text.length?e.from.ch:0)):e.to}function Mr(e,t){if(rt(e,t.from)<0)return e;if(rt(e,t.to)<=0)return Tr(t);var n=e.line+t.text.length-(t.to.line-t.from.line)-1,i=e.ch;return e.line==t.to.line&&(i+=Tr(t).ch-t.to.ch),it(n,i)}function Br(e,t){for(var n=[],i=0;i1&&e.remove(l.line+1,p-1),e.insert(l.line+1,v)}dn(e,"change",e,t)}function Rr(e,t,n){!function e(i,r,o){if(i.linked)for(var a=0;al-(e.cm?e.cm.options.historyEventDelay:500)||"*"==t.origin.charAt(0)))&&(o=function(e,t){return t?(qr(e.done),Y(e.done)):e.done.length&&!Y(e.done).ranges?Y(e.done):e.done.length>1&&!e.done[e.done.length-2].ranges?(e.done.pop(),Y(e.done)):void 0}(r,r.lastOp==i)))a=Y(o.changes),0==rt(t.from,t.to)&&0==rt(t.from,a.to)?a.to=Tr(t):o.changes.push(jr(e,t));else{var s=Y(r.done);for(s&&s.ranges||Gr(e.sel,r.done),o={changes:[jr(e,t)],generation:r.generation},r.done.push(o);r.done.length>r.undoDepth;)r.done.shift(),r.done[0].ranges||r.done.shift()}r.done.push(n),r.generation=++r.maxGeneration,r.lastModTime=r.lastSelTime=l,r.lastOp=r.lastSelOp=i,r.lastOrigin=r.lastSelOrigin=t.origin,a||ve(e,"historyAdded")}function $r(e,t,n,i){var r=e.history,o=i&&i.origin;n==r.lastSelOp||o&&r.lastSelOrigin==o&&(r.lastModTime==r.lastSelTime&&r.lastOrigin==o||function(e,t,n,i){var r=t.charAt(0);return"*"==r||"+"==r&&n.ranges.length==i.ranges.length&&n.somethingSelected()==i.somethingSelected()&&new Date-e.history.lastSelTime<=(e.cm?e.cm.options.historyEventDelay:500)}(e,o,Y(r.done),t))?r.done[r.done.length-1]=t:Gr(t,r.done),r.lastSelTime=+new Date,r.lastSelOrigin=o,r.lastSelOp=n,i&&!1!==i.clearRedo&&qr(r.undone)}function Gr(e,t){var n=Y(t);n&&n.ranges&&n.equals(e)||t.push(e)}function Vr(e,t,n,i){var r=t["spans_"+e.id],o=0;e.iter(Math.max(e.first,n),Math.min(e.first+e.size,i),(function(n){n.markedSpans&&((r||(r=t["spans_"+e.id]={}))[o]=n.markedSpans),++o}))}function Xr(e){if(!e)return null;for(var t,n=0;n-1&&(Y(l)[d]=u[d],delete u[d])}}}return i}function Yr(e,t,n,i){if(i){var r=e.anchor;if(n){var o=rt(t,r)<0;o!=rt(n,r)<0?(r=t,t=n):o!=rt(t,n)<0&&(t=n)}return new Ar(r,t)}return new Ar(n||t,t)}function Qr(e,t,n,i,r){null==r&&(r=e.cm&&(e.cm.display.shift||e.extend)),io(e,new Fr([Yr(e.sel.primary(),t,n,r)],0),i)}function Jr(e,t,n){for(var i=[],r=e.cm&&(e.cm.display.shift||e.extend),o=0;o=t.ch:l.to>t.ch))){if(r&&(ve(s,"beforeCursorEnter"),s.explicitlyCleared)){if(o.markedSpans){--a;continue}break}if(!s.atomic)continue;if(n){var d=s.find(i<0?1:-1),h=void 0;if((i<0?c:u)&&(d=co(e,d,-i,d&&d.line==t.line?o:null)),d&&d.line==t.line&&(h=rt(d,n))&&(i<0?h<0:h>0))return so(e,d,t,i,r)}var f=s.find(i<0?-1:1);return(i<0?u:c)&&(f=co(e,f,i,f.line==t.line?o:null)),f?so(e,f,t,i,r):null}}return t}function uo(e,t,n,i,r){var o=i||1,a=so(e,t,n,o,r)||!r&&so(e,t,n,o,!0)||so(e,t,n,-o,r)||!r&&so(e,t,n,-o,!0);return a||(e.cantEdit=!0,it(e.first,0))}function co(e,t,n,i){return n<0&&0==t.ch?t.line>e.first?ct(e,it(t.line-1)):null:n>0&&t.ch==(i||Ke(e,t.line)).text.length?t.line0)){var c=[s,1],d=rt(u.from,l.from),h=rt(u.to,l.to);(d<0||!a.inclusiveLeft&&!d)&&c.push({from:u.from,to:l.from}),(h>0||!a.inclusiveRight&&!h)&&c.push({from:l.to,to:u.to}),r.splice.apply(r,c),s+=c.length-3}}return r}(e,t.from,t.to);if(i)for(var r=i.length-1;r>=0;--r)mo(e,{from:i[r].from,to:i[r].to,text:r?[""]:t.text,origin:t.origin});else mo(e,t)}}function mo(e,t){if(1!=t.text.length||""!=t.text[0]||0!=rt(t.from,t.to)){var n=Br(e,t);Ur(e,t,n,e.cm?e.cm.curOp.id:NaN),xo(e,t,n,Lt(e,t));var i=[];Rr(e,(function(e,n){n||-1!=q(i,e.history)||(Co(e.history,t),i.push(e.history)),xo(e,t,null,Lt(e,t))}))}}function go(e,t,n){var i=e.cm&&e.cm.state.suppressEdits;if(!i||n){for(var r,o=e.history,a=e.sel,l="undo"==t?o.done:o.undone,s="undo"==t?o.undone:o.done,u=0;u=0;--f){var p=h(f);if(p)return p.v}}}}function vo(e,t){if(0!=t&&(e.first+=t,e.sel=new Fr(Q(e.sel.ranges,(function(e){return new Ar(it(e.anchor.line+t,e.anchor.ch),it(e.head.line+t,e.head.ch))})),e.sel.primIndex),e.cm)){pi(e.cm,e.first,e.first-t,t);for(var n=e.cm.display,i=n.viewFrom;ie.lastLine())){if(t.from.lineo&&(t={from:t.from,to:it(o,Ke(e,o).text.length),text:[t.text[0]],origin:t.origin}),t.removed=Ze(e,t.from,t.to),n||(n=Br(e,t)),e.cm?function(e,t,n){var i=e.doc,r=e.display,o=t.from,a=t.to,l=!1,s=o.line;e.options.lineWrapping||(s=Je(Wt(Ke(i,o.line))),i.iter(s,a.line+1,(function(e){if(e==r.maxLine)return l=!0,!0})));i.sel.contains(t.from,t.to)>-1&&ye(e);Hr(i,t,n,ci(e)),e.options.lineWrapping||(i.iter(s,o.line+t.text.length,(function(e){var t=Vt(e);t>r.maxLineLength&&(r.maxLine=e,r.maxLineLength=t,r.maxLineChanged=!0,l=!1)})),l&&(e.curOp.updateMaxLine=!0));(function(e,t){if(e.modeFrontier=Math.min(e.modeFrontier,t),!(e.highlightFrontiern;i--){var r=Ke(e,i).stateAfter;if(r&&(!(r instanceof ht)||i+r.lookAhead1||!(this.children[0]instanceof ko))){var l=[];this.collapse(l),this.children=[new ko(l)],this.children[0].parent=this}},collapse:function(e){for(var t=0;t50){for(var a=r.lines.length%25+25,l=a;l10);e.parent.maybeSpill()}},iterN:function(e,t,n){for(var i=0;i0||0==a&&!1!==o.clearWhenEmpty)return o;if(o.replacedWith&&(o.collapsed=!0,o.widgetNode=M("span",[o.replacedWith],"CodeMirror-widget"),i.handleMouseEvents||o.widgetNode.setAttribute("cm-ignore-events","true"),i.insertLeft&&(o.widgetNode.insertLeft=!0)),o.collapsed){if(_t(e,t.line,t,n,o)||t.line!=n.line&&_t(e,n.line,t,n,o))throw new Error("Inserting collapsed marker partially overlapping an existing one");St=!0}o.addToHistory&&Ur(e,{from:t,to:n,origin:"markText"},e.sel,NaN);var l,s=t.line,u=e.cm;if(e.iter(s,n.line+1,(function(i){u&&o.collapsed&&!u.options.lineWrapping&&Wt(i)==u.display.maxLine&&(l=!0),o.collapsed&&s!=t.line&&Qe(i,0),function(e,t,n){var i=n&&window.WeakSet&&(n.markedSpans||(n.markedSpans=new WeakSet));i&&e.markedSpans&&i.has(e.markedSpans)?e.markedSpans.push(t):(e.markedSpans=e.markedSpans?e.markedSpans.concat([t]):[t],i&&i.add(e.markedSpans)),t.marker.attachLine(e)}(i,new Ft(o,s==t.line?t.ch:null,s==n.line?n.ch:null),e.cm&&e.cm.curOp),++s})),o.collapsed&&e.iter(t.line,n.line+1,(function(t){Ut(e,t)&&Qe(t,0)})),o.clearOnEnter&&pe(o,"beforeCursorEnter",(function(){return o.clear()})),o.readOnly&&(kt=!0,(e.history.done.length||e.history.undone.length)&&e.clearHistory()),o.collapsed&&(o.id=++Eo,o.atomic=!0),u){if(l&&(u.curOp.updateMaxLine=!0),o.collapsed)pi(u,t.line,n.line+1);else if(o.className||o.startStyle||o.endStyle||o.css||o.attributes||o.title)for(var c=t.line;c<=n.line;c++)mi(u,c,"text");o.atomic&&ao(u.doc),dn(u,"markerAdded",u,o)}return o}Lo.prototype.clear=function(){if(!this.explicitlyCleared){var e=this.doc.cm,t=e&&!e.curOp;if(t&&Ki(e),be(this,"clear")){var n=this.find();n&&dn(this,"clear",n.from,n.to)}for(var i=null,r=null,o=0;oe.display.maxLineLength&&(e.display.maxLine=u,e.display.maxLineLength=c,e.display.maxLineChanged=!0)}null!=i&&e&&this.collapsed&&pi(e,i,r+1),this.lines.length=0,this.explicitlyCleared=!0,this.atomic&&this.doc.cantEdit&&(this.doc.cantEdit=!1,e&&ao(e.doc)),e&&dn(e,"markerCleared",e,this,i,r),t&&Zi(e),this.parent&&this.parent.clear()}},Lo.prototype.find=function(e,t){var n,i;null==e&&"bookmark"==this.type&&(e=1);for(var r=0;r=0;s--)po(this,i[s]);l?no(this,l):this.cm&&Oi(this.cm)})),undo:or((function(){go(this,"undo")})),redo:or((function(){go(this,"redo")})),undoSelection:or((function(){go(this,"undo",!0)})),redoSelection:or((function(){go(this,"redo",!0)})),setExtending:function(e){this.extend=e},getExtending:function(){return this.extend},historySize:function(){for(var e=this.history,t=0,n=0,i=0;i=e.ch)&&t.push(r.marker.parent||r.marker)}return t},findMarks:function(e,t,n){e=ct(this,e),t=ct(this,t);var i=[],r=e.line;return this.iter(e.line,t.line+1,(function(o){var a=o.markedSpans;if(a)for(var l=0;l=s.to||null==s.from&&r!=e.line||null!=s.from&&r==t.line&&s.from>=t.ch||n&&!n(s.marker)||i.push(s.marker.parent||s.marker)}++r})),i},getAllMarks:function(){var e=[];return this.iter((function(t){var n=t.markedSpans;if(n)for(var i=0;ie)return t=e,!0;e-=o,++n})),ct(this,it(n,t))},indexFromPos:function(e){var t=(e=ct(this,e)).ch;if(e.linet&&(t=e.from),null!=e.to&&e.to-1)return t.state.draggingText(e),void setTimeout((function(){return t.display.input.focus()}),20);try{var d=e.dataTransfer.getData("Text");if(d){var h;if(t.state.draggingText&&!t.state.draggingText.copy&&(h=t.listSelections()),ro(t.doc,Lr(n,n)),h)for(var f=0;f=0;t--)yo(e.doc,"",i[t].from,i[t].to,"+delete");Oi(e)}))}function na(e,t,n){var i=le(e.text,t+n,n);return i<0||i>e.text.length?null:i}function ia(e,t,n){var i=na(e,t.ch,n);return null==i?null:new it(t.line,i,n<0?"after":"before")}function ra(e,t,n,i,r){if(e){"rtl"==t.doc.direction&&(r=-r);var o=he(n,t.doc.direction);if(o){var a,l=r<0?Y(o):o[0],s=r<0==(1==l.level)?"after":"before";if(l.level>0||"rtl"==t.doc.direction){var u=In(t,n);a=r<0?n.text.length-1:0;var c=zn(t,u,a).top;a=se((function(e){return zn(t,u,e).top==c}),r<0==(1==l.level)?l.from:l.to-1,a),"before"==s&&(a=na(n,a,1))}else a=r<0?l.to:l.from;return new it(i,a,s)}}return new it(i,r<0?n.text.length:0,r<0?"before":"after")}Vo.basic={Left:"goCharLeft",Right:"goCharRight",Up:"goLineUp",Down:"goLineDown",End:"goLineEnd",Home:"goLineStartSmart",PageUp:"goPageUp",PageDown:"goPageDown",Delete:"delCharAfter",Backspace:"delCharBefore","Shift-Backspace":"delCharBefore",Tab:"defaultTab","Shift-Tab":"indentAuto",Enter:"newlineAndIndent",Insert:"toggleOverwrite",Esc:"singleSelection"},Vo.pcDefault={"Ctrl-A":"selectAll","Ctrl-D":"deleteLine","Ctrl-Z":"undo","Shift-Ctrl-Z":"redo","Ctrl-Y":"redo","Ctrl-Home":"goDocStart","Ctrl-End":"goDocEnd","Ctrl-Up":"goLineUp","Ctrl-Down":"goLineDown","Ctrl-Left":"goGroupLeft","Ctrl-Right":"goGroupRight","Alt-Left":"goLineStart","Alt-Right":"goLineEnd","Ctrl-Backspace":"delGroupBefore","Ctrl-Delete":"delGroupAfter","Ctrl-S":"save","Ctrl-F":"find","Ctrl-G":"findNext","Shift-Ctrl-G":"findPrev","Shift-Ctrl-F":"replace","Shift-Ctrl-R":"replaceAll","Ctrl-[":"indentLess","Ctrl-]":"indentMore","Ctrl-U":"undoSelection","Shift-Ctrl-U":"redoSelection","Alt-U":"redoSelection",fallthrough:"basic"},Vo.emacsy={"Ctrl-F":"goCharRight","Ctrl-B":"goCharLeft","Ctrl-P":"goLineUp","Ctrl-N":"goLineDown","Ctrl-A":"goLineStart","Ctrl-E":"goLineEnd","Ctrl-V":"goPageDown","Shift-Ctrl-V":"goPageUp","Ctrl-D":"delCharAfter","Ctrl-H":"delCharBefore","Alt-Backspace":"delWordBefore","Ctrl-K":"killLine","Ctrl-T":"transposeChars","Ctrl-O":"openLine"},Vo.macDefault={"Cmd-A":"selectAll","Cmd-D":"deleteLine","Cmd-Z":"undo","Shift-Cmd-Z":"redo","Cmd-Y":"redo","Cmd-Home":"goDocStart","Cmd-Up":"goDocStart","Cmd-End":"goDocEnd","Cmd-Down":"goDocEnd","Alt-Left":"goGroupLeft","Alt-Right":"goGroupRight","Cmd-Left":"goLineLeft","Cmd-Right":"goLineRight","Alt-Backspace":"delGroupBefore","Ctrl-Alt-Backspace":"delGroupAfter","Alt-Delete":"delGroupAfter","Cmd-S":"save","Cmd-F":"find","Cmd-G":"findNext","Shift-Cmd-G":"findPrev","Cmd-Alt-F":"replace","Shift-Cmd-Alt-F":"replaceAll","Cmd-[":"indentLess","Cmd-]":"indentMore","Cmd-Backspace":"delWrappedLineLeft","Cmd-Delete":"delWrappedLineRight","Cmd-U":"undoSelection","Shift-Cmd-U":"redoSelection","Ctrl-Up":"goDocStart","Ctrl-Down":"goDocEnd",fallthrough:["basic","emacsy"]},Vo.default=y?Vo.macDefault:Vo.pcDefault;var oa={selectAll:ho,singleSelection:function(e){return e.setSelection(e.getCursor("anchor"),e.getCursor("head"),$)},killLine:function(e){return ta(e,(function(t){if(t.empty()){var n=Ke(e.doc,t.head.line).text.length;return t.head.ch==n&&t.head.line0)r=new it(r.line,r.ch+1),e.replaceRange(o.charAt(r.ch-1)+o.charAt(r.ch-2),it(r.line,r.ch-2),r,"+transpose");else if(r.line>e.doc.first){var a=Ke(e.doc,r.line-1).text;a&&(r=new it(r.line,1),e.replaceRange(o.charAt(0)+e.doc.lineSeparator()+a.charAt(a.length-1),it(r.line-1,a.length-1),r,"+transpose"))}n.push(new Ar(r,r))}e.setSelections(n)}))},newlineAndIndent:function(e){return nr(e,(function(){for(var t=e.listSelections(),n=t.length-1;n>=0;n--)e.replaceRange(e.doc.lineSeparator(),t[n].anchor,t[n].head,"+input");t=e.listSelections();for(var i=0;i-1&&(rt((r=u.ranges[r]).from(),t)<0||t.xRel>0)&&(rt(r.to(),t)>0||t.xRel<0)?function(e,t,n,i){var r=e.display,o=!1,u=ir(e,(function(t){s&&(r.scroller.draggable=!1),e.state.draggingText=!1,e.state.delayingBlurEvent&&(e.hasFocus()?e.state.delayingBlurEvent=!1:Fi(e)),ge(r.wrapper.ownerDocument,"mouseup",u),ge(r.wrapper.ownerDocument,"mousemove",c),ge(r.scroller,"dragstart",d),ge(r.scroller,"drop",u),o||(Ce(t),i.addNew||Qr(e.doc,n,null,null,i.extend),s&&!f||a&&9==l?setTimeout((function(){r.wrapper.ownerDocument.body.focus({preventScroll:!0}),r.input.focus()}),20):r.input.focus())})),c=function(e){o=o||Math.abs(t.clientX-e.clientX)+Math.abs(t.clientY-e.clientY)>=10},d=function(){return o=!0};s&&(r.scroller.draggable=!0);e.state.draggingText=u,u.copy=!i.moveOnDrag,pe(r.wrapper.ownerDocument,"mouseup",u),pe(r.wrapper.ownerDocument,"mousemove",c),pe(r.scroller,"dragstart",d),pe(r.scroller,"drop",u),e.state.delayingBlurEvent=!0,setTimeout((function(){return r.input.focus()}),20),r.scroller.dragDrop&&r.scroller.dragDrop()}(e,i,t,o):function(e,t,n,i){a&&Fi(e);var r=e.display,o=e.doc;Ce(t);var l,s,u=o.sel,c=u.ranges;i.addNew&&!i.extend?(s=o.sel.contains(n),l=s>-1?c[s]:new Ar(n,n)):(l=o.sel.primary(),s=o.sel.primIndex);if("rectangle"==i.unit)i.addNew||(l=new Ar(n,n)),n=hi(e,t,!0,!0),s=-1;else{var d=Da(e,n,i.unit);l=i.extend?Yr(l,d.anchor,d.head,i.extend):d}i.addNew?-1==s?(s=c.length,io(o,Er(e,c.concat([l]),s),{scroll:!1,origin:"*mouse"})):c.length>1&&c[s].empty()&&"char"==i.unit&&!i.extend?(io(o,Er(e,c.slice(0,s).concat(c.slice(s+1)),0),{scroll:!1,origin:"*mouse"}),u=o.sel):eo(o,s,l,G):(s=0,io(o,new Fr([l],0),G),u=o.sel);var h=n;function f(t){if(0!=rt(h,t))if(h=t,"rectangle"==i.unit){for(var r=[],a=e.options.tabSize,c=W(Ke(o,n.line).text,n.ch,a),d=W(Ke(o,t.line).text,t.ch,a),f=Math.min(c,d),p=Math.max(c,d),m=Math.min(n.line,t.line),g=Math.min(e.lastLine(),Math.max(n.line,t.line));m<=g;m++){var v=Ke(o,m).text,x=X(v,f,a);f==p?r.push(new Ar(it(m,x),it(m,x))):v.length>x&&r.push(new Ar(it(m,x),it(m,X(v,p,a))))}r.length||r.push(new Ar(n,n)),io(o,Er(e,u.ranges.slice(0,s).concat(r),s),{origin:"*mouse",scroll:!1}),e.scrollIntoView(t)}else{var y,b=l,D=Da(e,t,i.unit),C=b.anchor;rt(D.anchor,C)>0?(y=D.head,C=st(b.from(),D.anchor)):(y=D.anchor,C=lt(b.to(),D.head));var w=u.ranges.slice(0);w[s]=function(e,t){var n=t.anchor,i=t.head,r=Ke(e.doc,n.line);if(0==rt(n,i)&&n.sticky==i.sticky)return t;var o=he(r);if(!o)return t;var a=ce(o,n.ch,n.sticky),l=o[a];if(l.from!=n.ch&&l.to!=n.ch)return t;var s,u=a+(l.from==n.ch==(1!=l.level)?0:1);if(0==u||u==o.length)return t;if(i.line!=n.line)s=(i.line-n.line)*("ltr"==e.doc.direction?1:-1)>0;else{var c=ce(o,i.ch,i.sticky),d=c-a||(i.ch-n.ch)*(1==l.level?-1:1);s=c==u-1||c==u?d<0:d>0}var h=o[u+(s?-1:0)],f=s==(1==h.level),p=f?h.from:h.to,m=f?"after":"before";return n.ch==p&&n.sticky==m?t:new Ar(new it(n.line,p,m),i)}(e,new Ar(ct(o,C),y)),io(o,Er(e,w,s),G)}}var p=r.wrapper.getBoundingClientRect(),m=0;function g(t){var n=++m,a=hi(e,t,!0,"rectangle"==i.unit);if(a)if(0!=rt(a,h)){e.curOp.focus=N(H(e)),f(a);var l=Mi(r,o);(a.line>=l.to||a.linep.bottom?20:0;s&&setTimeout(ir(e,(function(){m==n&&(r.scroller.scrollTop+=s,g(t))})),50)}}function v(t){e.state.selectingText=!1,m=1/0,t&&(Ce(t),r.input.focus()),ge(r.wrapper.ownerDocument,"mousemove",x),ge(r.wrapper.ownerDocument,"mouseup",y),o.history.lastSelOrigin=null}var x=ir(e,(function(e){0!==e.buttons&&Ae(e)?g(e):v(e)})),y=ir(e,v);e.state.selectingText=y,pe(r.wrapper.ownerDocument,"mousemove",x),pe(r.wrapper.ownerDocument,"mouseup",y)}(e,i,t,o)}(t,i,o,e):Fe(e)==n.scroller&&Ce(e):2==r?(i&&Qr(t.doc,i),setTimeout((function(){return n.input.focus()}),20)):3==r&&(k?t.display.input.onContextMenu(e):Fi(t)))}}function Da(e,t,n){if("char"==n)return new Ar(t,t);if("word"==n)return e.findWordAt(t);if("line"==n)return new Ar(it(t.line,0),ct(e.doc,it(t.line+1,0)));var i=n(e,t);return new Ar(i.from,i.to)}function Ca(e,t,n,i){var r,o;if(t.touches)r=t.touches[0].clientX,o=t.touches[0].clientY;else try{r=t.clientX,o=t.clientY}catch(e){return!1}if(r>=Math.floor(e.display.gutters.getBoundingClientRect().right))return!1;i&&Ce(t);var a=e.display,l=a.lineDiv.getBoundingClientRect();if(o>l.bottom||!be(e,n))return ke(t);o-=l.top-a.viewOffset;for(var s=0;s=r)return ve(e,n,e,et(e.doc,o),e.display.gutterSpecs[s].className,t),ke(t)}}function wa(e,t){return Ca(e,t,"gutterClick",!0)}function ka(e,t){Sn(e.display,t)||function(e,t){if(!be(e,"gutterContextMenu"))return!1;return Ca(e,t,"gutterContextMenu",!1)}(e,t)||xe(e,t,"contextmenu")||k||e.display.input.onContextMenu(t)}function Sa(e){e.display.wrapper.className=e.display.wrapper.className.replace(/\s*cm-s-\S+/g,"")+e.options.theme.replace(/(^|\s)\s*/g," cm-s-"),qn(e)}ya.prototype.compare=function(e,t,n){return this.time+400>e&&0==rt(t,this.pos)&&n==this.button};var Fa={toString:function(){return"CodeMirror.Init"}},Aa={},Ea={};function La(e,t,n){if(!t!=!(n&&n!=Fa)){var i=e.display.dragFunctions,r=t?pe:ge;r(e.display.scroller,"dragstart",i.start),r(e.display.scroller,"dragenter",i.enter),r(e.display.scroller,"dragover",i.over),r(e.display.scroller,"dragleave",i.leave),r(e.display.scroller,"drop",i.drop)}}function Ta(e){e.options.lineWrapping?(O(e.display.wrapper,"CodeMirror-wrap"),e.display.sizer.style.minWidth="",e.display.sizerWidth=null):(A(e.display.wrapper,"CodeMirror-wrap"),Xt(e)),di(e),pi(e),qn(e),setTimeout((function(){return Ui(e)}),100)}function Ma(e,t){var n=this;if(!(this instanceof Ma))return new Ma(e,t);this.options=t=t?_(t):{},_(Aa,t,!1);var i=t.value;"string"==typeof i?i=new Io(i,t.mode,null,t.lineSeparator,t.direction):t.mode&&(i.modeOption=t.mode),this.doc=i;var r=new Ma.inputStyles[t.inputStyle](this),o=this.display=new br(e,i,r,t);for(var u in o.wrapper.CodeMirror=this,Sa(this),t.lineWrapping&&(this.display.wrapper.className+=" CodeMirror-wrap"),Vi(this),this.state={keyMaps:[],overlays:[],modeGen:0,overwrite:!1,delayingBlurEvent:!1,focused:!1,suppressEdits:!1,pasteIncoming:-1,cutIncoming:-1,selectingText:!1,draggingText:!1,highlight:new j,keySeq:null,specialChars:null},t.autofocus&&!x&&o.input.focus(),a&&l<11&&setTimeout((function(){return n.display.input.reset(!0)}),20),function(e){var t=e.display;pe(t.scroller,"mousedown",ir(e,ba)),pe(t.scroller,"dblclick",a&&l<11?ir(e,(function(t){if(!xe(e,t)){var n=hi(e,t);if(n&&!wa(e,t)&&!Sn(e.display,t)){Ce(t);var i=e.findWordAt(n);Qr(e.doc,i.anchor,i.head)}}})):function(t){return xe(e,t)||Ce(t)});pe(t.scroller,"contextmenu",(function(t){return ka(e,t)})),pe(t.input.getField(),"contextmenu",(function(n){t.scroller.contains(n.target)||ka(e,n)}));var n,i={end:0};function r(){t.activeTouch&&(n=setTimeout((function(){return t.activeTouch=null}),1e3),(i=t.activeTouch).end=+new Date)}function o(e){if(1!=e.touches.length)return!1;var t=e.touches[0];return t.radiusX<=1&&t.radiusY<=1}function s(e,t){if(null==t.left)return!0;var n=t.left-e.left,i=t.top-e.top;return n*n+i*i>400}pe(t.scroller,"touchstart",(function(r){if(!xe(e,r)&&!o(r)&&!wa(e,r)){t.input.ensurePolled(),clearTimeout(n);var a=+new Date;t.activeTouch={start:a,moved:!1,prev:a-i.end<=300?i:null},1==r.touches.length&&(t.activeTouch.left=r.touches[0].pageX,t.activeTouch.top=r.touches[0].pageY)}})),pe(t.scroller,"touchmove",(function(){t.activeTouch&&(t.activeTouch.moved=!0)})),pe(t.scroller,"touchend",(function(n){var i=t.activeTouch;if(i&&!Sn(t,n)&&null!=i.left&&!i.moved&&new Date-i.start<300){var o,a=e.coordsChar(t.activeTouch,"page");o=!i.prev||s(i,i.prev)?new Ar(a,a):!i.prev.prev||s(i,i.prev.prev)?e.findWordAt(a):new Ar(it(a.line,0),ct(e.doc,it(a.line+1,0))),e.setSelection(o.anchor,o.head),e.focus(),Ce(n)}r()})),pe(t.scroller,"touchcancel",r),pe(t.scroller,"scroll",(function(){t.scroller.clientHeight&&(Ri(e,t.scroller.scrollTop),_i(e,t.scroller.scrollLeft,!0),ve(e,"scroll",e))})),pe(t.scroller,"mousewheel",(function(t){return Sr(e,t)})),pe(t.scroller,"DOMMouseScroll",(function(t){return Sr(e,t)})),pe(t.wrapper,"scroll",(function(){return t.wrapper.scrollTop=t.wrapper.scrollLeft=0})),t.dragFunctions={enter:function(t){xe(e,t)||Se(t)},over:function(t){xe(e,t)||(!function(e,t){var n=hi(e,t);if(n){var i=document.createDocumentFragment();Di(e,n,i),e.display.dragCursor||(e.display.dragCursor=T("div",null,"CodeMirror-cursors CodeMirror-dragcursors"),e.display.lineSpace.insertBefore(e.display.dragCursor,e.display.cursorDiv)),L(e.display.dragCursor,i)}}(e,t),Se(t))},start:function(t){return function(e,t){if(a&&(!e.state.draggingText||+new Date-zo<100))Se(t);else if(!xe(e,t)&&!Sn(e.display,t)&&(t.dataTransfer.setData("Text",e.getSelection()),t.dataTransfer.effectAllowed="copyMove",t.dataTransfer.setDragImage&&!f)){var n=T("img",null,null,"position: fixed; left: 0; top: 0;");n.src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==",h&&(n.width=n.height=1,e.display.wrapper.appendChild(n),n._top=n.offsetTop),t.dataTransfer.setDragImage(n,0,0),h&&n.parentNode.removeChild(n)}}(e,t)},drop:ir(e,Ho),leave:function(t){xe(e,t)||Ro(e)}};var u=t.input.getField();pe(u,"keyup",(function(t){return ma.call(e,t)})),pe(u,"keydown",ir(e,pa)),pe(u,"keypress",ir(e,ga)),pe(u,"focus",(function(t){return Ai(e,t)})),pe(u,"blur",(function(t){return Ei(e,t)}))}(this),Wo(),Ki(this),this.curOp.forceUpdate=!0,Pr(this,i),t.autofocus&&!x||this.hasFocus()?setTimeout((function(){n.hasFocus()&&!n.state.focused&&Ai(n)}),20):Ei(this),Ea)Ea.hasOwnProperty(u)&&Ea[u](this,t[u],Fa);gr(this),t.finishInit&&t.finishInit(this);for(var c=0;c150)){if(!i)return;n="prev"}}else u=0,n="not";"prev"==n?u=t>o.first?W(Ke(o,t-1).text,null,a):0:"add"==n?u=s+e.options.indentUnit:"subtract"==n?u=s-e.options.indentUnit:"number"==typeof n&&(u=s+n),u=Math.max(0,u);var d="",h=0;if(e.options.indentWithTabs)for(var f=Math.floor(u/a);f;--f)h+=a,d+="\t";if(ha,s=Oe(t),u=null;if(l&&i.ranges.length>1)if(Oa&&Oa.text.join("\n")==t){if(i.ranges.length%Oa.text.length==0){u=[];for(var c=0;c=0;h--){var f=i.ranges[h],p=f.from(),m=f.to();f.empty()&&(n&&n>0?p=it(p.line,p.ch-n):e.state.overwrite&&!l?m=it(m.line,Math.min(Ke(o,m.line).text.length,m.ch+Y(s).length)):l&&Oa&&Oa.lineWise&&Oa.text.join("\n")==s.join("\n")&&(p=m=it(p.line,0)));var g={from:p,to:m,text:u?u[h%u.length]:s,origin:r||(l?"paste":e.state.cutIncoming>a?"cut":"+input")};po(e.doc,g),dn(e,"inputRead",e,g)}t&&!l&&Ra(e,t),Oi(e),e.curOp.updateInput<2&&(e.curOp.updateInput=d),e.curOp.typing=!0,e.state.pasteIncoming=e.state.cutIncoming=-1}function Ha(e,t){var n=e.clipboardData&&e.clipboardData.getData("Text");if(n)return e.preventDefault(),t.isReadOnly()||t.options.disableInput||!t.hasFocus()||nr(t,(function(){return za(t,n,0,null,"paste")})),!0}function Ra(e,t){if(e.options.electricChars&&e.options.smartIndent)for(var n=e.doc.sel,i=n.ranges.length-1;i>=0;i--){var r=n.ranges[i];if(!(r.head.ch>100||i&&n.ranges[i-1].head.line==r.head.line)){var o=e.getModeAt(r.head),a=!1;if(o.electricChars){for(var l=0;l-1){a=Na(e,r.head.line,"smart");break}}else o.electricInput&&o.electricInput.test(Ke(e.doc,r.head.line).text.slice(0,r.head.ch))&&(a=Na(e,r.head.line,"smart"));a&&dn(e,"electricInput",e,r.head.line)}}}function Pa(e){for(var t=[],n=[],i=0;i0?0:-1));if(isNaN(c))a=null;else{var d=n>0?c>=55296&&c<56320:c>=56320&&c<57343;a=new it(t.line,Math.max(0,Math.min(l.text.length,t.ch+n*(d?2:1))),-n)}}else a=r?function(e,t,n,i){var r=he(t,e.doc.direction);if(!r)return ia(t,n,i);n.ch>=t.text.length?(n.ch=t.text.length,n.sticky="before"):n.ch<=0&&(n.ch=0,n.sticky="after");var o=ce(r,n.ch,n.sticky),a=r[o];if("ltr"==e.doc.direction&&a.level%2==0&&(i>0?a.to>n.ch:a.from=a.from&&h>=c.begin)){var f=d?"before":"after";return new it(n.line,h,f)}}var p=function(e,t,i){for(var o=function(e,t){return t?new it(n.line,s(e,1),"before"):new it(n.line,e,"after")};e>=0&&e0==(1!=a.level),u=l?i.begin:s(i.end,-1);if(a.from<=u&&u0?c.end:s(c.begin,-1);return null==g||i>0&&g==t.text.length||!(m=p(i>0?0:r.length-1,i,u(g)))?null:m}(e.cm,l,t,n):ia(l,t,n);if(null==a){if(o||(u=t.line+s)=e.first+e.size||(t=new it(u,t.ch,t.sticky),!(l=Ke(e,u))))return!1;t=ra(r,e.cm,l,t.line,s)}else t=a;return!0}if("char"==i||"codepoint"==i)u();else if("column"==i)u(!0);else if("word"==i||"group"==i)for(var c=null,d="group"==i,h=e.cm&&e.cm.getHelper(t,"wordChars"),f=!0;!(n<0)||u(!f);f=!1){var p=l.text.charAt(t.ch)||"\n",m=ie(p,h)?"w":d&&"\n"==p?"n":!d||/\s/.test(p)?null:"p";if(!d||f||m||(m="s"),c&&c!=m){n<0&&(n=1,u(),t.sticky="after");break}if(m&&(c=m),n>0&&!u(!f))break}var g=uo(e,t,o,a,!0);return ot(o,g)&&(g.hitSide=!0),g}function qa(e,t,n,i){var r,o,a=e.doc,l=t.left;if("page"==i){var s=Math.min(e.display.wrapper.clientHeight,R(e).innerHeight||a(e).documentElement.clientHeight),u=Math.max(s-.5*ai(e.display),3);r=(n>0?t.bottom:t.top)+n*u}else"line"==i&&(r=n>0?t.bottom+3:t.top-3);for(;(o=Jn(e,l,r)).outside;){if(n<0?r<=0:r>=a.height){o.hitSide=!0;break}r+=5*n}return o}var Ua=function(e){this.cm=e,this.lastAnchorNode=this.lastAnchorOffset=this.lastFocusNode=this.lastFocusOffset=null,this.polling=new j,this.composing=null,this.gracePeriod=!1,this.readDOMTimeout=null};function $a(e,t){var n=On(e,t.line);if(!n||n.hidden)return null;var i=Ke(e.doc,t.line),r=Bn(n,i,t.line),o=he(i,e.doc.direction),a="left";o&&(a=ce(o,t.ch)%2?"right":"left");var l=Pn(r.map,t.ch,a);return l.offset="right"==l.collapse?l.end:l.start,l}function Ga(e,t){return t&&(e.bad=!0),e}function Va(e,t,n){var i;if(t==e.display.lineDiv){if(!(i=e.display.lineDiv.childNodes[n]))return Ga(e.clipPos(it(e.display.viewTo-1)),!0);t=null,n=0}else for(i=t;;i=i.parentNode){if(!i||i==e.display.lineDiv)return null;if(i.parentNode&&i.parentNode==e.display.lineDiv)break}for(var r=0;r=t.display.viewTo||o.line=t.display.viewFrom&&$a(t,r)||{node:s[0].measure.map[2],offset:0},c=o.linei.firstLine()&&(a=it(a.line-1,Ke(i.doc,a.line-1).length)),l.ch==Ke(i.doc,l.line).text.length&&l.liner.viewTo-1)return!1;a.line==r.viewFrom||0==(e=fi(i,a.line))?(t=Je(r.view[0].line),n=r.view[0].node):(t=Je(r.view[e].line),n=r.view[e-1].node.nextSibling);var s,u,c=fi(i,l.line);if(c==r.view.length-1?(s=r.viewTo-1,u=r.lineDiv.lastChild):(s=Je(r.view[c+1].line)-1,u=r.view[c+1].node.previousSibling),!n)return!1;for(var d=i.doc.splitLines(function(e,t,n,i,r){var o="",a=!1,l=e.doc.lineSeparator(),s=!1;function u(e){return function(t){return t.id==e}}function c(){a&&(o+=l,s&&(o+=l),a=s=!1)}function d(e){e&&(c(),o+=e)}function h(t){if(1==t.nodeType){var n=t.getAttribute("cm-text");if(n)return void d(n);var o,f=t.getAttribute("cm-marker");if(f){var p=e.findMarks(it(i,0),it(r+1,0),u(+f));return void(p.length&&(o=p[0].find(0))&&d(Ze(e.doc,o.from,o.to).join(l)))}if("false"==t.getAttribute("contenteditable"))return;var m=/^(pre|div|p|li|table|br)$/i.test(t.nodeName);if(!/^br$/i.test(t.nodeName)&&0==t.textContent.length)return;m&&c();for(var g=0;g1&&h.length>1;)if(Y(d)==Y(h))d.pop(),h.pop(),s--;else{if(d[0]!=h[0])break;d.shift(),h.shift(),t++}for(var f=0,p=0,m=d[0],g=h[0],v=Math.min(m.length,g.length);fa.ch&&x.charCodeAt(x.length-p-1)==y.charCodeAt(y.length-p-1);)f--,p++;d[d.length-1]=x.slice(0,x.length-p).replace(/^\u200b+/,""),d[0]=d[0].slice(f).replace(/\u200b+$/,"");var D=it(t,f),C=it(s,h.length?Y(h).length-p:0);return d.length>1||d[0]||rt(D,C)?(yo(i.doc,d,D,C,"+input"),!0):void 0},Ua.prototype.ensurePolled=function(){this.forceCompositionEnd()},Ua.prototype.reset=function(){this.forceCompositionEnd()},Ua.prototype.forceCompositionEnd=function(){this.composing&&(clearTimeout(this.readDOMTimeout),this.composing=null,this.updateFromDOM(),this.div.blur(),this.div.focus())},Ua.prototype.readFromDOMSoon=function(){var e=this;null==this.readDOMTimeout&&(this.readDOMTimeout=setTimeout((function(){if(e.readDOMTimeout=null,e.composing){if(!e.composing.done)return;e.composing=null}e.updateFromDOM()}),80))},Ua.prototype.updateFromDOM=function(){var e=this;!this.cm.isReadOnly()&&this.pollContent()||nr(this.cm,(function(){return pi(e.cm)}))},Ua.prototype.setUneditable=function(e){e.contentEditable="false"},Ua.prototype.onKeyPress=function(e){0==e.charCode||this.composing||(e.preventDefault(),this.cm.isReadOnly()||ir(this.cm,za)(this.cm,String.fromCharCode(null==e.charCode?e.keyCode:e.charCode),0))},Ua.prototype.readOnlyChanged=function(e){this.div.contentEditable=String("nocursor"!=e)},Ua.prototype.onContextMenu=function(){},Ua.prototype.resetPosition=function(){},Ua.prototype.needsContentAttribute=!0;var Ka=function(e){this.cm=e,this.prevInput="",this.pollingFast=!1,this.polling=new j,this.hasSelection=!1,this.composing=null,this.resetting=!1};Ka.prototype.init=function(e){var t=this,n=this,i=this.cm;this.createField(e);var r=this.textarea;function o(e){if(!xe(i,e)){if(i.somethingSelected())Ia({lineWise:!1,text:i.getSelections()});else{if(!i.options.lineWiseCopyCut)return;var t=Pa(i);Ia({lineWise:!0,text:t.text}),"cut"==e.type?i.setSelections(t.ranges,null,$):(n.prevInput="",r.value=t.text.join("\n"),z(r))}"cut"==e.type&&(i.state.cutIncoming=+new Date)}}e.wrapper.insertBefore(this.wrapper,e.wrapper.firstChild),g&&(r.style.width="0px"),pe(r,"input",(function(){a&&l>=9&&t.hasSelection&&(t.hasSelection=null),n.poll()})),pe(r,"paste",(function(e){xe(i,e)||Ha(e,i)||(i.state.pasteIncoming=+new Date,n.fastPoll())})),pe(r,"cut",o),pe(r,"copy",o),pe(e.scroller,"paste",(function(t){if(!Sn(e,t)&&!xe(i,t)){if(!r.dispatchEvent)return i.state.pasteIncoming=+new Date,void n.focus();var o=new Event("paste");o.clipboardData=t.clipboardData,r.dispatchEvent(o)}})),pe(e.lineSpace,"selectstart",(function(t){Sn(e,t)||Ce(t)})),pe(r,"compositionstart",(function(){var e=i.getCursor("from");n.composing&&n.composing.range.clear(),n.composing={start:e,range:i.markText(e,i.getCursor("to"),{className:"CodeMirror-composing"})}})),pe(r,"compositionend",(function(){n.composing&&(n.poll(),n.composing.range.clear(),n.composing=null)}))},Ka.prototype.createField=function(e){this.wrapper=Wa(),this.textarea=this.wrapper.firstChild;var t=this.cm.options;_a(this.textarea,t.spellcheck,t.autocorrect,t.autocapitalize)},Ka.prototype.screenReaderLabelChanged=function(e){e?this.textarea.setAttribute("aria-label",e):this.textarea.removeAttribute("aria-label")},Ka.prototype.prepareSelection=function(){var e=this.cm,t=e.display,n=e.doc,i=bi(e);if(e.options.moveInputWithCursor){var r=Zn(e,n.sel.primary().head,"div"),o=t.wrapper.getBoundingClientRect(),a=t.lineDiv.getBoundingClientRect();i.teTop=Math.max(0,Math.min(t.wrapper.clientHeight-10,r.top+a.top-o.top)),i.teLeft=Math.max(0,Math.min(t.wrapper.clientWidth-10,r.left+a.left-o.left))}return i},Ka.prototype.showSelection=function(e){var t=this.cm.display;L(t.cursorDiv,e.cursors),L(t.selectionDiv,e.selection),null!=e.teTop&&(this.wrapper.style.top=e.teTop+"px",this.wrapper.style.left=e.teLeft+"px")},Ka.prototype.reset=function(e){if(!(this.contextMenuPending||this.composing&&e)){var t=this.cm;if(this.resetting=!0,t.somethingSelected()){this.prevInput="";var n=t.getSelection();this.textarea.value=n,t.state.focused&&z(this.textarea),a&&l>=9&&(this.hasSelection=n)}else e||(this.prevInput=this.textarea.value="",a&&l>=9&&(this.hasSelection=null));this.resetting=!1}},Ka.prototype.getField=function(){return this.textarea},Ka.prototype.supportsTouch=function(){return!1},Ka.prototype.focus=function(){if("nocursor"!=this.cm.options.readOnly&&(!x||N(this.textarea.ownerDocument)!=this.textarea))try{this.textarea.focus()}catch(e){}},Ka.prototype.blur=function(){this.textarea.blur()},Ka.prototype.resetPosition=function(){this.wrapper.style.top=this.wrapper.style.left=0},Ka.prototype.receivedFocus=function(){this.slowPoll()},Ka.prototype.slowPoll=function(){var e=this;this.pollingFast||this.polling.set(this.cm.options.pollInterval,(function(){e.poll(),e.cm.state.focused&&e.slowPoll()}))},Ka.prototype.fastPoll=function(){var e=!1,t=this;t.pollingFast=!0,t.polling.set(20,(function n(){t.poll()||e?(t.pollingFast=!1,t.slowPoll()):(e=!0,t.polling.set(60,n))}))},Ka.prototype.poll=function(){var e=this,t=this.cm,n=this.textarea,i=this.prevInput;if(this.contextMenuPending||this.resetting||!t.state.focused||Ie(n)&&!i&&!this.composing||t.isReadOnly()||t.options.disableInput||t.state.keySeq)return!1;var r=n.value;if(r==i&&!t.somethingSelected())return!1;if(a&&l>=9&&this.hasSelection===r||y&&/[\uf700-\uf7ff]/.test(r))return t.display.input.reset(),!1;if(t.doc.sel==t.display.selForContextMenu){var o=r.charCodeAt(0);if(8203!=o||i||(i=""),8666==o)return this.reset(),this.cm.execCommand("undo")}for(var s=0,u=Math.min(i.length,r.length);s1e3||r.indexOf("\n")>-1?n.value=e.prevInput="":e.prevInput=r,e.composing&&(e.composing.range.clear(),e.composing.range=t.markText(e.composing.start,t.getCursor("to"),{className:"CodeMirror-composing"}))})),!0},Ka.prototype.ensurePolled=function(){this.pollingFast&&this.poll()&&(this.pollingFast=!1)},Ka.prototype.onKeyPress=function(){a&&l>=9&&(this.hasSelection=null),this.fastPoll()},Ka.prototype.onContextMenu=function(e){var t=this,n=t.cm,i=n.display,r=t.textarea;t.contextMenuPending&&t.contextMenuPending();var o=hi(n,e),u=i.scroller.scrollTop;if(o&&!h){n.options.resetSelectionOnContextMenu&&-1==n.doc.sel.contains(o)&&ir(n,io)(n.doc,Lr(o),$);var c,d=r.style.cssText,f=t.wrapper.style.cssText,p=t.wrapper.offsetParent.getBoundingClientRect();if(t.wrapper.style.cssText="position: static",r.style.cssText="position: absolute; width: 30px; height: 30px;\n top: "+(e.clientY-p.top-5)+"px; left: "+(e.clientX-p.left-5)+"px;\n z-index: 1000; background: "+(a?"rgba(255, 255, 255, .05)":"transparent")+";\n outline: none; border-width: 0; outline: none; overflow: hidden; opacity: .05; filter: alpha(opacity=5);",s&&(c=r.ownerDocument.defaultView.scrollY),i.input.focus(),s&&r.ownerDocument.defaultView.scrollTo(null,c),i.input.reset(),n.somethingSelected()||(r.value=t.prevInput=" "),t.contextMenuPending=v,i.selForContextMenu=n.doc.sel,clearTimeout(i.detectingSelectAll),a&&l>=9&&g(),k){Se(e);var m=function(){ge(window,"mouseup",m),setTimeout(v,20)};pe(window,"mouseup",m)}else setTimeout(v,50)}function g(){if(null!=r.selectionStart){var e=n.somethingSelected(),o=""+(e?r.value:"");r.value="⇚",r.value=o,t.prevInput=e?"":"",r.selectionStart=1,r.selectionEnd=o.length,i.selForContextMenu=n.doc.sel}}function v(){if(t.contextMenuPending==v&&(t.contextMenuPending=!1,t.wrapper.style.cssText=f,r.style.cssText=d,a&&l<9&&i.scrollbars.setScrollTop(i.scroller.scrollTop=u),null!=r.selectionStart)){(!a||a&&l<9)&&g();var e=0,o=function(){i.selForContextMenu==n.doc.sel&&0==r.selectionStart&&r.selectionEnd>0&&""==t.prevInput?ir(n,ho)(n):e++<10?i.detectingSelectAll=setTimeout(o,500):(i.selForContextMenu=null,i.input.reset())};i.detectingSelectAll=setTimeout(o,200)}}},Ka.prototype.readOnlyChanged=function(e){e||this.reset(),this.textarea.disabled="nocursor"==e,this.textarea.readOnly=!!e},Ka.prototype.setUneditable=function(){},Ka.prototype.needsContentAttribute=!1,function(e){var t=e.optionHandlers;function n(n,i,r,o){e.defaults[n]=i,r&&(t[n]=o?function(e,t,n){n!=Fa&&r(e,t,n)}:r)}e.defineOption=n,e.Init=Fa,n("value","",(function(e,t){return e.setValue(t)}),!0),n("mode",null,(function(e,t){e.doc.modeOption=t,Or(e)}),!0),n("indentUnit",2,Or,!0),n("indentWithTabs",!1),n("smartIndent",!0),n("tabSize",4,(function(e){Ir(e),qn(e),pi(e)}),!0),n("lineSeparator",null,(function(e,t){if(e.doc.lineSep=t,t){var n=[],i=e.doc.first;e.doc.iter((function(e){for(var r=0;;){var o=e.text.indexOf(t,r);if(-1==o)break;r=o+t.length,n.push(it(i,o))}i++}));for(var r=n.length-1;r>=0;r--)yo(e.doc,t,n[r],it(n[r].line,n[r].ch+t.length))}})),n("specialChars",/[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc]/g,(function(e,t,n){e.state.specialChars=new RegExp(t.source+(t.test("\t")?"":"|\t"),"g"),n!=Fa&&e.refresh()})),n("specialCharPlaceholder",tn,(function(e){return e.refresh()}),!0),n("electricChars",!0),n("inputStyle",x?"contenteditable":"textarea",(function(){throw new Error("inputStyle can not (yet) be changed in a running editor")}),!0),n("spellcheck",!1,(function(e,t){return e.getInputField().spellcheck=t}),!0),n("autocorrect",!1,(function(e,t){return e.getInputField().autocorrect=t}),!0),n("autocapitalize",!1,(function(e,t){return e.getInputField().autocapitalize=t}),!0),n("rtlMoveVisually",!D),n("wholeLineUpdateBefore",!0),n("theme","default",(function(e){Sa(e),yr(e)}),!0),n("keyMap","default",(function(e,t,n){var i=ea(t),r=n!=Fa&&ea(n);r&&r.detach&&r.detach(e,i),i.attach&&i.attach(e,r||null)})),n("extraKeys",null),n("configureMouse",null),n("lineWrapping",!1,Ta,!0),n("gutters",[],(function(e,t){e.display.gutterSpecs=vr(t,e.options.lineNumbers),yr(e)}),!0),n("fixedGutter",!0,(function(e,t){e.display.gutters.style.left=t?ui(e.display)+"px":"0",e.refresh()}),!0),n("coverGutterNextToScrollbar",!1,(function(e){return Ui(e)}),!0),n("scrollbarStyle","native",(function(e){Vi(e),Ui(e),e.display.scrollbars.setScrollTop(e.doc.scrollTop),e.display.scrollbars.setScrollLeft(e.doc.scrollLeft)}),!0),n("lineNumbers",!1,(function(e,t){e.display.gutterSpecs=vr(e.options.gutters,t),yr(e)}),!0),n("firstLineNumber",1,yr,!0),n("lineNumberFormatter",(function(e){return e}),yr,!0),n("showCursorWhenSelecting",!1,yi,!0),n("resetSelectionOnContextMenu",!0),n("lineWiseCopyCut",!0),n("pasteLinesPerSelection",!0),n("selectionsMayTouch",!1),n("readOnly",!1,(function(e,t){"nocursor"==t&&(Ei(e),e.display.input.blur()),e.display.input.readOnlyChanged(t)})),n("screenReaderLabel",null,(function(e,t){t=""===t?null:t,e.display.input.screenReaderLabelChanged(t)})),n("disableInput",!1,(function(e,t){t||e.display.input.reset()}),!0),n("dragDrop",!0,La),n("allowDropFileTypes",null),n("cursorBlinkRate",530),n("cursorScrollMargin",0),n("cursorHeight",1,yi,!0),n("singleCursorHeightPerLine",!0,yi,!0),n("workTime",100),n("workDelay",100),n("flattenSpans",!0,Ir,!0),n("addModeClass",!1,Ir,!0),n("pollInterval",100),n("undoDepth",200,(function(e,t){return e.doc.history.undoDepth=t})),n("historyEventDelay",1250),n("viewportMargin",10,(function(e){return e.refresh()}),!0),n("maxHighlightLength",1e4,Ir,!0),n("moveInputWithCursor",!0,(function(e,t){t||e.display.input.resetPosition()})),n("tabindex",null,(function(e,t){return e.display.input.getField().tabIndex=t||""})),n("autofocus",null),n("direction","ltr",(function(e,t){return e.doc.setDirection(t)}),!0),n("phrases",null)}(Ma),function(e){var t=e.optionHandlers,n=e.helpers={};e.prototype={constructor:e,focus:function(){R(this).focus(),this.display.input.focus()},setOption:function(e,n){var i=this.options,r=i[e];i[e]==n&&"mode"!=e||(i[e]=n,t.hasOwnProperty(e)&&ir(this,t[e])(this,n,r),ve(this,"optionChange",this,e))},getOption:function(e){return this.options[e]},getDoc:function(){return this.doc},addKeyMap:function(e,t){this.state.keyMaps[t?"push":"unshift"](ea(e))},removeKeyMap:function(e){for(var t=this.state.keyMaps,n=0;nn&&(Na(this,r.head.line,e,!0),n=r.head.line,i==this.doc.sel.primIndex&&Oi(this));else{var o=r.from(),a=r.to(),l=Math.max(n,o.line);n=Math.min(this.lastLine(),a.line-(a.ch?0:1))+1;for(var s=l;s0&&eo(this.doc,i,new Ar(o,u[i].to()),$)}}})),getTokenAt:function(e,t){return Dt(this,e,t)},getLineTokens:function(e,t){return Dt(this,it(e),t,!0)},getTokenTypeAt:function(e){e=ct(this.doc,e);var t,n=mt(this,Ke(this.doc,e.line)),i=0,r=(n.length-1)/2,o=e.ch;if(0==o)t=n[2];else for(;;){var a=i+r>>1;if((a?n[2*a-1]:0)>=o)r=a;else{if(!(n[2*a+1]o&&(e=o,r=!0),i=Ke(this.doc,e)}else i=e;return Vn(this,i,{top:0,left:0},t||"page",n||r).top+(r?this.doc.height-Gt(i):0)},defaultTextHeight:function(){return ai(this.display)},defaultCharWidth:function(){return li(this.display)},getViewport:function(){return{from:this.display.viewFrom,to:this.display.viewTo}},addWidget:function(e,t,n,i,r){var o,a,l,s=this.display,u=(e=Zn(this,ct(this.doc,e))).bottom,c=e.left;if(t.style.position="absolute",t.setAttribute("cm-ignore-events","true"),this.display.input.setUneditable(t),s.sizer.appendChild(t),"over"==i)u=e.top;else if("above"==i||"near"==i){var d=Math.max(s.wrapper.clientHeight,this.doc.height),h=Math.max(s.sizer.clientWidth,s.lineSpace.clientWidth);("above"==i||e.bottom+t.offsetHeight>d)&&e.top>t.offsetHeight?u=e.top-t.offsetHeight:e.bottom+t.offsetHeight<=d&&(u=e.bottom),c+t.offsetWidth>h&&(c=h-t.offsetWidth)}t.style.top=u+"px",t.style.left=t.style.right="","right"==r?(c=s.sizer.clientWidth-t.offsetWidth,t.style.right="0px"):("left"==r?c=0:"middle"==r&&(c=(s.sizer.clientWidth-t.offsetWidth)/2),t.style.left=c+"px"),n&&(o=this,a={left:c,top:u,right:c+t.offsetWidth,bottom:u+t.offsetHeight},null!=(l=Bi(o,a)).scrollTop&&Ri(o,l.scrollTop),null!=l.scrollLeft&&_i(o,l.scrollLeft))},triggerOnKeyDown:rr(pa),triggerOnKeyPress:rr(ga),triggerOnKeyUp:ma,triggerOnMouseDown:rr(ba),execCommand:function(e){if(oa.hasOwnProperty(e))return oa[e].call(null,this)},triggerElectric:rr((function(e){Ra(this,e)})),findPosH:function(e,t,n,i){var r=1;t<0&&(r=-1,t=-t);for(var o=ct(this.doc,e),a=0;a0&&a(t.charAt(n-1));)--n;for(;i.5||this.options.lineWrapping)&&di(this),ve(this,"refresh",this)})),swapDoc:rr((function(e){var t=this.doc;return t.cm=null,this.state.selectingText&&this.state.selectingText(),Pr(this,e),qn(this),this.display.input.reset(),Ii(this,e.scrollLeft,e.scrollTop),this.curOp.forceScroll=!0,dn(this,"swapDoc",this,t),t})),phrase:function(e){var t=this.options.phrases;return t&&Object.prototype.hasOwnProperty.call(t,e)?t[e]:e},getInputField:function(){return this.display.input.getField()},getWrapperElement:function(){return this.display.wrapper},getScrollerElement:function(){return this.display.scroller},getGutterElement:function(){return this.display.gutters}},De(e),e.registerHelper=function(t,i,r){n.hasOwnProperty(t)||(n[t]=e[t]={_global:[]}),n[t][i]=r},e.registerGlobalHelper=function(t,i,r,o){e.registerHelper(t,i,o),n[t]._global.push({pred:r,val:o})}}(Ma);var Za="iter insert remove copy getEditor constructor".split(" ");for(var Ya in Io.prototype)Io.prototype.hasOwnProperty(Ya)&&q(Za,Ya)<0&&(Ma.prototype[Ya]=function(e){return function(){return e.apply(this.doc,arguments)}}(Io.prototype[Ya]));return De(Io),Ma.inputStyles={textarea:Ka,contenteditable:Ua},Ma.defineMode=function(e){Ma.defaults.mode||"null"==e||(Ma.defaults.mode=e),_e.apply(this,arguments)},Ma.defineMIME=function(e,t){Pe[e]=t},Ma.defineMode("null",(function(){return{token:function(e){return e.skipToEnd()}}})),Ma.defineMIME("text/plain","null"),Ma.defineExtension=function(e,t){Ma.prototype[e]=t},Ma.defineDocExtension=function(e,t){Io.prototype[e]=t},Ma.fromTextArea=function(e,t){if((t=t?_(t):{}).value=e.value,!t.tabindex&&e.tabIndex&&(t.tabindex=e.tabIndex),!t.placeholder&&e.placeholder&&(t.placeholder=e.placeholder),null==t.autofocus){var n=N(e.ownerDocument);t.autofocus=n==e||null!=e.getAttribute("autofocus")&&n==document.body}function i(){e.value=l.getValue()}var r;if(e.form&&(pe(e.form,"submit",i),!t.leaveSubmitMethodAlone)){var o=e.form;r=o.submit;try{var a=o.submit=function(){i(),o.submit=r,o.submit(),o.submit=a}}catch(e){}}t.finishInit=function(n){n.save=i,n.getTextArea=function(){return e},n.toTextArea=function(){n.toTextArea=isNaN,i(),e.parentNode.removeChild(n.getWrapperElement()),e.style.display="",e.form&&(ge(e.form,"submit",i),t.leaveSubmitMethodAlone||"function"!=typeof e.form.submit||(e.form.submit=r))}},e.style.display="none";var l=Ma((function(t){return e.parentNode.insertBefore(t,e.nextSibling)}),t);return l},function(e){e.off=ge,e.on=pe,e.wheelEventPixels=kr,e.Doc=Io,e.splitLines=Oe,e.countColumn=W,e.findColumn=X,e.isWordChar=ne,e.Pass=U,e.signal=ve,e.Line=Kt,e.changeEnd=Tr,e.scrollbarModel=Gi,e.Pos=it,e.cmpPos=rt,e.modes=Re,e.mimeModes=Pe,e.resolveMode=We,e.getMode=je,e.modeExtensions=qe,e.extendMode=Ue,e.copyState=$e,e.startState=Ve,e.innerMode=Ge,e.commands=oa,e.keyMap=Vo,e.keyName=Jo,e.isModifierKey=Yo,e.lookupKey=Zo,e.normalizeKeyMap=Ko,e.StringStream=Xe,e.SharedTextMarker=Mo,e.TextMarker=Lo,e.LineWidget=Fo,e.e_preventDefault=Ce,e.e_stopPropagation=we,e.e_stop=Se,e.addClass=O,e.contains=B,e.rmClass=A,e.keyNames=qo}(Ma),Ma.version="5.65.15",Ma}))},{}],11:[function(e,t,n){var i;i=function(e){"use strict";var t=/^((?:(?:aaas?|about|acap|adiumxtra|af[ps]|aim|apt|attachment|aw|beshare|bitcoin|bolo|callto|cap|chrome(?:-extension)?|cid|coap|com-eventbrite-attendee|content|crid|cvs|data|dav|dict|dlna-(?:playcontainer|playsingle)|dns|doi|dtn|dvb|ed2k|facetime|feed|file|finger|fish|ftp|geo|gg|git|gizmoproject|go|gopher|gtalk|h323|hcp|https?|iax|icap|icon|im|imap|info|ipn|ipp|irc[6s]?|iris(?:\.beep|\.lwz|\.xpc|\.xpcs)?|itms|jar|javascript|jms|keyparc|lastfm|ldaps?|magnet|mailto|maps|market|message|mid|mms|ms-help|msnim|msrps?|mtqp|mumble|mupdate|mvn|news|nfs|nih?|nntp|notes|oid|opaquelocktoken|palm|paparazzi|platform|pop|pres|proxy|psyc|query|res(?:ource)?|rmi|rsync|rtmp|rtsp|secondlife|service|session|sftp|sgn|shttp|sieve|sips?|skype|sm[bs]|snmp|soap\.beeps?|soldat|spotify|ssh|steam|svn|tag|teamspeak|tel(?:net)?|tftp|things|thismessage|tip|tn3270|tv|udp|unreal|urn|ut2004|vemmi|ventrilo|view-source|webcal|wss?|wtai|wyciwyg|xcon(?:-userid)?|xfire|xmlrpc\.beeps?|xmpp|xri|ymsgr|z39\.50[rs]?):(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]|\([^\s()<>]*\))+(?:\([^\s()<>]*\)|[^\s`*!()\[\]{};:'".,<>?«»“”‘’]))/i;e.defineMode("gfm",(function(n,i){var r=0,o={startState:function(){return{code:!1,codeBlock:!1,ateSpace:!1}},copyState:function(e){return{code:e.code,codeBlock:e.codeBlock,ateSpace:e.ateSpace}},token:function(e,n){if(n.combineTokens=null,n.codeBlock)return e.match(/^```+/)?(n.codeBlock=!1,null):(e.skipToEnd(),null);if(e.sol()&&(n.code=!1),e.sol()&&e.match(/^```+/))return e.skipToEnd(),n.codeBlock=!0,null;if("`"===e.peek()){e.next();var o=e.pos;e.eatWhile("`");var a=1+e.pos-o;return n.code?a===r&&(n.code=!1):(r=a,n.code=!0),null}if(n.code)return e.next(),null;if(e.eatSpace())return n.ateSpace=!0,null;if((e.sol()||n.ateSpace)&&(n.ateSpace=!1,!1!==i.gitHubSpice)){if(e.match(/^(?:[a-zA-Z0-9\-_]+\/)?(?:[a-zA-Z0-9\-_]+@)?(?=.{0,6}\d)(?:[a-f0-9]{7,40}\b)/))return n.combineTokens=!0,"link";if(e.match(/^(?:[a-zA-Z0-9\-_]+\/)?(?:[a-zA-Z0-9\-_]+)?#[0-9]+\b/))return n.combineTokens=!0,"link"}return e.match(t)&&"]("!=e.string.slice(e.start-2,e.start)&&(0==e.start||/\W/.test(e.string.charAt(e.start-1)))?(n.combineTokens=!0,"link"):(e.next(),null)},blankLine:function(e){return e.code=!1,null}},a={taskLists:!0,strikethrough:!0,emoji:!0};for(var l in i)a[l]=i[l];return a.name="markdown",e.overlayMode(e.getMode(n,a),o)}),"markdown"),e.defineMIME("text/x-gfm","gfm")},"object"==typeof n&&"object"==typeof t?i(e("../../lib/codemirror"),e("../markdown/markdown"),e("../../addon/mode/overlay")):i(CodeMirror)},{"../../addon/mode/overlay":7,"../../lib/codemirror":10,"../markdown/markdown":12}],12:[function(e,t,n){var i;i=function(e){"use strict";e.defineMode("markdown",(function(t,n){var i=e.getMode(t,"text/html"),r="null"==i.name;void 0===n.highlightFormatting&&(n.highlightFormatting=!1),void 0===n.maxBlockquoteDepth&&(n.maxBlockquoteDepth=0),void 0===n.taskLists&&(n.taskLists=!1),void 0===n.strikethrough&&(n.strikethrough=!1),void 0===n.emoji&&(n.emoji=!1),void 0===n.fencedCodeBlockHighlighting&&(n.fencedCodeBlockHighlighting=!0),void 0===n.fencedCodeBlockDefaultMode&&(n.fencedCodeBlockDefaultMode="text/plain"),void 0===n.xml&&(n.xml=!0),void 0===n.tokenTypeOverrides&&(n.tokenTypeOverrides={});var o={header:"header",code:"comment",quote:"quote",list1:"variable-2",list2:"variable-3",list3:"keyword",hr:"hr",image:"image",imageAltText:"image-alt-text",imageMarker:"image-marker",formatting:"formatting",linkInline:"link",linkEmail:"link",linkText:"link",linkHref:"string",em:"em",strong:"strong",strikethrough:"strikethrough",emoji:"builtin"};for(var a in o)o.hasOwnProperty(a)&&n.tokenTypeOverrides[a]&&(o[a]=n.tokenTypeOverrides[a]);var l=/^([*\-_])(?:\s*\1){2,}\s*$/,s=/^(?:[*\-+]|^[0-9]+([.)]))\s+/,u=/^\[(x| )\](?=\s)/i,c=n.allowAtxHeaderWithoutSpace?/^(#+)/:/^(#+)(?: |$)/,d=/^ {0,3}(?:\={1,}|-{2,})\s*$/,h=/^[^#!\[\]*_\\<>` "'(~:]+/,f=/^(~~~+|```+)[ \t]*([\w\/+#-]*)[^\n`]*$/,p=/^\s*\[[^\]]+?\]:.*$/,m=/[!"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E42\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDF3C-\uDF3E]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]/;function g(e,t,n){return t.f=t.inline=n,n(e,t)}function v(e,t,n){return t.f=t.block=n,n(e,t)}function x(t){if(t.linkTitle=!1,t.linkHref=!1,t.linkText=!1,t.em=!1,t.strong=!1,t.strikethrough=!1,t.quote=0,t.indentedCode=!1,t.f==b){var n=r;if(!n){var o=e.innerMode(i,t.htmlState);n="xml"==o.mode.name&&null===o.state.tagStart&&!o.state.context&&o.state.tokenize.isInText}n&&(t.f=k,t.block=y,t.htmlState=null)}return t.trailingSpace=0,t.trailingSpaceNewLine=!1,t.prevLine=t.thisLine,t.thisLine={stream:null},null}function y(i,r){var a,h=i.column()===r.indentation,m=!(a=r.prevLine.stream)||!/\S/.test(a.string),v=r.indentedCode,x=r.prevLine.hr,y=!1!==r.list,b=(r.listStack[r.listStack.length-1]||0)+3;r.indentedCode=!1;var w=r.indentation;if(null===r.indentationDiff&&(r.indentationDiff=r.indentation,y)){for(r.list=null;w=4&&(v||r.prevLine.fencedCodeEnd||r.prevLine.header||m))return i.skipToEnd(),r.indentedCode=!0,o.code;if(i.eatSpace())return null;if(h&&r.indentation<=b&&(F=i.match(c))&&F[1].length<=6)return r.quote=0,r.header=F[1].length,r.thisLine.header=!0,n.highlightFormatting&&(r.formatting="header"),r.f=r.inline,C(r);if(r.indentation<=b&&i.eat(">"))return r.quote=h?1:r.quote+1,n.highlightFormatting&&(r.formatting="quote"),i.eatSpace(),C(r);if(!S&&!r.setext&&h&&r.indentation<=b&&(F=i.match(s))){var A=F[1]?"ol":"ul";return r.indentation=w+i.current().length,r.list=!0,r.quote=0,r.listStack.push(r.indentation),r.em=!1,r.strong=!1,r.code=!1,r.strikethrough=!1,n.taskLists&&i.match(u,!1)&&(r.taskList=!0),r.f=r.inline,n.highlightFormatting&&(r.formatting=["list","list-"+A]),C(r)}return h&&r.indentation<=b&&(F=i.match(f,!0))?(r.quote=0,r.fencedEndRE=new RegExp(F[1]+"+ *$"),r.localMode=n.fencedCodeBlockHighlighting&&function(n){if(e.findModeByName){var i=e.findModeByName(n);i&&(n=i.mime||i.mimes[0])}var r=e.getMode(t,n);return"null"==r.name?null:r}(F[2]||n.fencedCodeBlockDefaultMode),r.localMode&&(r.localState=e.startState(r.localMode)),r.f=r.block=D,n.highlightFormatting&&(r.formatting="code-block"),r.code=-1,C(r)):r.setext||!(k&&y||r.quote||!1!==r.list||r.code||S||p.test(i.string))&&(F=i.lookAhead(1))&&(F=F.match(d))?(r.setext?(r.header=r.setext,r.setext=0,i.skipToEnd(),n.highlightFormatting&&(r.formatting="header")):(r.header="="==F[0].charAt(0)?1:2,r.setext=r.header),r.thisLine.header=!0,r.f=r.inline,C(r)):S?(i.skipToEnd(),r.hr=!0,r.thisLine.hr=!0,o.hr):"["===i.peek()?g(i,r,E):g(i,r,r.inline)}function b(t,n){var o=i.token(t,n.htmlState);if(!r){var a=e.innerMode(i,n.htmlState);("xml"==a.mode.name&&null===a.state.tagStart&&!a.state.context&&a.state.tokenize.isInText||n.md_inside&&t.current().indexOf(">")>-1)&&(n.f=k,n.block=y,n.htmlState=null)}return o}function D(e,t){var i,r=t.listStack[t.listStack.length-1]||0,a=t.indentation=e.quote?t.push(o.formatting+"-"+e.formatting[i]+"-"+e.quote):t.push("error"))}if(e.taskOpen)return t.push("meta"),t.length?t.join(" "):null;if(e.taskClosed)return t.push("property"),t.length?t.join(" "):null;if(e.linkHref?t.push(o.linkHref,"url"):(e.strong&&t.push(o.strong),e.em&&t.push(o.em),e.strikethrough&&t.push(o.strikethrough),e.emoji&&t.push(o.emoji),e.linkText&&t.push(o.linkText),e.code&&t.push(o.code),e.image&&t.push(o.image),e.imageAltText&&t.push(o.imageAltText,"link"),e.imageMarker&&t.push(o.imageMarker)),e.header&&t.push(o.header,o.header+"-"+e.header),e.quote&&(t.push(o.quote),!n.maxBlockquoteDepth||n.maxBlockquoteDepth>=e.quote?t.push(o.quote+"-"+e.quote):t.push(o.quote+"-"+n.maxBlockquoteDepth)),!1!==e.list){var r=(e.listStack.length-1)%3;r?1===r?t.push(o.list2):t.push(o.list3):t.push(o.list1)}return e.trailingSpaceNewLine?t.push("trailing-space-new-line"):e.trailingSpace&&t.push("trailing-space-"+(e.trailingSpace%2?"a":"b")),t.length?t.join(" "):null}function w(e,t){if(e.match(h,!0))return C(t)}function k(t,r){var a=r.text(t,r);if(void 0!==a)return a;if(r.list)return r.list=null,C(r);if(r.taskList)return" "===t.match(u,!0)[1]?r.taskOpen=!0:r.taskClosed=!0,n.highlightFormatting&&(r.formatting="task"),r.taskList=!1,C(r);if(r.taskOpen=!1,r.taskClosed=!1,r.header&&t.match(/^#+$/,!0))return n.highlightFormatting&&(r.formatting="header"),C(r);var l=t.next();if(r.linkTitle){r.linkTitle=!1;var s=l;"("===l&&(s=")");var c="^\\s*(?:[^"+(s=(s+"").replace(/([.?*+^\[\]\\(){}|-])/g,"\\$1"))+"\\\\]+|\\\\\\\\|\\\\.)"+s;if(t.match(new RegExp(c),!0))return o.linkHref}if("`"===l){var d=r.formatting;n.highlightFormatting&&(r.formatting="code"),t.eatWhile("`");var h=t.current().length;if(0!=r.code||r.quote&&1!=h){if(h==r.code){var f=C(r);return r.code=0,f}return r.formatting=d,C(r)}return r.code=h,C(r)}if(r.code)return C(r);if("\\"===l&&(t.next(),n.highlightFormatting)){var p=C(r),g=o.formatting+"-escape";return p?p+" "+g:g}if("!"===l&&t.match(/\[[^\]]*\] ?(?:\(|\[)/,!1))return r.imageMarker=!0,r.image=!0,n.highlightFormatting&&(r.formatting="image"),C(r);if("["===l&&r.imageMarker&&t.match(/[^\]]*\](\(.*?\)| ?\[.*?\])/,!1))return r.imageMarker=!1,r.imageAltText=!0,n.highlightFormatting&&(r.formatting="image"),C(r);if("]"===l&&r.imageAltText){n.highlightFormatting&&(r.formatting="image");var p=C(r);return r.imageAltText=!1,r.image=!1,r.inline=r.f=F,p}if("["===l&&!r.image)return r.linkText&&t.match(/^.*?\]/)||(r.linkText=!0,n.highlightFormatting&&(r.formatting="link")),C(r);if("]"===l&&r.linkText){n.highlightFormatting&&(r.formatting="link");var p=C(r);return r.linkText=!1,r.inline=r.f=t.match(/\(.*?\)| ?\[.*?\]/,!1)?F:k,p}if("<"===l&&t.match(/^(https?|ftps?):\/\/(?:[^\\>]|\\.)+>/,!1))return r.f=r.inline=S,n.highlightFormatting&&(r.formatting="link"),(p=C(r))?p+=" ":p="",p+o.linkInline;if("<"===l&&t.match(/^[^> \\]+@(?:[^\\>]|\\.)+>/,!1))return r.f=r.inline=S,n.highlightFormatting&&(r.formatting="link"),(p=C(r))?p+=" ":p="",p+o.linkEmail;if(n.xml&&"<"===l&&t.match(/^(!--|\?|!\[CDATA\[|[a-z][a-z0-9-]*(?:\s+[a-z_:.\-]+(?:\s*=\s*[^>]+)?)*\s*(?:>|$))/i,!1)){var x=t.string.indexOf(">",t.pos);if(-1!=x){var y=t.string.substring(t.start,x);/markdown\s*=\s*('|"){0,1}1('|"){0,1}/.test(y)&&(r.md_inside=!0)}return t.backUp(1),r.htmlState=e.startState(i),v(t,r,b)}if(n.xml&&"<"===l&&t.match(/^\/\w*?>/))return r.md_inside=!1,"tag";if("*"===l||"_"===l){for(var D=1,w=1==t.pos?" ":t.string.charAt(t.pos-2);D<3&&t.eat(l);)D++;var A=t.peek()||" ",E=!/\s/.test(A)&&(!m.test(A)||/\s/.test(w)||m.test(w)),L=!/\s/.test(w)&&(!m.test(w)||/\s/.test(A)||m.test(A)),T=null,M=null;if(D%2&&(r.em||!E||"*"!==l&&L&&!m.test(w)?r.em!=l||!L||"*"!==l&&E&&!m.test(A)||(T=!1):T=!0),D>1&&(r.strong||!E||"*"!==l&&L&&!m.test(w)?r.strong!=l||!L||"*"!==l&&E&&!m.test(A)||(M=!1):M=!0),null!=M||null!=T)return n.highlightFormatting&&(r.formatting=null==T?"strong":null==M?"em":"strong em"),!0===T&&(r.em=l),!0===M&&(r.strong=l),f=C(r),!1===T&&(r.em=!1),!1===M&&(r.strong=!1),f}else if(" "===l&&(t.eat("*")||t.eat("_"))){if(" "===t.peek())return C(r);t.backUp(1)}if(n.strikethrough)if("~"===l&&t.eatWhile(l)){if(r.strikethrough)return n.highlightFormatting&&(r.formatting="strikethrough"),f=C(r),r.strikethrough=!1,f;if(t.match(/^[^\s]/,!1))return r.strikethrough=!0,n.highlightFormatting&&(r.formatting="strikethrough"),C(r)}else if(" "===l&&t.match("~~",!0)){if(" "===t.peek())return C(r);t.backUp(2)}if(n.emoji&&":"===l&&t.match(/^(?:[a-z_\d+][a-z_\d+-]*|\-[a-z_\d+][a-z_\d+-]*):/)){r.emoji=!0,n.highlightFormatting&&(r.formatting="emoji");var B=C(r);return r.emoji=!1,B}return" "===l&&(t.match(/^ +$/,!1)?r.trailingSpace++:r.trailingSpace&&(r.trailingSpaceNewLine=!0)),C(r)}function S(e,t){if(">"===e.next()){t.f=t.inline=k,n.highlightFormatting&&(t.formatting="link");var i=C(t);return i?i+=" ":i="",i+o.linkInline}return e.match(/^[^>]+/,!0),o.linkInline}function F(e,t){if(e.eatSpace())return null;var i,r=e.next();return"("===r||"["===r?(t.f=t.inline=(i="("===r?")":"]",function(e,t){if(e.next()===i){t.f=t.inline=k,n.highlightFormatting&&(t.formatting="link-string");var r=C(t);return t.linkHref=!1,r}return e.match(A[i]),t.linkHref=!0,C(t)}),n.highlightFormatting&&(t.formatting="link-string"),t.linkHref=!0,C(t)):"error"}var A={")":/^(?:[^\\\(\)]|\\.|\((?:[^\\\(\)]|\\.)*\))*?(?=\))/,"]":/^(?:[^\\\[\]]|\\.|\[(?:[^\\\[\]]|\\.)*\])*?(?=\])/};function E(e,t){return e.match(/^([^\]\\]|\\.)*\]:/,!1)?(t.f=L,e.next(),n.highlightFormatting&&(t.formatting="link"),t.linkText=!0,C(t)):g(e,t,k)}function L(e,t){if(e.match("]:",!0)){t.f=t.inline=T,n.highlightFormatting&&(t.formatting="link");var i=C(t);return t.linkText=!1,i}return e.match(/^([^\]\\]|\\.)+/,!0),o.linkText}function T(e,t){return e.eatSpace()?null:(e.match(/^[^\s]+/,!0),void 0===e.peek()?t.linkTitle=!0:e.match(/^(?:\s+(?:"(?:[^"\\]|\\.)+"|'(?:[^'\\]|\\.)+'|\((?:[^)\\]|\\.)+\)))?/,!0),t.f=t.inline=k,o.linkHref+" url")}var M={startState:function(){return{f:y,prevLine:{stream:null},thisLine:{stream:null},block:y,htmlState:null,indentation:0,inline:k,text:w,formatting:!1,linkText:!1,linkHref:!1,linkTitle:!1,code:0,em:!1,strong:!1,header:0,setext:0,hr:!1,taskList:!1,list:!1,listStack:[],quote:0,trailingSpace:0,trailingSpaceNewLine:!1,strikethrough:!1,emoji:!1,fencedEndRE:null}},copyState:function(t){return{f:t.f,prevLine:t.prevLine,thisLine:t.thisLine,block:t.block,htmlState:t.htmlState&&e.copyState(i,t.htmlState),indentation:t.indentation,localMode:t.localMode,localState:t.localMode?e.copyState(t.localMode,t.localState):null,inline:t.inline,text:t.text,formatting:!1,linkText:t.linkText,linkTitle:t.linkTitle,linkHref:t.linkHref,code:t.code,em:t.em,strong:t.strong,strikethrough:t.strikethrough,emoji:t.emoji,header:t.header,setext:t.setext,hr:t.hr,taskList:t.taskList,list:t.list,listStack:t.listStack.slice(0),quote:t.quote,indentedCode:t.indentedCode,trailingSpace:t.trailingSpace,trailingSpaceNewLine:t.trailingSpaceNewLine,md_inside:t.md_inside,fencedEndRE:t.fencedEndRE}},token:function(e,t){if(t.formatting=!1,e!=t.thisLine.stream){if(t.header=0,t.hr=!1,e.match(/^\s*$/,!0))return x(t),null;if(t.prevLine=t.thisLine,t.thisLine={stream:e},t.taskList=!1,t.trailingSpace=0,t.trailingSpaceNewLine=!1,!t.localState&&(t.f=t.block,t.f!=b)){var n=e.match(/^\s*/,!0)[0].replace(/\t/g," ").length;if(t.indentation=n,t.indentationDiff=null,n>0)return null}}return t.f(e,t)},innerMode:function(e){return e.block==b?{state:e.htmlState,mode:i}:e.localState?{state:e.localState,mode:e.localMode}:{state:e,mode:M}},indent:function(t,n,r){return t.block==b&&i.indent?i.indent(t.htmlState,n,r):t.localState&&t.localMode.indent?t.localMode.indent(t.localState,n,r):e.Pass},blankLine:x,getType:C,blockCommentStart:"\x3c!--",blockCommentEnd:"--\x3e",closeBrackets:"()[]{}''\"\"``",fold:"markdown"};return M}),"xml"),e.defineMIME("text/markdown","markdown"),e.defineMIME("text/x-markdown","markdown")},"object"==typeof n&&"object"==typeof t?i(e("../../lib/codemirror"),e("../xml/xml"),e("../meta")):i(CodeMirror)},{"../../lib/codemirror":10,"../meta":13,"../xml/xml":14}],13:[function(e,t,n){(function(e){"use strict";e.modeInfo=[{name:"APL",mime:"text/apl",mode:"apl",ext:["dyalog","apl"]},{name:"PGP",mimes:["application/pgp","application/pgp-encrypted","application/pgp-keys","application/pgp-signature"],mode:"asciiarmor",ext:["asc","pgp","sig"]},{name:"ASN.1",mime:"text/x-ttcn-asn",mode:"asn.1",ext:["asn","asn1"]},{name:"Asterisk",mime:"text/x-asterisk",mode:"asterisk",file:/^extensions\.conf$/i},{name:"Brainfuck",mime:"text/x-brainfuck",mode:"brainfuck",ext:["b","bf"]},{name:"C",mime:"text/x-csrc",mode:"clike",ext:["c","h","ino"]},{name:"C++",mime:"text/x-c++src",mode:"clike",ext:["cpp","c++","cc","cxx","hpp","h++","hh","hxx"],alias:["cpp"]},{name:"Cobol",mime:"text/x-cobol",mode:"cobol",ext:["cob","cpy","cbl"]},{name:"C#",mime:"text/x-csharp",mode:"clike",ext:["cs"],alias:["csharp","cs"]},{name:"Clojure",mime:"text/x-clojure",mode:"clojure",ext:["clj","cljc","cljx"]},{name:"ClojureScript",mime:"text/x-clojurescript",mode:"clojure",ext:["cljs"]},{name:"Closure Stylesheets (GSS)",mime:"text/x-gss",mode:"css",ext:["gss"]},{name:"CMake",mime:"text/x-cmake",mode:"cmake",ext:["cmake","cmake.in"],file:/^CMakeLists\.txt$/},{name:"CoffeeScript",mimes:["application/vnd.coffeescript","text/coffeescript","text/x-coffeescript"],mode:"coffeescript",ext:["coffee"],alias:["coffee","coffee-script"]},{name:"Common Lisp",mime:"text/x-common-lisp",mode:"commonlisp",ext:["cl","lisp","el"],alias:["lisp"]},{name:"Cypher",mime:"application/x-cypher-query",mode:"cypher",ext:["cyp","cypher"]},{name:"Cython",mime:"text/x-cython",mode:"python",ext:["pyx","pxd","pxi"]},{name:"Crystal",mime:"text/x-crystal",mode:"crystal",ext:["cr"]},{name:"CSS",mime:"text/css",mode:"css",ext:["css"]},{name:"CQL",mime:"text/x-cassandra",mode:"sql",ext:["cql"]},{name:"D",mime:"text/x-d",mode:"d",ext:["d"]},{name:"Dart",mimes:["application/dart","text/x-dart"],mode:"dart",ext:["dart"]},{name:"diff",mime:"text/x-diff",mode:"diff",ext:["diff","patch"]},{name:"Django",mime:"text/x-django",mode:"django"},{name:"Dockerfile",mime:"text/x-dockerfile",mode:"dockerfile",file:/^Dockerfile$/},{name:"DTD",mime:"application/xml-dtd",mode:"dtd",ext:["dtd"]},{name:"Dylan",mime:"text/x-dylan",mode:"dylan",ext:["dylan","dyl","intr"]},{name:"EBNF",mime:"text/x-ebnf",mode:"ebnf"},{name:"ECL",mime:"text/x-ecl",mode:"ecl",ext:["ecl"]},{name:"edn",mime:"application/edn",mode:"clojure",ext:["edn"]},{name:"Eiffel",mime:"text/x-eiffel",mode:"eiffel",ext:["e"]},{name:"Elm",mime:"text/x-elm",mode:"elm",ext:["elm"]},{name:"Embedded JavaScript",mime:"application/x-ejs",mode:"htmlembedded",ext:["ejs"]},{name:"Embedded Ruby",mime:"application/x-erb",mode:"htmlembedded",ext:["erb"]},{name:"Erlang",mime:"text/x-erlang",mode:"erlang",ext:["erl"]},{name:"Esper",mime:"text/x-esper",mode:"sql"},{name:"Factor",mime:"text/x-factor",mode:"factor",ext:["factor"]},{name:"FCL",mime:"text/x-fcl",mode:"fcl"},{name:"Forth",mime:"text/x-forth",mode:"forth",ext:["forth","fth","4th"]},{name:"Fortran",mime:"text/x-fortran",mode:"fortran",ext:["f","for","f77","f90","f95"]},{name:"F#",mime:"text/x-fsharp",mode:"mllike",ext:["fs"],alias:["fsharp"]},{name:"Gas",mime:"text/x-gas",mode:"gas",ext:["s"]},{name:"Gherkin",mime:"text/x-feature",mode:"gherkin",ext:["feature"]},{name:"GitHub Flavored Markdown",mime:"text/x-gfm",mode:"gfm",file:/^(readme|contributing|history)\.md$/i},{name:"Go",mime:"text/x-go",mode:"go",ext:["go"]},{name:"Groovy",mime:"text/x-groovy",mode:"groovy",ext:["groovy","gradle"],file:/^Jenkinsfile$/},{name:"HAML",mime:"text/x-haml",mode:"haml",ext:["haml"]},{name:"Haskell",mime:"text/x-haskell",mode:"haskell",ext:["hs"]},{name:"Haskell (Literate)",mime:"text/x-literate-haskell",mode:"haskell-literate",ext:["lhs"]},{name:"Haxe",mime:"text/x-haxe",mode:"haxe",ext:["hx"]},{name:"HXML",mime:"text/x-hxml",mode:"haxe",ext:["hxml"]},{name:"ASP.NET",mime:"application/x-aspx",mode:"htmlembedded",ext:["aspx"],alias:["asp","aspx"]},{name:"HTML",mime:"text/html",mode:"htmlmixed",ext:["html","htm","handlebars","hbs"],alias:["xhtml"]},{name:"HTTP",mime:"message/http",mode:"http"},{name:"IDL",mime:"text/x-idl",mode:"idl",ext:["pro"]},{name:"Pug",mime:"text/x-pug",mode:"pug",ext:["jade","pug"],alias:["jade"]},{name:"Java",mime:"text/x-java",mode:"clike",ext:["java"]},{name:"Java Server Pages",mime:"application/x-jsp",mode:"htmlembedded",ext:["jsp"],alias:["jsp"]},{name:"JavaScript",mimes:["text/javascript","text/ecmascript","application/javascript","application/x-javascript","application/ecmascript"],mode:"javascript",ext:["js"],alias:["ecmascript","js","node"]},{name:"JSON",mimes:["application/json","application/x-json"],mode:"javascript",ext:["json","map"],alias:["json5"]},{name:"JSON-LD",mime:"application/ld+json",mode:"javascript",ext:["jsonld"],alias:["jsonld"]},{name:"JSX",mime:"text/jsx",mode:"jsx",ext:["jsx"]},{name:"Jinja2",mime:"text/jinja2",mode:"jinja2",ext:["j2","jinja","jinja2"]},{name:"Julia",mime:"text/x-julia",mode:"julia",ext:["jl"],alias:["jl"]},{name:"Kotlin",mime:"text/x-kotlin",mode:"clike",ext:["kt"]},{name:"LESS",mime:"text/x-less",mode:"css",ext:["less"]},{name:"LiveScript",mime:"text/x-livescript",mode:"livescript",ext:["ls"],alias:["ls"]},{name:"Lua",mime:"text/x-lua",mode:"lua",ext:["lua"]},{name:"Markdown",mime:"text/x-markdown",mode:"markdown",ext:["markdown","md","mkd"]},{name:"mIRC",mime:"text/mirc",mode:"mirc"},{name:"MariaDB SQL",mime:"text/x-mariadb",mode:"sql"},{name:"Mathematica",mime:"text/x-mathematica",mode:"mathematica",ext:["m","nb","wl","wls"]},{name:"Modelica",mime:"text/x-modelica",mode:"modelica",ext:["mo"]},{name:"MUMPS",mime:"text/x-mumps",mode:"mumps",ext:["mps"]},{name:"MS SQL",mime:"text/x-mssql",mode:"sql"},{name:"mbox",mime:"application/mbox",mode:"mbox",ext:["mbox"]},{name:"MySQL",mime:"text/x-mysql",mode:"sql"},{name:"Nginx",mime:"text/x-nginx-conf",mode:"nginx",file:/nginx.*\.conf$/i},{name:"NSIS",mime:"text/x-nsis",mode:"nsis",ext:["nsh","nsi"]},{name:"NTriples",mimes:["application/n-triples","application/n-quads","text/n-triples"],mode:"ntriples",ext:["nt","nq"]},{name:"Objective-C",mime:"text/x-objectivec",mode:"clike",ext:["m"],alias:["objective-c","objc"]},{name:"Objective-C++",mime:"text/x-objectivec++",mode:"clike",ext:["mm"],alias:["objective-c++","objc++"]},{name:"OCaml",mime:"text/x-ocaml",mode:"mllike",ext:["ml","mli","mll","mly"]},{name:"Octave",mime:"text/x-octave",mode:"octave",ext:["m"]},{name:"Oz",mime:"text/x-oz",mode:"oz",ext:["oz"]},{name:"Pascal",mime:"text/x-pascal",mode:"pascal",ext:["p","pas"]},{name:"PEG.js",mime:"null",mode:"pegjs",ext:["jsonld"]},{name:"Perl",mime:"text/x-perl",mode:"perl",ext:["pl","pm"]},{name:"PHP",mimes:["text/x-php","application/x-httpd-php","application/x-httpd-php-open"],mode:"php",ext:["php","php3","php4","php5","php7","phtml"]},{name:"Pig",mime:"text/x-pig",mode:"pig",ext:["pig"]},{name:"Plain Text",mime:"text/plain",mode:"null",ext:["txt","text","conf","def","list","log"]},{name:"PLSQL",mime:"text/x-plsql",mode:"sql",ext:["pls"]},{name:"PostgreSQL",mime:"text/x-pgsql",mode:"sql"},{name:"PowerShell",mime:"application/x-powershell",mode:"powershell",ext:["ps1","psd1","psm1"]},{name:"Properties files",mime:"text/x-properties",mode:"properties",ext:["properties","ini","in"],alias:["ini","properties"]},{name:"ProtoBuf",mime:"text/x-protobuf",mode:"protobuf",ext:["proto"]},{name:"Python",mime:"text/x-python",mode:"python",ext:["BUILD","bzl","py","pyw"],file:/^(BUCK|BUILD)$/},{name:"Puppet",mime:"text/x-puppet",mode:"puppet",ext:["pp"]},{name:"Q",mime:"text/x-q",mode:"q",ext:["q"]},{name:"R",mime:"text/x-rsrc",mode:"r",ext:["r","R"],alias:["rscript"]},{name:"reStructuredText",mime:"text/x-rst",mode:"rst",ext:["rst"],alias:["rst"]},{name:"RPM Changes",mime:"text/x-rpm-changes",mode:"rpm"},{name:"RPM Spec",mime:"text/x-rpm-spec",mode:"rpm",ext:["spec"]},{name:"Ruby",mime:"text/x-ruby",mode:"ruby",ext:["rb"],alias:["jruby","macruby","rake","rb","rbx"]},{name:"Rust",mime:"text/x-rustsrc",mode:"rust",ext:["rs"]},{name:"SAS",mime:"text/x-sas",mode:"sas",ext:["sas"]},{name:"Sass",mime:"text/x-sass",mode:"sass",ext:["sass"]},{name:"Scala",mime:"text/x-scala",mode:"clike",ext:["scala"]},{name:"Scheme",mime:"text/x-scheme",mode:"scheme",ext:["scm","ss"]},{name:"SCSS",mime:"text/x-scss",mode:"css",ext:["scss"]},{name:"Shell",mimes:["text/x-sh","application/x-sh"],mode:"shell",ext:["sh","ksh","bash"],alias:["bash","sh","zsh"],file:/^PKGBUILD$/},{name:"Sieve",mime:"application/sieve",mode:"sieve",ext:["siv","sieve"]},{name:"Slim",mimes:["text/x-slim","application/x-slim"],mode:"slim",ext:["slim"]},{name:"Smalltalk",mime:"text/x-stsrc",mode:"smalltalk",ext:["st"]},{name:"Smarty",mime:"text/x-smarty",mode:"smarty",ext:["tpl"]},{name:"Solr",mime:"text/x-solr",mode:"solr"},{name:"SML",mime:"text/x-sml",mode:"mllike",ext:["sml","sig","fun","smackspec"]},{name:"Soy",mime:"text/x-soy",mode:"soy",ext:["soy"],alias:["closure template"]},{name:"SPARQL",mime:"application/sparql-query",mode:"sparql",ext:["rq","sparql"],alias:["sparul"]},{name:"Spreadsheet",mime:"text/x-spreadsheet",mode:"spreadsheet",alias:["excel","formula"]},{name:"SQL",mime:"text/x-sql",mode:"sql",ext:["sql"]},{name:"SQLite",mime:"text/x-sqlite",mode:"sql"},{name:"Squirrel",mime:"text/x-squirrel",mode:"clike",ext:["nut"]},{name:"Stylus",mime:"text/x-styl",mode:"stylus",ext:["styl"]},{name:"Swift",mime:"text/x-swift",mode:"swift",ext:["swift"]},{name:"sTeX",mime:"text/x-stex",mode:"stex"},{name:"LaTeX",mime:"text/x-latex",mode:"stex",ext:["text","ltx","tex"],alias:["tex"]},{name:"SystemVerilog",mime:"text/x-systemverilog",mode:"verilog",ext:["v","sv","svh"]},{name:"Tcl",mime:"text/x-tcl",mode:"tcl",ext:["tcl"]},{name:"Textile",mime:"text/x-textile",mode:"textile",ext:["textile"]},{name:"TiddlyWiki",mime:"text/x-tiddlywiki",mode:"tiddlywiki"},{name:"Tiki wiki",mime:"text/tiki",mode:"tiki"},{name:"TOML",mime:"text/x-toml",mode:"toml",ext:["toml"]},{name:"Tornado",mime:"text/x-tornado",mode:"tornado"},{name:"troff",mime:"text/troff",mode:"troff",ext:["1","2","3","4","5","6","7","8","9"]},{name:"TTCN",mime:"text/x-ttcn",mode:"ttcn",ext:["ttcn","ttcn3","ttcnpp"]},{name:"TTCN_CFG",mime:"text/x-ttcn-cfg",mode:"ttcn-cfg",ext:["cfg"]},{name:"Turtle",mime:"text/turtle",mode:"turtle",ext:["ttl"]},{name:"TypeScript",mime:"application/typescript",mode:"javascript",ext:["ts"],alias:["ts"]},{name:"TypeScript-JSX",mime:"text/typescript-jsx",mode:"jsx",ext:["tsx"],alias:["tsx"]},{name:"Twig",mime:"text/x-twig",mode:"twig"},{name:"Web IDL",mime:"text/x-webidl",mode:"webidl",ext:["webidl"]},{name:"VB.NET",mime:"text/x-vb",mode:"vb",ext:["vb"]},{name:"VBScript",mime:"text/vbscript",mode:"vbscript",ext:["vbs"]},{name:"Velocity",mime:"text/velocity",mode:"velocity",ext:["vtl"]},{name:"Verilog",mime:"text/x-verilog",mode:"verilog",ext:["v"]},{name:"VHDL",mime:"text/x-vhdl",mode:"vhdl",ext:["vhd","vhdl"]},{name:"Vue.js Component",mimes:["script/x-vue","text/x-vue"],mode:"vue",ext:["vue"]},{name:"XML",mimes:["application/xml","text/xml"],mode:"xml",ext:["xml","xsl","xsd","svg"],alias:["rss","wsdl","xsd"]},{name:"XQuery",mime:"application/xquery",mode:"xquery",ext:["xy","xquery"]},{name:"Yacas",mime:"text/x-yacas",mode:"yacas",ext:["ys"]},{name:"YAML",mimes:["text/x-yaml","text/yaml"],mode:"yaml",ext:["yaml","yml"],alias:["yml"]},{name:"Z80",mime:"text/x-z80",mode:"z80",ext:["z80"]},{name:"mscgen",mime:"text/x-mscgen",mode:"mscgen",ext:["mscgen","mscin","msc"]},{name:"xu",mime:"text/x-xu",mode:"mscgen",ext:["xu"]},{name:"msgenny",mime:"text/x-msgenny",mode:"mscgen",ext:["msgenny"]},{name:"WebAssembly",mime:"text/webassembly",mode:"wast",ext:["wat","wast"]}];for(var t=0;t-1&&t.substring(r+1,t.length);if(o)return e.findModeByExtension(o)},e.findModeByName=function(t){t=t.toLowerCase();for(var n=0;n")):null:e.match("--")?n(f("comment","--\x3e")):e.match("DOCTYPE",!0,!0)?(e.eatWhile(/[\w\._\-]/),n(p(1))):null:e.eat("?")?(e.eatWhile(/[\w\._\-]/),t.tokenize=f("meta","?>"),"meta"):(o=e.eat("/")?"closeTag":"openTag",t.tokenize=h,"tag bracket"):"&"==i?(e.eat("#")?e.eat("x")?e.eatWhile(/[a-fA-F\d]/)&&e.eat(";"):e.eatWhile(/[\d]/)&&e.eat(";"):e.eatWhile(/[\w\.\-:]/)&&e.eat(";"))?"atom":"error":(e.eatWhile(/[^&<]/),null)}function h(e,t){var n,i,r=e.next();if(">"==r||"/"==r&&e.eat(">"))return t.tokenize=d,o=">"==r?"endTag":"selfcloseTag","tag bracket";if("="==r)return o="equals",null;if("<"==r){t.tokenize=d,t.state=y,t.tagName=t.tagStart=null;var a=t.tokenize(e,t);return a?a+" tag error":"tag error"}return/[\'\"]/.test(r)?(t.tokenize=(n=r,i=function(e,t){for(;!e.eol();)if(e.next()==n){t.tokenize=h;break}return"string"},i.isInAttribute=!0,i),t.stringStartCol=e.column(),t.tokenize(e,t)):(e.match(/^[^\s\u00a0=<>\"\']*[^\s\u00a0=<>\"\'\/]/),"word")}function f(e,t){return function(n,i){for(;!n.eol();){if(n.match(t)){i.tokenize=d;break}n.next()}return e}}function p(e){return function(t,n){for(var i;null!=(i=t.next());){if("<"==i)return n.tokenize=p(e+1),n.tokenize(t,n);if(">"==i){if(1==e){n.tokenize=d;break}return n.tokenize=p(e-1),n.tokenize(t,n)}}return"meta"}}function m(e){return e&&e.toLowerCase()}function g(e,t,n){this.prev=e.context,this.tagName=t||"",this.indent=e.indented,this.startOfLine=n,(s.doNotIndent.hasOwnProperty(t)||e.context&&e.context.noIndent)&&(this.noIndent=!0)}function v(e){e.context&&(e.context=e.context.prev)}function x(e,t){for(var n;;){if(!e.context)return;if(n=e.context.tagName,!s.contextGrabbers.hasOwnProperty(m(n))||!s.contextGrabbers[m(n)].hasOwnProperty(m(t)))return;v(e)}}function y(e,t,n){return"openTag"==e?(n.tagStart=t.column(),b):"closeTag"==e?D:y}function b(e,t,n){return"word"==e?(n.tagName=t.current(),a="tag",k):s.allowMissingTagName&&"endTag"==e?(a="tag bracket",k(e,0,n)):(a="error",b)}function D(e,t,n){if("word"==e){var i=t.current();return n.context&&n.context.tagName!=i&&s.implicitlyClosed.hasOwnProperty(m(n.context.tagName))&&v(n),n.context&&n.context.tagName==i||!1===s.matchClosing?(a="tag",C):(a="tag error",w)}return s.allowMissingTagName&&"endTag"==e?(a="tag bracket",C(e,0,n)):(a="error",w)}function C(e,t,n){return"endTag"!=e?(a="error",C):(v(n),y)}function w(e,t,n){return a="error",C(e,0,n)}function k(e,t,n){if("word"==e)return a="attribute",S;if("endTag"==e||"selfcloseTag"==e){var i=n.tagName,r=n.tagStart;return n.tagName=n.tagStart=null,"selfcloseTag"==e||s.autoSelfClosers.hasOwnProperty(m(i))?x(n,i):(x(n,i),n.context=new g(n,i,r==n.indented)),y}return a="error",k}function S(e,t,n){return"equals"==e?F:(s.allowMissing||(a="error"),k(e,0,n))}function F(e,t,n){return"string"==e?A:"word"==e&&s.allowUnquoted?(a="string",k):(a="error",k(e,0,n))}function A(e,t,n){return"string"==e?A:k(e,0,n)}return d.isInText=!0,{startState:function(e){var t={tokenize:d,state:y,indented:e||0,tagName:null,tagStart:null,context:null};return null!=e&&(t.baseIndent=e),t},token:function(e,t){if(!t.tagName&&e.sol()&&(t.indented=e.indentation()),e.eatSpace())return null;o=null;var n=t.tokenize(e,t);return(n||o)&&"comment"!=n&&(a=null,t.state=t.state(o||n,e,t),a&&(n="error"==a?n+" error":a)),n},indent:function(t,n,i){var r=t.context;if(t.tokenize.isInAttribute)return t.tagStart==t.indented?t.stringStartCol+1:t.indented+l;if(r&&r.noIndent)return e.Pass;if(t.tokenize!=h&&t.tokenize!=d)return i?i.match(/^(\s*)/)[0].length:0;if(t.tagName)return!1!==s.multilineTagIndentPastTag?t.tagStart+t.tagName.length+2:t.tagStart+l*(s.multilineTagIndentFactor||1);if(s.alignCDATA&&/$/,blockCommentStart:"\x3c!--",blockCommentEnd:"--\x3e",configuration:s.htmlMode?"html":"xml",helperType:s.htmlMode?"html":"xml",skipAttribute:function(e){e.state==F&&(e.state=k)},xmlCurrentTag:function(e){return e.tagName?{name:e.tagName,close:"closeTag"==e.type}:null},xmlCurrentContext:function(e){for(var t=[],n=e.context;n;n=n.prev)t.push(n.tagName);return t.reverse()}}})),e.defineMIME("text/xml","xml"),e.defineMIME("application/xml","xml"),e.mimeModes.hasOwnProperty("text/html")||e.defineMIME("text/html",{name:"xml",htmlMode:!0})})("object"==typeof n&&"object"==typeof t?e("../../lib/codemirror"):CodeMirror)},{"../../lib/codemirror":10}],15:[function(e,t,n){!function(e,i){"object"==typeof n&&void 0!==t?i(n):i((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,i=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function r(){return{async:!1,baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartLists:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1}}e.defaults={async:!1,baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartLists:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1};var o=/[&<>"']/,a=/[&<>"']/g,l=/[<>"']|&(?!#?\w+;)/,s=/[<>"']|&(?!#?\w+;)/g,u={"&":"&","<":"<",">":">",'"':""","'":"'"},c=function(e){return u[e]};function d(e,t){if(t){if(o.test(e))return e.replace(a,c)}else if(l.test(e))return e.replace(s,c);return e}var h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function f(e){return e.replace(h,(function(e,t){return"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""}))}var p=/(^|[^\[])\^/g;function m(e,t){e="string"==typeof e?e:e.source,t=t||"";var n={replace:function(t,i){return i=(i=i.source||i).replace(p,"$1"),e=e.replace(t,i),n},getRegex:function(){return new RegExp(e,t)}};return n}var g=/[^\w:]/g,v=/^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;function x(e,t,n){if(e){var i;try{i=decodeURIComponent(f(n)).replace(g,"").toLowerCase()}catch(e){return null}if(0===i.indexOf("javascript:")||0===i.indexOf("vbscript:")||0===i.indexOf("data:"))return null}t&&!v.test(n)&&(n=function(e,t){y[" "+e]||(b.test(e)?y[" "+e]=e+"/":y[" "+e]=F(e,"/",!0));var n=-1===(e=y[" "+e]).indexOf(":");return"//"===t.substring(0,2)?n?t:e.replace(D,"$1")+t:"/"===t.charAt(0)?n?t:e.replace(C,"$1")+t:e+t}(t,n));try{n=encodeURI(n).replace(/%25/g,"%")}catch(e){return null}return n}var y={},b=/^[^:]+:\/*[^/]*$/,D=/^([^:]+:)[\s\S]*$/,C=/^([^:]+:\/*[^/]*)[\s\S]*$/;var w={exec:function(){}};function k(e){for(var t,n,i=1;i=0&&"\\"===n[r];)i=!i;return i?"|":" |"})).split(/ \|/),i=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),n.length>t)n.splice(t);else for(;n.length1;)1&t&&(n+=e),t>>=1,e+=e;return n+e}function L(e,t,n,i){var r=t.href,o=t.title?d(t.title):null,a=e[1].replace(/\\([\[\]])/g,"$1");if("!"!==e[0].charAt(0)){i.state.inLink=!0;var l={type:"link",raw:n,href:r,title:o,text:a,tokens:i.inlineTokens(a)};return i.state.inLink=!1,l}return{type:"image",raw:n,href:r,title:o,text:d(a)}}var T=function(){function t(t){this.options=t||e.defaults}var n=t.prototype;return n.space=function(e){var t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}},n.code=function(e){var t=this.rules.block.code.exec(e);if(t){var n=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:F(n,"\n")}}},n.fences=function(e){var t=this.rules.block.fences.exec(e);if(t){var n=t[0],i=function(e,t){var n=e.match(/^(\s+)(?:```)/);if(null===n)return t;var i=n[1];return t.split("\n").map((function(e){var t=e.match(/^\s+/);return null===t?e:t[0].length>=i.length?e.slice(i.length):e})).join("\n")}(n,t[3]||"");return{type:"code",raw:n,lang:t[2]?t[2].trim():t[2],text:i}}},n.heading=function(e){var t=this.rules.block.heading.exec(e);if(t){var n=t[2].trim();if(/#$/.test(n)){var i=F(n,"#");this.options.pedantic?n=i.trim():i&&!/ $/.test(i)||(n=i.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}},n.hr=function(e){var t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}},n.blockquote=function(e){var t=this.rules.block.blockquote.exec(e);if(t){var n=t[0].replace(/^ *>[ \t]?/gm,"");return{type:"blockquote",raw:t[0],tokens:this.lexer.blockTokens(n,[]),text:n}}},n.list=function(e){var t=this.rules.block.list.exec(e);if(t){var n,r,o,a,l,s,u,c,d,h,f,p,m=t[1].trim(),g=m.length>1,v={type:"list",raw:"",ordered:g,start:g?+m.slice(0,-1):"",loose:!1,items:[]};m=g?"\\d{1,9}\\"+m.slice(-1):"\\"+m,this.options.pedantic&&(m=g?m:"[*+-]");for(var x=new RegExp("^( {0,3}"+m+")((?:[\t ][^\\n]*)?(?:\\n|$))");e&&(p=!1,t=x.exec(e))&&!this.rules.block.hr.test(e);){if(n=t[0],e=e.substring(n.length),c=t[2].split("\n",1)[0],d=e.split("\n",1)[0],this.options.pedantic?(a=2,f=c.trimLeft()):(a=(a=t[2].search(/[^ ]/))>4?1:a,f=c.slice(a),a+=t[1].length),s=!1,!c&&/^ *$/.test(d)&&(n+=d+"\n",e=e.substring(d.length+1),p=!0),!p)for(var y=new RegExp("^ {0,"+Math.min(3,a-1)+"}(?:[*+-]|\\d{1,9}[.)])((?: [^\\n]*)?(?:\\n|$))"),b=new RegExp("^ {0,"+Math.min(3,a-1)+"}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)"),D=new RegExp("^ {0,"+Math.min(3,a-1)+"}(?:```|~~~)"),C=new RegExp("^ {0,"+Math.min(3,a-1)+"}#");e&&(c=h=e.split("\n",1)[0],this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),!D.test(c))&&!C.test(c)&&!y.test(c)&&!b.test(e);){if(c.search(/[^ ]/)>=a||!c.trim())f+="\n"+c.slice(a);else{if(s)break;f+="\n"+c}s||c.trim()||(s=!0),n+=h+"\n",e=e.substring(h.length+1)}v.loose||(u?v.loose=!0:/\n *\n *$/.test(n)&&(u=!0)),this.options.gfm&&(r=/^\[[ xX]\] /.exec(f))&&(o="[ ] "!==r[0],f=f.replace(/^\[[ xX]\] +/,"")),v.items.push({type:"list_item",raw:n,task:!!r,checked:o,loose:!1,text:f}),v.raw+=n}v.items[v.items.length-1].raw=n.trimRight(),v.items[v.items.length-1].text=f.trimRight(),v.raw=v.raw.trimRight();var w=v.items.length;for(l=0;l1)return!0}return!1}));!v.loose&&k.length&&S&&(v.loose=!0,v.items[l].loose=!0)}return v}},n.html=function(e){var t=this.rules.block.html.exec(e);if(t){var n={type:"html",raw:t[0],pre:!this.options.sanitizer&&("pre"===t[1]||"script"===t[1]||"style"===t[1]),text:t[0]};if(this.options.sanitize){var i=this.options.sanitizer?this.options.sanitizer(t[0]):d(t[0]);n.type="paragraph",n.text=i,n.tokens=this.lexer.inline(i)}return n}},n.def=function(e){var t=this.rules.block.def.exec(e);if(t)return t[3]&&(t[3]=t[3].substring(1,t[3].length-1)),{type:"def",tag:t[1].toLowerCase().replace(/\s+/g," "),raw:t[0],href:t[2],title:t[3]}},n.table=function(e){var t=this.rules.block.table.exec(e);if(t){var n={type:"table",header:S(t[1]).map((function(e){return{text:e}})),align:t[2].replace(/^ *|\| *$/g,"").split(/ *\| */),rows:t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[]};if(n.header.length===n.align.length){n.raw=t[0];var i,r,o,a,l=n.align.length;for(i=0;i/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:this.options.sanitize?"text":"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,text:this.options.sanitize?this.options.sanitizer?this.options.sanitizer(t[0]):d(t[0]):t[0]}},n.link=function(e){var t=this.rules.inline.link.exec(e);if(t){var n=t[2].trim();if(!this.options.pedantic&&/^$/.test(n))return;var i=F(n.slice(0,-1),"\\");if((n.length-i.length)%2==0)return}else{var r=function(e,t){if(-1===e.indexOf(t[1]))return-1;for(var n=e.length,i=0,r=0;r-1){var o=(0===t[0].indexOf("!")?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,o).trim(),t[3]=""}}var a=t[2],l="";if(this.options.pedantic){var s=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(a);s&&(a=s[1],l=s[3])}else l=t[3]?t[3].slice(1,-1):"";return a=a.trim(),/^$/.test(n)?a.slice(1):a.slice(1,-1)),L(t,{href:a?a.replace(this.rules.inline._escapes,"$1"):a,title:l?l.replace(this.rules.inline._escapes,"$1"):l},t[0],this.lexer)}},n.reflink=function(e,t){var n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){var i=(n[2]||n[1]).replace(/\s+/g," ");if(!(i=t[i.toLowerCase()])||!i.href){var r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return L(n,i,n[0],this.lexer)}},n.emStrong=function(e,t,n){void 0===n&&(n="");var i=this.rules.inline.emStrong.lDelim.exec(e);if(i&&(!i[3]||!n.match(/(?:[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])/))){var r=i[1]||i[2]||"";if(!r||r&&(""===n||this.rules.inline.punctuation.exec(n))){var o,a,l=i[0].length-1,s=l,u=0,c="*"===i[0][0]?this.rules.inline.emStrong.rDelimAst:this.rules.inline.emStrong.rDelimUnd;for(c.lastIndex=0,t=t.slice(-1*e.length+l);null!=(i=c.exec(t));)if(o=i[1]||i[2]||i[3]||i[4]||i[5]||i[6])if(a=o.length,i[3]||i[4])s+=a;else if(!((i[5]||i[6])&&l%3)||(l+a)%3){if(!((s-=a)>0)){if(a=Math.min(a,a+s+u),Math.min(l,a)%2){var d=e.slice(1,l+i.index+a);return{type:"em",raw:e.slice(0,l+i.index+a+1),text:d,tokens:this.lexer.inlineTokens(d)}}var h=e.slice(2,l+i.index+a-1);return{type:"strong",raw:e.slice(0,l+i.index+a+1),text:h,tokens:this.lexer.inlineTokens(h)}}}else u+=a}}},n.codespan=function(e){var t=this.rules.inline.code.exec(e);if(t){var n=t[2].replace(/\n/g," "),i=/[^ ]/.test(n),r=/^ /.test(n)&&/ $/.test(n);return i&&r&&(n=n.substring(1,n.length-1)),n=d(n,!0),{type:"codespan",raw:t[0],text:n}}},n.br=function(e){var t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}},n.del=function(e){var t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}},n.autolink=function(e,t){var n,i,r=this.rules.inline.autolink.exec(e);if(r)return i="@"===r[2]?"mailto:"+(n=d(this.options.mangle?t(r[1]):r[1])):n=d(r[1]),{type:"link",raw:r[0],text:n,href:i,tokens:[{type:"text",raw:n,text:n}]}},n.url=function(e,t){var n;if(n=this.rules.inline.url.exec(e)){var i,r;if("@"===n[2])r="mailto:"+(i=d(this.options.mangle?t(n[0]):n[0]));else{var o;do{o=n[0],n[0]=this.rules.inline._backpedal.exec(n[0])[0]}while(o!==n[0]);i=d(n[0]),r="www."===n[1]?"http://"+i:i}return{type:"link",raw:n[0],text:i,href:r,tokens:[{type:"text",raw:i,text:i}]}}},n.inlineText=function(e,t){var n,i=this.rules.inline.text.exec(e);if(i)return n=this.lexer.state.inRawBlock?this.options.sanitize?this.options.sanitizer?this.options.sanitizer(i[0]):d(i[0]):i[0]:d(this.options.smartypants?t(i[0]):i[0]),{type:"text",raw:i[0],text:n}},t}(),M={newline:/^(?: *(?:\n|$))+/,code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,fences:/^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?=\n|$)|$)/,hr:/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/,html:"^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))",def:/^ {0,3}\[(label)\]: *(?:\n *)?([^\s>]+)>?(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,table:w,lheading:/^([^\n]+)\n {0,3}(=+|-+) *(?:\n+|$)/,_paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,text:/^[^\n]+/,_label:/(?!\s*\])(?:\\.|[^\[\]\\])+/,_title:/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/};M.def=m(M.def).replace("label",M._label).replace("title",M._title).getRegex(),M.bullet=/(?:[*+-]|\d{1,9}[.)])/,M.listItemStart=m(/^( *)(bull) */).replace("bull",M.bullet).getRegex(),M.list=m(M.list).replace(/bull/g,M.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+M.def.source+")").getRegex(),M._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",M._comment=/|$)/,M.html=m(M.html,"i").replace("comment",M._comment).replace("tag",M._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),M.paragraph=m(M._paragraph).replace("hr",M.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",M._tag).getRegex(),M.blockquote=m(M.blockquote).replace("paragraph",M.paragraph).getRegex(),M.normal=k({},M),M.gfm=k({},M.normal,{table:"^ *([^\\n ].*\\|.*)\\n {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)"}),M.gfm.table=m(M.gfm.table).replace("hr",M.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",M._tag).getRegex(),M.gfm.paragraph=m(M._paragraph).replace("hr",M.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("table",M.gfm.table).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",M._tag).getRegex(),M.pedantic=k({},M.normal,{html:m("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",M._comment).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:w,paragraph:m(M.normal._paragraph).replace("hr",M.hr).replace("heading"," *#{1,6} *[^\n]").replace("lheading",M.lheading).replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").getRegex()});var B={escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,autolink:/^<(scheme:[^\s\x00-\x1f<>]*|email)>/,url:w,tag:"^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^",link:/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,reflink:/^!?\[(label)\]\[(ref)\]/,nolink:/^!?\[(ref)\](?:\[\])?/,reflinkSearch:"reflink|nolink(?!\\()",emStrong:{lDelim:/^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/,rDelimAst:/^[^_*]*?\_\_[^_*]*?\*[^_*]*?(?=\_\_)|[^*]+(?=[^*])|[punct_](\*+)(?=[\s]|$)|[^punct*_\s](\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|[^punct*_\s](\*+)(?=[^punct*_\s])/,rDelimUnd:/^[^_*]*?\*\*[^_*]*?\_[^_*]*?(?=\*\*)|[^_]+(?=[^_])|[punct*](\_+)(?=[\s]|$)|[^punct*_\s](\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/},code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,br:/^( {2,}|\\)\n(?!\s*$)/,del:w,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\.5&&(n="x"+n.toString(16)),i+=""+n+";";return i}B._punctuation="!\"#$%&'()+\\-.,/:;<=>?@\\[\\]`^{|}~",B.punctuation=m(B.punctuation).replace(/punctuation/g,B._punctuation).getRegex(),B.blockSkip=/\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g,B.escapedEmSt=/\\\*|\\_/g,B._comment=m(M._comment).replace("(?:--\x3e|$)","--\x3e").getRegex(),B.emStrong.lDelim=m(B.emStrong.lDelim).replace(/punct/g,B._punctuation).getRegex(),B.emStrong.rDelimAst=m(B.emStrong.rDelimAst,"g").replace(/punct/g,B._punctuation).getRegex(),B.emStrong.rDelimUnd=m(B.emStrong.rDelimUnd,"g").replace(/punct/g,B._punctuation).getRegex(),B._escapes=/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g,B._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,B._email=/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/,B.autolink=m(B.autolink).replace("scheme",B._scheme).replace("email",B._email).getRegex(),B._attribute=/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/,B.tag=m(B.tag).replace("comment",B._comment).replace("attribute",B._attribute).getRegex(),B._label=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,B._href=/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/,B._title=/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/,B.link=m(B.link).replace("label",B._label).replace("href",B._href).replace("title",B._title).getRegex(),B.reflink=m(B.reflink).replace("label",B._label).replace("ref",M._label).getRegex(),B.nolink=m(B.nolink).replace("ref",M._label).getRegex(),B.reflinkSearch=m(B.reflinkSearch,"g").replace("reflink",B.reflink).replace("nolink",B.nolink).getRegex(),B.normal=k({},B),B.pedantic=k({},B.normal,{strong:{start:/^__|\*\*/,middle:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,endAst:/\*\*(?!\*)/g,endUnd:/__(?!_)/g},em:{start:/^_|\*/,middle:/^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,endAst:/\*(?!\*)/g,endUnd:/_(?!_)/g},link:m(/^!?\[(label)\]\((.*?)\)/).replace("label",B._label).getRegex(),reflink:m(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",B._label).getRegex()}),B.gfm=k({},B.normal,{escape:m(B.escape).replace("])","~|])").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),!(i=t[t.length-1])||"paragraph"!==i.type&&"text"!==i.type?t.push(n):(i.raw+="\n"+n.raw,i.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=i.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),!(i=t[t.length-1])||"paragraph"!==i.type&&"text"!==i.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(i.raw+="\n"+n.raw,i.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=i.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else if(r=e,this.options.extensions&&this.options.extensions.startBlock&&function(){var t=1/0,n=e.slice(1),i=void 0;a.options.extensions.startBlock.forEach((function(e){"number"==typeof(i=e.call({lexer:this},n))&&i>=0&&(t=Math.min(t,i))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}(),this.state.top&&(n=this.tokenizer.paragraph(r)))i=t[t.length-1],o&&"paragraph"===i.type?(i.raw+="\n"+n.raw,i.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=i.text):t.push(n),o=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),(i=t[t.length-1])&&"text"===i.type?(i.raw+="\n"+n.raw,i.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=i.text):t.push(n);else if(e){var l="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(l);break}throw new Error(l)}return this.state.top=!0,t},a.inline=function(e,t){return void 0===t&&(t=[]),this.inlineQueue.push({src:e,tokens:t}),t},a.inlineTokens=function(e,t){var n,i,r,o=this;void 0===t&&(t=[]);var a,l,s,u=e;if(this.tokens.links){var c=Object.keys(this.tokens.links);if(c.length>0)for(;null!=(a=this.tokenizer.rules.inline.reflinkSearch.exec(u));)c.includes(a[0].slice(a[0].lastIndexOf("[")+1,-1))&&(u=u.slice(0,a.index)+"["+E("a",a[0].length-2)+"]"+u.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(a=this.tokenizer.rules.inline.blockSkip.exec(u));)u=u.slice(0,a.index)+"["+E("a",a[0].length-2)+"]"+u.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(a=this.tokenizer.rules.inline.escapedEmSt.exec(u));)u=u.slice(0,a.index)+"++"+u.slice(this.tokenizer.rules.inline.escapedEmSt.lastIndex);for(;e;)if(l||(s=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((function(i){return!!(n=i.call({lexer:o},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)}))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),(i=t[t.length-1])&&"text"===n.type&&"text"===i.type?(i.raw+=n.raw,i.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),(i=t[t.length-1])&&"text"===n.type&&"text"===i.type?(i.raw+=n.raw,i.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,u,s))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e,O))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e,O))){if(r=e,this.options.extensions&&this.options.extensions.startInline&&function(){var t=1/0,n=e.slice(1),i=void 0;o.options.extensions.startInline.forEach((function(e){"number"==typeof(i=e.call({lexer:this},n))&&i>=0&&(t=Math.min(t,i))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}(),n=this.tokenizer.inlineText(r,N))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(s=n.raw.slice(-1)),l=!0,(i=t[t.length-1])&&"text"===i.type?(i.raw+=n.raw,i.text+=n.text):t.push(n);else if(e){var d="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(d);break}throw new Error(d)}}else e=e.substring(n.raw.length),t.push(n);return t},i=n,o=[{key:"rules",get:function(){return{block:M,inline:B}}}],(r=null)&&t(i.prototype,r),o&&t(i,o),Object.defineProperty(i,"prototype",{writable:!1}),n}(),z=function(){function t(t){this.options=t||e.defaults}var n=t.prototype;return n.code=function(e,t,n){var i=(t||"").match(/\S*/)[0];if(this.options.highlight){var r=this.options.highlight(e,i);null!=r&&r!==e&&(n=!0,e=r)}return e=e.replace(/\n$/,"")+"\n",i?''+(n?e:d(e,!0))+" \n":""+(n?e:d(e,!0))+" \n"},n.blockquote=function(e){return"\n"+e+" \n"},n.html=function(e){return e},n.heading=function(e,t,n,i){return this.options.headerIds?"\n":""+e+" \n"},n.hr=function(){return this.options.xhtml?" \n":" \n"},n.list=function(e,t,n){var i=t?"ol":"ul";return"<"+i+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+""+i+">\n"},n.listitem=function(e){return""+e+" \n"},n.checkbox=function(e){return" "},n.paragraph=function(e){return""+e+"
\n"},n.table=function(e,t){return t&&(t=""+t+" "),"\n"},n.tablerow=function(e){return"\n"+e+" \n"},n.tablecell=function(e,t){var n=t.header?"th":"td";return(t.align?"<"+n+' align="'+t.align+'">':"<"+n+">")+e+""+n+">\n"},n.strong=function(e){return""+e+" "},n.em=function(e){return""+e+" "},n.codespan=function(e){return""+e+""},n.br=function(){return this.options.xhtml?" ":" "},n.del=function(e){return""+e+""},n.link=function(e,t,n){if(null===(e=x(this.options.sanitize,this.options.baseUrl,e)))return n;var i='"+n+" "},n.image=function(e,t,n){if(null===(e=x(this.options.sanitize,this.options.baseUrl,e)))return n;var i=' ":">"},n.text=function(e){return e},t}(),H=function(){function e(){}var t=e.prototype;return t.strong=function(e){return e},t.em=function(e){return e},t.codespan=function(e){return e},t.del=function(e){return e},t.html=function(e){return e},t.text=function(e){return e},t.link=function(e,t,n){return""+n},t.image=function(e,t,n){return""+n},t.br=function(){return""},e}(),R=function(){function e(){this.seen={}}var t=e.prototype;return t.serialize=function(e){return e.toLowerCase().trim().replace(/<[!\/a-z].*?>/gi,"").replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g,"").replace(/\s/g,"-")},t.getNextSafeSlug=function(e,t){var n=e,i=0;if(this.seen.hasOwnProperty(n)){i=this.seen[e];do{n=e+"-"+ ++i}while(this.seen.hasOwnProperty(n))}return t||(this.seen[e]=i,this.seen[n]=0),n},t.slug=function(e,t){void 0===t&&(t={});var n=this.serialize(e);return this.getNextSafeSlug(n,t.dryrun)},e}(),P=function(){function t(t){this.options=t||e.defaults,this.options.renderer=this.options.renderer||new z,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new H,this.slugger=new R}t.parse=function(e,n){return new t(n).parse(e)},t.parseInline=function(e,n){return new t(n).parseInline(e)};var n=t.prototype;return n.parse=function(e,t){void 0===t&&(t=!0);var n,i,r,o,a,l,s,u,c,d,h,p,m,g,v,x,y,b,D,C="",w=e.length;for(n=0;n0&&"paragraph"===v.tokens[0].type?(v.tokens[0].text=b+" "+v.tokens[0].text,v.tokens[0].tokens&&v.tokens[0].tokens.length>0&&"text"===v.tokens[0].tokens[0].type&&(v.tokens[0].tokens[0].text=b+" "+v.tokens[0].tokens[0].text)):v.tokens.unshift({type:"text",text:b}):g+=b),g+=this.parse(v.tokens,m),c+=this.renderer.listitem(g,y,x);C+=this.renderer.list(c,h,p);continue;case"html":C+=this.renderer.html(d.text);continue;case"paragraph":C+=this.renderer.paragraph(this.parseInline(d.tokens));continue;case"text":for(c=d.tokens?this.parseInline(d.tokens):d.text;n+1An error occurred:
"+d(e.message+"",!0)+" ";throw e}try{var s=I.lex(e,t);if(t.walkTokens){if(t.async)return Promise.all(_.walkTokens(s,t.walkTokens)).then((function(){return P.parse(s,t)})).catch(l);_.walkTokens(s,t.walkTokens)}return P.parse(s,t)}catch(e){l(e)}}_.options=_.setOptions=function(t){var n;return k(_.defaults,t),n=_.defaults,e.defaults=n,_},_.getDefaults=r,_.defaults=e.defaults,_.use=function(){for(var e=arguments.length,t=new Array(e),n=0;nAn error occurred:"+d(e.message+"",!0)+" ";throw e}},_.Parser=P,_.parser=P.parse,_.Renderer=z,_.TextRenderer=H,_.Lexer=I,_.lexer=I.lex,_.Tokenizer=T,_.Slugger=R,_.parse=_;var W=_.options,j=_.setOptions,q=_.use,U=_.walkTokens,$=_.parseInline,G=_,V=P.parse,X=I.lex;e.Lexer=I,e.Parser=P,e.Renderer=z,e.Slugger=R,e.TextRenderer=H,e.Tokenizer=T,e.getDefaults=r,e.lexer=X,e.marked=_,e.options=W,e.parse=G,e.parseInline=$,e.parser=V,e.setOptions=j,e.use=q,e.walkTokens=U,Object.defineProperty(e,"__esModule",{value:!0})}))},{}],16:[function(e,t,n){(function(n){(function(){var i;!function(){"use strict";(i=function(e,t,i,r){r=r||{},this.dictionary=null,this.rules={},this.dictionaryTable={},this.compoundRules=[],this.compoundRuleCodes={},this.replacementTable=[],this.flags=r.flags||{},this.memoized={},this.loaded=!1;var o,a,l,s,u,c=this;function d(e,t){var n=c._readFile(e,null,r.asyncLoad);r.asyncLoad?n.then((function(e){t(e)})):t(n)}function h(e){t=e,i&&p()}function f(e){i=e,t&&p()}function p(){for(c.rules=c._parseAFF(t),c.compoundRuleCodes={},a=0,s=c.compoundRules.length;a0&&(b.continuationClasses=x),"."!==y&&(b.match="SFX"===d?new RegExp(y+"$"):new RegExp("^"+y)),"0"!=m&&(b.remove="SFX"===d?new RegExp(m+"$"):m),p.push(b)}s[h]={type:d,combineable:"Y"==f,entries:p},r+=n}else if("COMPOUNDRULE"===d){for(o=r+1,l=r+1+(n=parseInt(c[1],10));o0&&(null===n[e]&&(n[e]=[]),n[e].push(t))}for(var r=1,o=t.length;r1){var u=this.parseRuleCodes(l[1]);"NEEDAFFIX"in this.flags&&-1!=u.indexOf(this.flags.NEEDAFFIX)||i(s,u);for(var c=0,d=u.length;c=this.flags.COMPOUNDMIN)for(t=0,n=this.compoundRules.length;t1&&c[1][1]!==c[1][0]&&(o=c[0]+c[1][1]+c[1][0]+c[1].substring(2),t&&!l.check(o)||(o in a?a[o]+=1:a[o]=1)),c[1]){var d=c[1].substring(0,1).toUpperCase()===c[1].substring(0,1)?"uppercase":"lowercase";for(i=0;ii?1:t[0].localeCompare(e[0])})).reverse();var u=[],c="lowercase";e.toUpperCase()===e?c="uppercase":e.substr(0,1).toUpperCase()+e.substr(1).toLowerCase()===e&&(c="capitalized");var d=t;for(n=0;n)+?/g),s={toggleBold:x,toggleItalic:y,drawLink:O,toggleHeadingSmaller:w,toggleHeadingBigger:k,drawImage:I,toggleBlockquote:C,toggleOrderedList:B,toggleUnorderedList:M,toggleCodeBlock:D,togglePreview:U,toggleStrikethrough:b,toggleHeading1:S,toggleHeading2:F,toggleHeading3:A,toggleHeading4:E,toggleHeading5:L,toggleHeading6:T,cleanBlock:N,drawTable:P,drawHorizontalRule:_,undo:W,redo:j,toggleSideBySide:q,toggleFullScreen:v},u={toggleBold:"Cmd-B",toggleItalic:"Cmd-I",drawLink:"Cmd-K",toggleHeadingSmaller:"Cmd-H",toggleHeadingBigger:"Shift-Cmd-H",toggleHeading1:"Ctrl+Alt+1",toggleHeading2:"Ctrl+Alt+2",toggleHeading3:"Ctrl+Alt+3",toggleHeading4:"Ctrl+Alt+4",toggleHeading5:"Ctrl+Alt+5",toggleHeading6:"Ctrl+Alt+6",cleanBlock:"Cmd-E",drawImage:"Cmd-Alt-I",toggleBlockquote:"Cmd-'",toggleOrderedList:"Cmd-Alt-L",toggleUnorderedList:"Cmd-L",toggleCodeBlock:"Cmd-Alt-C",togglePreview:"Cmd-P",toggleSideBySide:"F9",toggleFullScreen:"F11"},c=function(){var e,t=!1;return e=navigator.userAgent||navigator.vendor||window.opera,(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(e)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(e.substr(0,4)))&&(t=!0),t};function d(e){return e=a?e.replace("Ctrl","Cmd"):e.replace("Cmd","Ctrl")}function h(e,t,n,i){var r=f(e,!1,t,n,"button",i);r.classList.add("easymde-dropdown"),r.onclick=function(){r.focus()};var o=document.createElement("div");o.className="easymde-dropdown-content";for(var a=0;a0){for(var g=document.createElement("i"),v=0;v=0&&!n(h=s.getLineHandle(o));o--);var g,v,x,y,b=i(s.getTokenAt({line:o,ch:1})).fencedChars;n(s.getLineHandle(u.line))?(g="",v=u.line):n(s.getLineHandle(u.line-1))?(g="",v=u.line-1):(g=b+"\n",v=u.line),n(s.getLineHandle(c.line))?(x="",y=c.line,0===c.ch&&(y+=1)):0!==c.ch&&n(s.getLineHandle(c.line+1))?(x="",y=c.line+1):(x=b+"\n",y=c.line+1),0===c.ch&&(y-=1),s.operation((function(){s.replaceRange(x,{line:y,ch:0},{line:y+(x?0:1),ch:0}),s.replaceRange(g,{line:v,ch:0},{line:v+(g?0:1),ch:0})})),s.setSelection({line:v+(g?1:0),ch:0},{line:y+(g?1:-1),ch:0}),s.focus()}else{var D=u.line;if(n(s.getLineHandle(u.line))&&("fenced"===r(s,u.line+1)?(o=u.line,D=u.line+1):(a=u.line,D=u.line-1)),void 0===o)for(o=D;o>=0&&!n(h=s.getLineHandle(o));o--);if(void 0===a)for(l=s.lineCount(),a=D;a=0;o--)if(!(h=s.getLineHandle(o)).text.match(/^\s*$/)&&"indented"!==r(s,o,h)){o+=1;break}for(l=s.lineCount(),a=u.line;a ]+|[0-9]+(.|\)))[ ]*/,""),e.replaceRange(t,{line:r,ch:0},{line:r,ch:99999999999999})}(e.codemirror)}function O(e){var t=e.options,n="https://";if(t.promptURLs){var i=prompt(t.promptTexts.link,n);if(!i)return!1;n=z(i)}X(e,"link",t.insertTexts.link,n)}function I(e){var t=e.options,n="https://";if(t.promptURLs){var i=prompt(t.promptTexts.image,n);if(!i)return!1;n=z(i)}X(e,"image",t.insertTexts.image,n)}function z(e){return encodeURI(e).replace(/([\\()])/g,"\\$1")}function H(e){e.openBrowseFileWindow()}function R(e,t){var n=e.codemirror,i=m(n),r=e.options,o=t.substr(t.lastIndexOf("/")+1),a=o.substring(o.lastIndexOf(".")+1).replace(/\?.*$/,"").toLowerCase();if(["png","jpg","jpeg","gif","svg","apng","avif","webp"].includes(a))$(n,i.image,r.insertTexts.uploadedImage,t);else{var l=r.insertTexts.link;l[0]="["+o,$(n,i.link,l,t)}e.updateStatusBar("upload-image",e.options.imageTexts.sbOnUploaded.replace("#image_name#",o)),setTimeout((function(){e.updateStatusBar("upload-image",e.options.imageTexts.sbInit)}),1e3)}function P(e){var t=e.codemirror,n=m(t),i=e.options;$(t,n.table,i.insertTexts.table)}function _(e){var t=e.codemirror,n=m(t),i=e.options;$(t,n.image,i.insertTexts.horizontalRule)}function W(e){var t=e.codemirror;t.undo(),t.focus()}function j(e){var t=e.codemirror;t.redo(),t.focus()}function q(e){var t=e.codemirror,n=t.getWrapperElement(),i=n.nextSibling,r=e.toolbarElements&&e.toolbarElements["side-by-side"],o=!1,a=n.parentNode;i.classList.contains("editor-preview-active-side")?(!1===e.options.sideBySideFullscreen&&a.classList.remove("sided--no-fullscreen"),i.classList.remove("editor-preview-active-side"),r&&r.classList.remove("active"),n.classList.remove("CodeMirror-sided")):(setTimeout((function(){t.getOption("fullScreen")||(!1===e.options.sideBySideFullscreen?a.classList.add("sided--no-fullscreen"):v(e)),i.classList.add("editor-preview-active-side")}),1),r&&r.classList.add("active"),n.classList.add("CodeMirror-sided"),o=!0);var l=n.lastChild;if(l.classList.contains("editor-preview-active")){l.classList.remove("editor-preview-active");var s=e.toolbarElements.preview,u=e.toolbar_div;s.classList.remove("active"),u.classList.remove("disabled-for-preview")}if(t.sideBySideRenderingFunction||(t.sideBySideRenderingFunction=function(){var t=e.options.previewRender(e.value(),i);null!=t&&(i.innerHTML=t)}),o){var c=e.options.previewRender(e.value(),i);null!=c&&(i.innerHTML=c),t.on("update",t.sideBySideRenderingFunction)}else t.off("update",t.sideBySideRenderingFunction);t.refresh()}function U(e){var t=e.codemirror,n=t.getWrapperElement(),i=e.toolbar_div,r=!!e.options.toolbar&&e.toolbarElements.preview,o=n.lastChild;if(t.getWrapperElement().nextSibling.classList.contains("editor-preview-active-side")&&q(e),!o||!o.classList.contains("editor-preview-full")){if((o=document.createElement("div")).className="editor-preview-full",e.options.previewClass)if(Array.isArray(e.options.previewClass))for(var a=0;a\s+/,"unordered-list":i,"ordered-list":i},u=function(e,t,o){var a=i.exec(t),l=function(e,t){return{quote:">","unordered-list":n,"ordered-list":"%%i."}[e].replace("%%i",t)}(e,c);return null!==a?(function(e,t){var i=new RegExp({quote:">","unordered-list":"\\"+n,"ordered-list":"\\d+."}[e]);return t&&i.test(t)}(e,a[2])&&(l=""),t=a[1]+l+a[3]+t.replace(r,"").replace(s[e],"$1")):0==o&&(t=l+" "+t),t},c=1,d=a.line;d<=l.line;d++)!function(n){var i=e.getLine(n);o[t]?i=i.replace(s[t],"$1"):("unordered-list"==t&&(i=u("ordered-list",i,!0)),i=u(t,i,!1),c+=1),e.replaceRange(i,{line:n,ch:0},{line:n,ch:99999999999999})}(d);e.focus()}}function X(e,t,n,i){if(e.codemirror&&!e.isPreviewActive()){var r=e.codemirror,o=m(r)[t];if(o){var a=r.getCursor("start"),l=r.getCursor("end"),s=r.getLine(a.line),u=s.slice(0,a.ch),c=s.slice(a.ch);"link"==t?u=u.replace(/(.*)[^!]\[/,"$1"):"image"==t&&(u=u.replace(/(.*)!\[$/,"$1")),c=c.replace(/]\(.*?\)/,""),r.replaceRange(u+c,{line:a.line,ch:0},{line:a.line,ch:99999999999999}),a.ch-=n[0].length,a!==l&&(l.ch-=n[0].length),r.setSelection(a,l),r.focus()}else $(r,o,n,i)}}function K(e,t,n,i){if(e.codemirror&&!e.isPreviewActive()){i=void 0===i?n:i;var r,o=e.codemirror,a=m(o),l=n,s=i,u=o.getCursor("start"),c=o.getCursor("end");a[t]?(l=(r=o.getLine(u.line)).slice(0,u.ch),s=r.slice(u.ch),"bold"==t?(l=l.replace(/(\*\*|__)(?![\s\S]*(\*\*|__))/,""),s=s.replace(/(\*\*|__)/,"")):"italic"==t?(l=l.replace(/(\*|_)(?![\s\S]*(\*|_))/,""),s=s.replace(/(\*|_)/,"")):"strikethrough"==t&&(l=l.replace(/(\*\*|~~)(?![\s\S]*(\*\*|~~))/,""),s=s.replace(/(\*\*|~~)/,"")),o.replaceRange(l+s,{line:u.line,ch:0},{line:u.line,ch:99999999999999}),"bold"==t||"strikethrough"==t?(u.ch-=2,u!==c&&(c.ch-=2)):"italic"==t&&(u.ch-=1,u!==c&&(c.ch-=1))):(r=o.getSelection(),"bold"==t?r=(r=r.split("**").join("")).split("__").join(""):"italic"==t?r=(r=r.split("*").join("")).split("_").join(""):"strikethrough"==t&&(r=r.split("~~").join("")),o.replaceSelection(l+r+s),u.ch+=n.length,c.ch=u.ch+r.length),o.setSelection(u,c),o.focus()}}function Z(e,t){if(Math.abs(e)<1024)return""+e+t[0];var n=0;do{e/=1024,++n}while(Math.abs(e)>=1024&&n=19968?n+=t[i].length:n+=1;return n}var ee={bold:"fa fa-bold",italic:"fa fa-italic",strikethrough:"fa fa-strikethrough",heading:"fa fa-header fa-heading","heading-smaller":"fa fa-header fa-heading header-smaller","heading-bigger":"fa fa-header fa-heading header-bigger","heading-1":"fa fa-header fa-heading header-1","heading-2":"fa fa-header fa-heading header-2","heading-3":"fa fa-header fa-heading header-3",code:"fa fa-code",quote:"fa fa-quote-left","ordered-list":"fa fa-list-ol","unordered-list":"fa fa-list-ul","clean-block":"fa fa-eraser",link:"fa fa-link",image:"fa fa-image","upload-image":"fa fa-image",table:"fa fa-table","horizontal-rule":"fa fa-minus",preview:"fa fa-eye","side-by-side":"fa fa-columns",fullscreen:"fa fa-arrows-alt",guide:"fa fa-question-circle",undo:"fa fa-undo",redo:"fa fa-repeat fa-redo"},te={bold:{name:"bold",action:x,className:ee.bold,title:"Bold",default:!0},italic:{name:"italic",action:y,className:ee.italic,title:"Italic",default:!0},strikethrough:{name:"strikethrough",action:b,className:ee.strikethrough,title:"Strikethrough"},heading:{name:"heading",action:w,className:ee.heading,title:"Heading",default:!0},"heading-smaller":{name:"heading-smaller",action:w,className:ee["heading-smaller"],title:"Smaller Heading"},"heading-bigger":{name:"heading-bigger",action:k,className:ee["heading-bigger"],title:"Bigger Heading"},"heading-1":{name:"heading-1",action:S,className:ee["heading-1"],title:"Big Heading"},"heading-2":{name:"heading-2",action:F,className:ee["heading-2"],title:"Medium Heading"},"heading-3":{name:"heading-3",action:A,className:ee["heading-3"],title:"Small Heading"},"separator-1":{name:"separator-1"},code:{name:"code",action:D,className:ee.code,title:"Code"},quote:{name:"quote",action:C,className:ee.quote,title:"Quote",default:!0},"unordered-list":{name:"unordered-list",action:M,className:ee["unordered-list"],title:"Generic List",default:!0},"ordered-list":{name:"ordered-list",action:B,className:ee["ordered-list"],title:"Numbered List",default:!0},"clean-block":{name:"clean-block",action:N,className:ee["clean-block"],title:"Clean block"},"separator-2":{name:"separator-2"},link:{name:"link",action:O,className:ee.link,title:"Create Link",default:!0},image:{name:"image",action:I,className:ee.image,title:"Insert Image",default:!0},"upload-image":{name:"upload-image",action:H,className:ee["upload-image"],title:"Import an image"},table:{name:"table",action:P,className:ee.table,title:"Insert Table"},"horizontal-rule":{name:"horizontal-rule",action:_,className:ee["horizontal-rule"],title:"Insert Horizontal Line"},"separator-3":{name:"separator-3"},preview:{name:"preview",action:U,className:ee.preview,noDisable:!0,title:"Toggle Preview",default:!0},"side-by-side":{name:"side-by-side",action:q,className:ee["side-by-side"],noDisable:!0,noMobile:!0,title:"Toggle Side by Side",default:!0},fullscreen:{name:"fullscreen",action:v,className:ee.fullscreen,noDisable:!0,noMobile:!0,title:"Toggle Fullscreen",default:!0},"separator-4":{name:"separator-4"},guide:{name:"guide",action:"https://www.markdownguide.org/basic-syntax/",className:ee.guide,noDisable:!0,title:"Markdown Guide",default:!0},"separator-5":{name:"separator-5"},undo:{name:"undo",action:W,className:ee.undo,noDisable:!0,title:"Undo"},redo:{name:"redo",action:j,className:ee.redo,noDisable:!0,title:"Redo"}},ne={link:["[","](#url#)"],image:[""],uploadedImage:["",""],table:["","\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |\n\n"],horizontalRule:["","\n\n-----\n\n"]},ie={link:"URL for the link:",image:"URL of the image:"},re={locale:"en-US",format:{hour:"2-digit",minute:"2-digit"}},oe={bold:"**",code:"```",italic:"*"},ae={sbInit:"Attach files by drag and dropping or pasting from clipboard.",sbOnDragEnter:"Drop image to upload it.",sbOnDrop:"Uploading image #images_names#...",sbProgress:"Uploading #file_name#: #progress#%",sbOnUploaded:"Uploaded #image_name#",sizeUnits:" B, KB, MB"},le={noFileGiven:"You must select a file.",typeNotAllowed:"This image type is not allowed.",fileTooLarge:"Image #image_name# is too big (#image_size#).\nMaximum file size is #image_max_size#.",importError:"Something went wrong when uploading the image #image_name#."};function se(e){(e=e||{}).parent=this;var t=!0;if(!1===e.autoDownloadFontAwesome&&(t=!1),!0!==e.autoDownloadFontAwesome)for(var n=document.styleSheets,i=0;i-1&&(t=!1);if(t){var r=document.createElement("link");r.rel="stylesheet",r.href="https://maxcdn.bootstrapcdn.com/font-awesome/latest/css/font-awesome.min.css",document.getElementsByTagName("head")[0].appendChild(r)}if(e.element)this.element=e.element;else if(null===e.element)return void console.log("EasyMDE: Error. No element was found.");if(void 0===e.toolbar)for(var o in e.toolbar=[],te)Object.prototype.hasOwnProperty.call(te,o)&&(-1!=o.indexOf("separator-")&&e.toolbar.push("|"),(!0===te[o].default||e.showIcons&&e.showIcons.constructor===Array&&-1!=e.showIcons.indexOf(o))&&e.toolbar.push(o));if(Object.prototype.hasOwnProperty.call(e,"previewClass")||(e.previewClass="editor-preview"),Object.prototype.hasOwnProperty.call(e,"status")||(e.status=["autosave","lines","words","cursor"],e.uploadImage&&e.status.unshift("upload-image")),e.previewRender||(e.previewRender=function(e){return this.parent.markdown(e)}),e.parsingConfig=Q({highlightFormatting:!0},e.parsingConfig||{}),e.insertTexts=Q({},ne,e.insertTexts||{}),e.promptTexts=Q({},ie,e.promptTexts||{}),e.blockStyles=Q({},oe,e.blockStyles||{}),null!=e.autosave&&(e.autosave.timeFormat=Q({},re,e.autosave.timeFormat||{})),e.iconClassMap=Q({},ee,e.iconClassMap||{}),e.shortcuts=Q({},u,e.shortcuts||{}),e.maxHeight=e.maxHeight||void 0,e.direction=e.direction||"ltr",void 0!==e.maxHeight?e.minHeight=e.maxHeight:e.minHeight=e.minHeight||"300px",e.errorCallback=e.errorCallback||function(e){alert(e)},e.uploadImage=e.uploadImage||!1,e.imageMaxSize=e.imageMaxSize||2097152,e.imageAccept=e.imageAccept||"image/png, image/jpeg, image/gif, image/avif",e.imageTexts=Q({},ae,e.imageTexts||{}),e.errorMessages=Q({},le,e.errorMessages||{}),e.imagePathAbsolute=e.imagePathAbsolute||!1,e.imageCSRFName=e.imageCSRFName||"csrfmiddlewaretoken",e.imageCSRFHeader=e.imageCSRFHeader||!1,e.imageInputName=e.imageInputName||"image",null!=e.autosave&&null!=e.autosave.unique_id&&""!=e.autosave.unique_id&&(e.autosave.uniqueId=e.autosave.unique_id),e.overlayMode&&void 0===e.overlayMode.combine&&(e.overlayMode.combine=!0),this.options=e,this.render(),!e.initialValue||this.options.autosave&&!0===this.options.autosave.foundSavedValue||this.value(e.initialValue),e.uploadImage){var a=this;this.codemirror.on("dragenter",(function(e,t){a.updateStatusBar("upload-image",a.options.imageTexts.sbOnDragEnter),t.stopPropagation(),t.preventDefault()})),this.codemirror.on("dragend",(function(e,t){a.updateStatusBar("upload-image",a.options.imageTexts.sbInit),t.stopPropagation(),t.preventDefault()})),this.codemirror.on("dragleave",(function(e,t){a.updateStatusBar("upload-image",a.options.imageTexts.sbInit),t.stopPropagation(),t.preventDefault()})),this.codemirror.on("dragover",(function(e,t){a.updateStatusBar("upload-image",a.options.imageTexts.sbOnDragEnter),t.stopPropagation(),t.preventDefault()})),this.codemirror.on("drop",(function(t,n){n.stopPropagation(),n.preventDefault(),e.imageUploadFunction?a.uploadImagesUsingCustomFunction(e.imageUploadFunction,n.dataTransfer.files):a.uploadImages(n.dataTransfer.files)})),this.codemirror.on("paste",(function(t,n){e.imageUploadFunction?a.uploadImagesUsingCustomFunction(e.imageUploadFunction,n.clipboardData.files):a.uploadImages(n.clipboardData.files)}))}}function ue(){if("object"!=typeof localStorage)return!1;try{localStorage.setItem("smde_localStorage",1),localStorage.removeItem("smde_localStorage")}catch(e){return!1}return!0}se.prototype.uploadImages=function(e,t,n){if(0!==e.length){for(var i=[],r=0;r$/,' target="_blank">');e=e.replace(n,i)}}return e}(i))}},se.prototype.render=function(e){if(e||(e=this.element||document.getElementsByTagName("textarea")[0]),!this._rendered||this._rendered!==e){this.element=e;var t,n,o=this.options,a=this,l={};for(var u in o.shortcuts)null!==o.shortcuts[u]&&null!==s[u]&&function(e){l[d(o.shortcuts[e])]=function(){var t=s[e];"function"==typeof t?t(a):"string"==typeof t&&window.open(t,"_blank")}}(u);if(l.Enter="newlineAndIndentContinueMarkdownList",l.Tab="tabAndIndentMarkdownList",l["Shift-Tab"]="shiftTabAndUnindentMarkdownList",l.Esc=function(e){e.getOption("fullScreen")&&v(a)},this.documentOnKeyDown=function(e){27==(e=e||window.event).keyCode&&a.codemirror.getOption("fullScreen")&&v(a)},document.addEventListener("keydown",this.documentOnKeyDown,!1),o.overlayMode?(i.defineMode("overlay-mode",(function(e){return i.overlayMode(i.getMode(e,!1!==o.spellChecker?"spell-checker":"gfm"),o.overlayMode.mode,o.overlayMode.combine)})),t="overlay-mode",(n=o.parsingConfig).gitHubSpice=!1):((t=o.parsingConfig).name="gfm",t.gitHubSpice=!1),!1!==o.spellChecker&&(t="spell-checker",(n=o.parsingConfig).name="gfm",n.gitHubSpice=!1,"function"==typeof o.spellChecker?o.spellChecker({codeMirrorInstance:i}):r({codeMirrorInstance:i})),this.codemirror=i.fromTextArea(e,{mode:t,backdrop:n,theme:null!=o.theme?o.theme:"easymde",tabSize:null!=o.tabSize?o.tabSize:2,indentUnit:null!=o.tabSize?o.tabSize:2,indentWithTabs:!1!==o.indentWithTabs,lineNumbers:!0===o.lineNumbers,autofocus:!0===o.autofocus,extraKeys:l,direction:o.direction,lineWrapping:!1!==o.lineWrapping,allowDropFileTypes:["text/plain"],placeholder:o.placeholder||e.getAttribute("placeholder")||"",styleSelectedText:null!=o.styleSelectedText?o.styleSelectedText:!c(),scrollbarStyle:null!=o.scrollbarStyle?o.scrollbarStyle:"native",configureMouse:function(e,t,n){return{addNew:!1}},inputStyle:null!=o.inputStyle?o.inputStyle:c()?"contenteditable":"textarea",spellcheck:null==o.nativeSpellcheck||o.nativeSpellcheck,autoRefresh:null!=o.autoRefresh&&o.autoRefresh}),this.codemirror.getScrollerElement().style.minHeight=o.minHeight,void 0!==o.maxHeight&&(this.codemirror.getScrollerElement().style.height=o.maxHeight),!0===o.forceSync){var h=this.codemirror;h.on("change",(function(){h.save()}))}this.gui={};var f=document.createElement("div");f.classList.add("EasyMDEContainer"),f.setAttribute("role","application");var p=this.codemirror.getWrapperElement();p.parentNode.insertBefore(f,p),f.appendChild(p),!1!==o.toolbar&&(this.gui.toolbar=this.createToolbar()),!1!==o.status&&(this.gui.statusbar=this.createStatusbar()),null!=o.autosave&&!0===o.autosave.enabled&&(this.autosave(),this.codemirror.on("change",(function(){clearTimeout(a._autosave_timeout),a._autosave_timeout=setTimeout((function(){a.autosave()}),a.options.autosave.submit_delay||a.options.autosave.delay||1e3)})));var m=this;this.codemirror.on("update",(function(){o.previewImagesInEditor&&f.querySelectorAll(".cm-image-marker").forEach((function(e){var t=e.parentElement;if(t.innerText.match(/^!\[.*?\]\(.*\)/g)&&!t.hasAttribute("data-img-src")){var n=t.innerText.match(/!\[.*?\]\((.*?)\)/);if(window.EMDEimagesCache||(window.EMDEimagesCache={}),n&&n.length>=2){var i=n[1];if(o.imagesPreviewHandler){var r=o.imagesPreviewHandler(n[1]);"string"==typeof r&&(i=r)}if(window.EMDEimagesCache[i])x(t,window.EMDEimagesCache[i]);else{window.EMDEimagesCache[i]={};var a=document.createElement("img");a.onload=function(){window.EMDEimagesCache[i]={naturalWidth:a.naturalWidth,naturalHeight:a.naturalHeight,url:i},x(t,window.EMDEimagesCache[i])},a.src=i}}}}))})),this.gui.sideBySide=this.createSideBySide(),this._rendered=this.element,(!0===o.autofocus||e.autofocus)&&this.codemirror.focus();var g=this.codemirror;setTimeout(function(){g.refresh()}.bind(g),0)}function x(e,t){var n,i,r=new URL(t.url,document.baseURI).href;e.setAttribute("data-img-src",r),e.setAttribute("style","--bg-image:url("+r+");--width:"+t.naturalWidth+"px;--height:"+(n=t.naturalWidth,i=t.naturalHeight,nthis.options.imageMaxSize)r(o(this.options.errorMessages.fileTooLarge));else{var a=new FormData;a.append("image",e),i.options.imageCSRFToken&&!i.options.imageCSRFHeader&&a.append(i.options.imageCSRFName,i.options.imageCSRFToken);var l=new XMLHttpRequest;l.upload.onprogress=function(t){if(t.lengthComputable){var n=""+Math.round(100*t.loaded/t.total);i.updateStatusBar("upload-image",i.options.imageTexts.sbProgress.replace("#file_name#",e.name).replace("#progress#",n))}},l.open("POST",this.options.imageUploadEndpoint),i.options.imageCSRFToken&&i.options.imageCSRFHeader&&l.setRequestHeader(i.options.imageCSRFName,i.options.imageCSRFToken),l.onload=function(){try{var e=JSON.parse(this.responseText)}catch(e){return console.error("EasyMDE: The server did not return a valid json."),void r(o(i.options.errorMessages.importError))}200===this.status&&e&&!e.error&&e.data&&e.data.filePath?t((i.options.imagePathAbsolute?"":window.location.origin+"/")+e.data.filePath):e.error&&e.error in i.options.errorMessages?r(o(i.options.errorMessages[e.error])):e.error?r(o(e.error)):(console.error("EasyMDE: Received an unexpected response after uploading the image."+this.status+" ("+this.statusText+")"),r(o(i.options.errorMessages.importError)))},l.onerror=function(e){console.error("EasyMDE: An unexpected error occurred when trying to upload the image."+e.target.status+" ("+e.target.statusText+")"),r(i.options.errorMessages.importError)},l.send(a)}},se.prototype.uploadImageUsingCustomFunction=function(e,t){var n=this;e.apply(this,[t,function(e){R(n,e)},function(e){var i=function(e){var i=n.options.imageTexts.sizeUnits.split(",");return e.replace("#image_name#",t.name).replace("#image_size#",Z(t.size,i)).replace("#image_max_size#",Z(n.options.imageMaxSize,i))}(e);n.updateStatusBar("upload-image",i),setTimeout((function(){n.updateStatusBar("upload-image",n.options.imageTexts.sbInit)}),1e4),n.options.errorCallback(i)}])},se.prototype.setPreviewMaxHeight=function(){var e=this.codemirror.getWrapperElement(),t=e.nextSibling,n=parseInt(window.getComputedStyle(e).paddingTop),i=parseInt(window.getComputedStyle(e).borderTopWidth),r=(parseInt(this.options.maxHeight)+2*n+2*i).toString()+"px";t.style.height=r},se.prototype.createSideBySide=function(){var e=this.codemirror,t=e.getWrapperElement(),n=t.nextSibling;if(!n||!n.classList.contains("editor-preview-side")){if((n=document.createElement("div")).className="editor-preview-side",this.options.previewClass)if(Array.isArray(this.options.previewClass))for(var i=0;i{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^
+]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(`
+`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(`
+`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,`
+`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],`
+`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],`
+`).split(`
+`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(`
+`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(`
+`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+`
+`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(`
+`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=`
+`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=`
+`+g}!T&&!g.trim()&&(T=!0),p+=G+`
+`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;cd.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
+`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
+`),this.blockTokens(e,this.tokens);for(let t=0;t(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=`
+`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
+`;return s?''+(n?i:R(i,!0))+`
+`:""+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}
+`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o
+`+s+""+i+`>
+`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`${t}
+`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`${this.parser.parseInline(e)}
+`}table(e){let t="",n="";for(let i=0;i${s}`),`
+`}tablerow({text:e}){return`
+${e}
+`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`${n}>
+`}strong({tokens:e}){return`${this.parser.parseInline(e)} `}em({tokens:e}){return`${this.parser.parseInline(e)} `}codespan({text:e}){return`${R(e,!0)}`}br(e){return" "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='"+s+" ",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=` ",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:R(e.text)}};var _=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||w,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new _}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=`
+Please report this to https://github.com/markedjs/marked.`,e){let s="An error occurred:
"+R(n.message+"",!0)+" ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex;
+
+if(__exports != exports)module.exports = exports;return module.exports}));
diff --git a/speakr/static/vendor/js/tailwind.min.js b/speakr/static/vendor/js/tailwind.min.js
new file mode 100644
index 0000000..5077984
--- /dev/null
+++ b/speakr/static/vendor/js/tailwind.min.js
@@ -0,0 +1,65 @@
+(()=>{var Zb=Object.create;var Oi=Object.defineProperty;var ew=Object.getOwnPropertyDescriptor;var tw=Object.getOwnPropertyNames;var rw=Object.getPrototypeOf,iw=Object.prototype.hasOwnProperty;var Qu=r=>Oi(r,"__esModule",{value:!0});var Ju=r=>{if(typeof require!="undefined")return require(r);throw new Error('Dynamic require of "'+r+'" is not supported')};var S=(r,e)=>()=>(r&&(e=r(r=0)),e);var x=(r,e)=>()=>(e||r((e={exports:{}}).exports,e),e.exports),_e=(r,e)=>{Qu(r);for(var t in e)Oi(r,t,{get:e[t],enumerable:!0})},nw=(r,e,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of tw(e))!iw.call(r,i)&&i!=="default"&&Oi(r,i,{get:()=>e[i],enumerable:!(t=ew(e,i))||t.enumerable});return r},J=r=>nw(Qu(Oi(r!=null?Zb(rw(r)):{},"default",r&&r.__esModule&&"default"in r?{get:()=>r.default,enumerable:!0}:{value:r,enumerable:!0})),r);var h,l=S(()=>{h={platform:"",env:{},versions:{node:"14.17.6"}}});var sw,re,Ve=S(()=>{l();sw=0,re={readFileSync:r=>self[r]||"",statSync:()=>({mtimeMs:sw++}),promises:{readFile:r=>Promise.resolve(self[r]||"")}}});var xs=x((oE,Ku)=>{l();"use strict";var Xu=class{constructor(e={}){if(!(e.maxSize&&e.maxSize>0))throw new TypeError("`maxSize` must be a number greater than 0");if(typeof e.maxAge=="number"&&e.maxAge===0)throw new TypeError("`maxAge` must be a number greater than 0");this.maxSize=e.maxSize,this.maxAge=e.maxAge||1/0,this.onEviction=e.onEviction,this.cache=new Map,this.oldCache=new Map,this._size=0}_emitEvictions(e){if(typeof this.onEviction=="function")for(let[t,i]of e)this.onEviction(t,i.value)}_deleteIfExpired(e,t){return typeof t.expiry=="number"&&t.expiry<=Date.now()?(typeof this.onEviction=="function"&&this.onEviction(e,t.value),this.delete(e)):!1}_getOrDeleteIfExpired(e,t){if(this._deleteIfExpired(e,t)===!1)return t.value}_getItemValue(e,t){return t.expiry?this._getOrDeleteIfExpired(e,t):t.value}_peek(e,t){let i=t.get(e);return this._getItemValue(e,i)}_set(e,t){this.cache.set(e,t),this._size++,this._size>=this.maxSize&&(this._size=0,this._emitEvictions(this.oldCache),this.oldCache=this.cache,this.cache=new Map)}_moveToRecent(e,t){this.oldCache.delete(e),this._set(e,t)}*_entriesAscending(){for(let e of this.oldCache){let[t,i]=e;this.cache.has(t)||this._deleteIfExpired(t,i)===!1&&(yield e)}for(let e of this.cache){let[t,i]=e;this._deleteIfExpired(t,i)===!1&&(yield e)}}get(e){if(this.cache.has(e)){let t=this.cache.get(e);return this._getItemValue(e,t)}if(this.oldCache.has(e)){let t=this.oldCache.get(e);if(this._deleteIfExpired(e,t)===!1)return this._moveToRecent(e,t),t.value}}set(e,t,{maxAge:i=this.maxAge===1/0?void 0:Date.now()+this.maxAge}={}){this.cache.has(e)?this.cache.set(e,{value:t,maxAge:i}):this._set(e,{value:t,expiry:i})}has(e){return this.cache.has(e)?!this._deleteIfExpired(e,this.cache.get(e)):this.oldCache.has(e)?!this._deleteIfExpired(e,this.oldCache.get(e)):!1}peek(e){if(this.cache.has(e))return this._peek(e,this.cache);if(this.oldCache.has(e))return this._peek(e,this.oldCache)}delete(e){let t=this.cache.delete(e);return t&&this._size--,this.oldCache.delete(e)||t}clear(){this.cache.clear(),this.oldCache.clear(),this._size=0}resize(e){if(!(e&&e>0))throw new TypeError("`maxSize` must be a number greater than 0");let t=[...this._entriesAscending()],i=t.length-e;i<0?(this.cache=new Map(t),this.oldCache=new Map,this._size=t.length):(i>0&&this._emitEvictions(t.slice(0,i)),this.oldCache=new Map(t.slice(i)),this.cache=new Map,this._size=0),this.maxSize=e}*keys(){for(let[e]of this)yield e}*values(){for(let[,e]of this)yield e}*[Symbol.iterator](){for(let e of this.cache){let[t,i]=e;this._deleteIfExpired(t,i)===!1&&(yield[t,i.value])}for(let e of this.oldCache){let[t,i]=e;this.cache.has(t)||this._deleteIfExpired(t,i)===!1&&(yield[t,i.value])}}*entriesDescending(){let e=[...this.cache];for(let t=e.length-1;t>=0;--t){let i=e[t],[n,s]=i;this._deleteIfExpired(n,s)===!1&&(yield[n,s.value])}e=[...this.oldCache];for(let t=e.length-1;t>=0;--t){let i=e[t],[n,s]=i;this.cache.has(n)||this._deleteIfExpired(n,s)===!1&&(yield[n,s.value])}}*entriesAscending(){for(let[e,t]of this._entriesAscending())yield[e,t.value]}get size(){if(!this._size)return this.oldCache.size;let e=0;for(let t of this.oldCache.keys())this.cache.has(t)||e++;return Math.min(this._size+e,this.maxSize)}};Ku.exports=Xu});var Zu,ef=S(()=>{l();Zu=r=>r&&r._hash});function _i(r){return Zu(r,{ignoreUnknown:!0})}var tf=S(()=>{l();ef()});function et(r){if(r=`${r}`,r==="0")return"0";if(/^[+-]?(\d+|\d*\.\d+)(e[+-]?\d+)?(%|\w+)?$/.test(r))return r.replace(/^[+-]?/,t=>t==="-"?"":"-");let e=["var","calc","min","max","clamp"];for(let t of e)if(r.includes(`${t}(`))return`calc(${r} * -1)`}var Ei=S(()=>{l()});var rf,nf=S(()=>{l();rf=["preflight","container","accessibility","pointerEvents","visibility","position","inset","isolation","zIndex","order","gridColumn","gridColumnStart","gridColumnEnd","gridRow","gridRowStart","gridRowEnd","float","clear","margin","boxSizing","lineClamp","display","aspectRatio","size","height","maxHeight","minHeight","width","minWidth","maxWidth","flex","flexShrink","flexGrow","flexBasis","tableLayout","captionSide","borderCollapse","borderSpacing","transformOrigin","translate","rotate","skew","scale","transform","animation","cursor","touchAction","userSelect","resize","scrollSnapType","scrollSnapAlign","scrollSnapStop","scrollMargin","scrollPadding","listStylePosition","listStyleType","listStyleImage","appearance","columns","breakBefore","breakInside","breakAfter","gridAutoColumns","gridAutoFlow","gridAutoRows","gridTemplateColumns","gridTemplateRows","flexDirection","flexWrap","placeContent","placeItems","alignContent","alignItems","justifyContent","justifyItems","gap","space","divideWidth","divideStyle","divideColor","divideOpacity","placeSelf","alignSelf","justifySelf","overflow","overscrollBehavior","scrollBehavior","textOverflow","hyphens","whitespace","textWrap","wordBreak","borderRadius","borderWidth","borderStyle","borderColor","borderOpacity","backgroundColor","backgroundOpacity","backgroundImage","gradientColorStops","boxDecorationBreak","backgroundSize","backgroundAttachment","backgroundClip","backgroundPosition","backgroundRepeat","backgroundOrigin","fill","stroke","strokeWidth","objectFit","objectPosition","padding","textAlign","textIndent","verticalAlign","fontFamily","fontSize","fontWeight","textTransform","fontStyle","fontVariantNumeric","lineHeight","letterSpacing","textColor","textOpacity","textDecoration","textDecorationColor","textDecorationStyle","textDecorationThickness","textUnderlineOffset","fontSmoothing","placeholderColor","placeholderOpacity","caretColor","accentColor","opacity","backgroundBlendMode","mixBlendMode","boxShadow","boxShadowColor","outlineStyle","outlineWidth","outlineOffset","outlineColor","ringWidth","ringColor","ringOpacity","ringOffsetWidth","ringOffsetColor","blur","brightness","contrast","dropShadow","grayscale","hueRotate","invert","saturate","sepia","filter","backdropBlur","backdropBrightness","backdropContrast","backdropGrayscale","backdropHueRotate","backdropInvert","backdropOpacity","backdropSaturate","backdropSepia","backdropFilter","transitionProperty","transitionDelay","transitionDuration","transitionTimingFunction","willChange","content","forcedColorAdjust"]});function sf(r,e){return r===void 0?e:Array.isArray(r)?r:[...new Set(e.filter(i=>r!==!1&&r[i]!==!1).concat(Object.keys(r).filter(i=>r[i]!==!1)))]}var af=S(()=>{l()});var of={};_e(of,{default:()=>K});var K,Tt=S(()=>{l();K=new Proxy({},{get:()=>String})});function vs(r,e,t){typeof h!="undefined"&&h.env.JEST_WORKER_ID||t&&lf.has(t)||(t&&lf.add(t),console.warn(""),e.forEach(i=>console.warn(r,"-",i)))}function ks(r){return K.dim(r)}var lf,M,Ee=S(()=>{l();Tt();lf=new Set;M={info(r,e){vs(K.bold(K.cyan("info")),...Array.isArray(r)?[r]:[e,r])},warn(r,e){["content-problems"].includes(r)||vs(K.bold(K.yellow("warn")),...Array.isArray(r)?[r]:[e,r])},risk(r,e){vs(K.bold(K.magenta("risk")),...Array.isArray(r)?[r]:[e,r])}}});function mr({version:r,from:e,to:t}){M.warn(`${e}-color-renamed`,[`As of Tailwind CSS ${r}, \`${e}\` has been renamed to \`${t}\`.`,"Update your configuration file to silence this warning."])}var uf,ff=S(()=>{l();Ee();uf={inherit:"inherit",current:"currentColor",transparent:"transparent",black:"#000",white:"#fff",slate:{50:"#f8fafc",100:"#f1f5f9",200:"#e2e8f0",300:"#cbd5e1",400:"#94a3b8",500:"#64748b",600:"#475569",700:"#334155",800:"#1e293b",900:"#0f172a",950:"#020617"},gray:{50:"#f9fafb",100:"#f3f4f6",200:"#e5e7eb",300:"#d1d5db",400:"#9ca3af",500:"#6b7280",600:"#4b5563",700:"#374151",800:"#1f2937",900:"#111827",950:"#030712"},zinc:{50:"#fafafa",100:"#f4f4f5",200:"#e4e4e7",300:"#d4d4d8",400:"#a1a1aa",500:"#71717a",600:"#52525b",700:"#3f3f46",800:"#27272a",900:"#18181b",950:"#09090b"},neutral:{50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",500:"#737373",600:"#525252",700:"#404040",800:"#262626",900:"#171717",950:"#0a0a0a"},stone:{50:"#fafaf9",100:"#f5f5f4",200:"#e7e5e4",300:"#d6d3d1",400:"#a8a29e",500:"#78716c",600:"#57534e",700:"#44403c",800:"#292524",900:"#1c1917",950:"#0c0a09"},red:{50:"#fef2f2",100:"#fee2e2",200:"#fecaca",300:"#fca5a5",400:"#f87171",500:"#ef4444",600:"#dc2626",700:"#b91c1c",800:"#991b1b",900:"#7f1d1d",950:"#450a0a"},orange:{50:"#fff7ed",100:"#ffedd5",200:"#fed7aa",300:"#fdba74",400:"#fb923c",500:"#f97316",600:"#ea580c",700:"#c2410c",800:"#9a3412",900:"#7c2d12",950:"#431407"},amber:{50:"#fffbeb",100:"#fef3c7",200:"#fde68a",300:"#fcd34d",400:"#fbbf24",500:"#f59e0b",600:"#d97706",700:"#b45309",800:"#92400e",900:"#78350f",950:"#451a03"},yellow:{50:"#fefce8",100:"#fef9c3",200:"#fef08a",300:"#fde047",400:"#facc15",500:"#eab308",600:"#ca8a04",700:"#a16207",800:"#854d0e",900:"#713f12",950:"#422006"},lime:{50:"#f7fee7",100:"#ecfccb",200:"#d9f99d",300:"#bef264",400:"#a3e635",500:"#84cc16",600:"#65a30d",700:"#4d7c0f",800:"#3f6212",900:"#365314",950:"#1a2e05"},green:{50:"#f0fdf4",100:"#dcfce7",200:"#bbf7d0",300:"#86efac",400:"#4ade80",500:"#22c55e",600:"#16a34a",700:"#15803d",800:"#166534",900:"#14532d",950:"#052e16"},emerald:{50:"#ecfdf5",100:"#d1fae5",200:"#a7f3d0",300:"#6ee7b7",400:"#34d399",500:"#10b981",600:"#059669",700:"#047857",800:"#065f46",900:"#064e3b",950:"#022c22"},teal:{50:"#f0fdfa",100:"#ccfbf1",200:"#99f6e4",300:"#5eead4",400:"#2dd4bf",500:"#14b8a6",600:"#0d9488",700:"#0f766e",800:"#115e59",900:"#134e4a",950:"#042f2e"},cyan:{50:"#ecfeff",100:"#cffafe",200:"#a5f3fc",300:"#67e8f9",400:"#22d3ee",500:"#06b6d4",600:"#0891b2",700:"#0e7490",800:"#155e75",900:"#164e63",950:"#083344"},sky:{50:"#f0f9ff",100:"#e0f2fe",200:"#bae6fd",300:"#7dd3fc",400:"#38bdf8",500:"#0ea5e9",600:"#0284c7",700:"#0369a1",800:"#075985",900:"#0c4a6e",950:"#082f49"},blue:{50:"#eff6ff",100:"#dbeafe",200:"#bfdbfe",300:"#93c5fd",400:"#60a5fa",500:"#3b82f6",600:"#2563eb",700:"#1d4ed8",800:"#1e40af",900:"#1e3a8a",950:"#172554"},indigo:{50:"#eef2ff",100:"#e0e7ff",200:"#c7d2fe",300:"#a5b4fc",400:"#818cf8",500:"#6366f1",600:"#4f46e5",700:"#4338ca",800:"#3730a3",900:"#312e81",950:"#1e1b4b"},violet:{50:"#f5f3ff",100:"#ede9fe",200:"#ddd6fe",300:"#c4b5fd",400:"#a78bfa",500:"#8b5cf6",600:"#7c3aed",700:"#6d28d9",800:"#5b21b6",900:"#4c1d95",950:"#2e1065"},purple:{50:"#faf5ff",100:"#f3e8ff",200:"#e9d5ff",300:"#d8b4fe",400:"#c084fc",500:"#a855f7",600:"#9333ea",700:"#7e22ce",800:"#6b21a8",900:"#581c87",950:"#3b0764"},fuchsia:{50:"#fdf4ff",100:"#fae8ff",200:"#f5d0fe",300:"#f0abfc",400:"#e879f9",500:"#d946ef",600:"#c026d3",700:"#a21caf",800:"#86198f",900:"#701a75",950:"#4a044e"},pink:{50:"#fdf2f8",100:"#fce7f3",200:"#fbcfe8",300:"#f9a8d4",400:"#f472b6",500:"#ec4899",600:"#db2777",700:"#be185d",800:"#9d174d",900:"#831843",950:"#500724"},rose:{50:"#fff1f2",100:"#ffe4e6",200:"#fecdd3",300:"#fda4af",400:"#fb7185",500:"#f43f5e",600:"#e11d48",700:"#be123c",800:"#9f1239",900:"#881337",950:"#4c0519"},get lightBlue(){return mr({version:"v2.2",from:"lightBlue",to:"sky"}),this.sky},get warmGray(){return mr({version:"v3.0",from:"warmGray",to:"stone"}),this.stone},get trueGray(){return mr({version:"v3.0",from:"trueGray",to:"neutral"}),this.neutral},get coolGray(){return mr({version:"v3.0",from:"coolGray",to:"gray"}),this.gray},get blueGray(){return mr({version:"v3.0",from:"blueGray",to:"slate"}),this.slate}}});function Ss(r,...e){for(let t of e){for(let i in t)r?.hasOwnProperty?.(i)||(r[i]=t[i]);for(let i of Object.getOwnPropertySymbols(t))r?.hasOwnProperty?.(i)||(r[i]=t[i])}return r}var cf=S(()=>{l()});function tt(r){if(Array.isArray(r))return r;let e=r.split("[").length-1,t=r.split("]").length-1;if(e!==t)throw new Error(`Path is invalid. Has unbalanced brackets: ${r}`);return r.split(/\.(?![^\[]*\])|[\[\]]/g).filter(Boolean)}var Ti=S(()=>{l()});function Z(r,e){return Pi.future.includes(e)?r.future==="all"||(r?.future?.[e]??pf[e]??!1):Pi.experimental.includes(e)?r.experimental==="all"||(r?.experimental?.[e]??pf[e]??!1):!1}function df(r){return r.experimental==="all"?Pi.experimental:Object.keys(r?.experimental??{}).filter(e=>Pi.experimental.includes(e)&&r.experimental[e])}function hf(r){if(h.env.JEST_WORKER_ID===void 0&&df(r).length>0){let e=df(r).map(t=>K.yellow(t)).join(", ");M.warn("experimental-flags-enabled",[`You have enabled experimental features: ${e}`,"Experimental features in Tailwind CSS are not covered by semver, may introduce breaking changes, and can change at any time."])}}var pf,Pi,We=S(()=>{l();Tt();Ee();pf={optimizeUniversalDefaults:!1,generalizedModifiers:!0,get disableColorOpacityUtilitiesByDefault(){return!1},get relativeContentPathsByDefault(){return!1}},Pi={future:["hoverOnlyWhenSupported","respectDefaultRingColorOpacity","disableColorOpacityUtilitiesByDefault","relativeContentPathsByDefault"],experimental:["optimizeUniversalDefaults","generalizedModifiers"]}});function mf(r){(()=>{if(r.purge||!r.content||!Array.isArray(r.content)&&!(typeof r.content=="object"&&r.content!==null))return!1;if(Array.isArray(r.content))return r.content.every(t=>typeof t=="string"?!0:!(typeof t?.raw!="string"||t?.extension&&typeof t?.extension!="string"));if(typeof r.content=="object"&&r.content!==null){if(Object.keys(r.content).some(t=>!["files","relative","extract","transform"].includes(t)))return!1;if(Array.isArray(r.content.files)){if(!r.content.files.every(t=>typeof t=="string"?!0:!(typeof t?.raw!="string"||t?.extension&&typeof t?.extension!="string")))return!1;if(typeof r.content.extract=="object"){for(let t of Object.values(r.content.extract))if(typeof t!="function")return!1}else if(!(r.content.extract===void 0||typeof r.content.extract=="function"))return!1;if(typeof r.content.transform=="object"){for(let t of Object.values(r.content.transform))if(typeof t!="function")return!1}else if(!(r.content.transform===void 0||typeof r.content.transform=="function"))return!1;if(typeof r.content.relative!="boolean"&&typeof r.content.relative!="undefined")return!1}return!0}return!1})()||M.warn("purge-deprecation",["The `purge`/`content` options have changed in Tailwind CSS v3.0.","Update your configuration file to eliminate this warning.","https://tailwindcss.com/docs/upgrade-guide#configure-content-sources"]),r.safelist=(()=>{let{content:t,purge:i,safelist:n}=r;return Array.isArray(n)?n:Array.isArray(t?.safelist)?t.safelist:Array.isArray(i?.safelist)?i.safelist:Array.isArray(i?.options?.safelist)?i.options.safelist:[]})(),r.blocklist=(()=>{let{blocklist:t}=r;if(Array.isArray(t)){if(t.every(i=>typeof i=="string"))return t;M.warn("blocklist-invalid",["The `blocklist` option must be an array of strings.","https://tailwindcss.com/docs/content-configuration#discarding-classes"])}return[]})(),typeof r.prefix=="function"?(M.warn("prefix-function",["As of Tailwind CSS v3.0, `prefix` cannot be a function.","Update `prefix` in your configuration to be a string to eliminate this warning.","https://tailwindcss.com/docs/upgrade-guide#prefix-cannot-be-a-function"]),r.prefix=""):r.prefix=r.prefix??"",r.content={relative:(()=>{let{content:t}=r;return t?.relative?t.relative:Z(r,"relativeContentPathsByDefault")})(),files:(()=>{let{content:t,purge:i}=r;return Array.isArray(i)?i:Array.isArray(i?.content)?i.content:Array.isArray(t)?t:Array.isArray(t?.content)?t.content:Array.isArray(t?.files)?t.files:[]})(),extract:(()=>{let t=(()=>r.purge?.extract?r.purge.extract:r.content?.extract?r.content.extract:r.purge?.extract?.DEFAULT?r.purge.extract.DEFAULT:r.content?.extract?.DEFAULT?r.content.extract.DEFAULT:r.purge?.options?.extractors?r.purge.options.extractors:r.content?.options?.extractors?r.content.options.extractors:{})(),i={},n=(()=>{if(r.purge?.options?.defaultExtractor)return r.purge.options.defaultExtractor;if(r.content?.options?.defaultExtractor)return r.content.options.defaultExtractor})();if(n!==void 0&&(i.DEFAULT=n),typeof t=="function")i.DEFAULT=t;else if(Array.isArray(t))for(let{extensions:s,extractor:a}of t??[])for(let o of s)i[o]=a;else typeof t=="object"&&t!==null&&Object.assign(i,t);return i})(),transform:(()=>{let t=(()=>r.purge?.transform?r.purge.transform:r.content?.transform?r.content.transform:r.purge?.transform?.DEFAULT?r.purge.transform.DEFAULT:r.content?.transform?.DEFAULT?r.content.transform.DEFAULT:{})(),i={};return typeof t=="function"&&(i.DEFAULT=t),typeof t=="object"&&t!==null&&Object.assign(i,t),i})()};for(let t of r.content.files)if(typeof t=="string"&&/{([^,]*?)}/g.test(t)){M.warn("invalid-glob-braces",[`The glob pattern ${ks(t)} in your Tailwind CSS configuration is invalid.`,`Update it to ${ks(t.replace(/{([^,]*?)}/g,"$1"))} to silence this warning.`]);break}return r}var gf=S(()=>{l();We();Ee()});function ne(r){if(Object.prototype.toString.call(r)!=="[object Object]")return!1;let e=Object.getPrototypeOf(r);return e===null||Object.getPrototypeOf(e)===null}var Pt=S(()=>{l()});function Di(r){return Array.isArray(r)?r.map(e=>Di(e)):typeof r=="object"&&r!==null?Object.fromEntries(Object.entries(r).map(([e,t])=>[e,Di(t)])):r}var yf=S(()=>{l()});function kt(r){return r.replace(/\\,/g,"\\2c ")}var Ii=S(()=>{l()});var Cs,bf=S(()=>{l();Cs={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}});function gr(r,{loose:e=!1}={}){if(typeof r!="string")return null;if(r=r.trim(),r==="transparent")return{mode:"rgb",color:["0","0","0"],alpha:"0"};if(r in Cs)return{mode:"rgb",color:Cs[r].map(s=>s.toString())};let t=r.replace(ow,(s,a,o,u,c)=>["#",a,a,o,o,u,u,c?c+c:""].join("")).match(aw);if(t!==null)return{mode:"rgb",color:[parseInt(t[1],16),parseInt(t[2],16),parseInt(t[3],16)].map(s=>s.toString()),alpha:t[4]?(parseInt(t[4],16)/255).toString():void 0};let i=r.match(lw)??r.match(uw);if(i===null)return null;let n=[i[2],i[3],i[4]].filter(Boolean).map(s=>s.toString());return n.length===2&&n[0].startsWith("var(")?{mode:i[1],color:[n[0]],alpha:n[1]}:!e&&n.length!==3||n.length<3&&!n.some(s=>/^var\(.*?\)$/.test(s))?null:{mode:i[1],color:n,alpha:i[5]?.toString?.()}}function As({mode:r,color:e,alpha:t}){let i=t!==void 0;return r==="rgba"||r==="hsla"?`${r}(${e.join(", ")}${i?`, ${t}`:""})`:`${r}(${e.join(" ")}${i?` / ${t}`:""})`}var aw,ow,rt,Ri,wf,it,lw,uw,Os=S(()=>{l();bf();aw=/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i,ow=/^#([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i,rt=/(?:\d+|\d*\.\d+)%?/,Ri=/(?:\s*,\s*|\s+)/,wf=/\s*[,/]\s*/,it=/var\(--(?:[^ )]*?)(?:,(?:[^ )]*?|var\(--[^ )]*?\)))?\)/,lw=new RegExp(`^(rgba?)\\(\\s*(${rt.source}|${it.source})(?:${Ri.source}(${rt.source}|${it.source}))?(?:${Ri.source}(${rt.source}|${it.source}))?(?:${wf.source}(${rt.source}|${it.source}))?\\s*\\)$`),uw=new RegExp(`^(hsla?)\\(\\s*((?:${rt.source})(?:deg|rad|grad|turn)?|${it.source})(?:${Ri.source}(${rt.source}|${it.source}))?(?:${Ri.source}(${rt.source}|${it.source}))?(?:${wf.source}(${rt.source}|${it.source}))?\\s*\\)$`)});function Ie(r,e,t){if(typeof r=="function")return r({opacityValue:e});let i=gr(r,{loose:!0});return i===null?t:As({...i,alpha:e})}function oe({color:r,property:e,variable:t}){let i=[].concat(e);if(typeof r=="function")return{[t]:"1",...Object.fromEntries(i.map(s=>[s,r({opacityVariable:t,opacityValue:`var(${t})`})]))};let n=gr(r);return n===null?Object.fromEntries(i.map(s=>[s,r])):n.alpha!==void 0?Object.fromEntries(i.map(s=>[s,r])):{[t]:"1",...Object.fromEntries(i.map(s=>[s,As({...n,alpha:`var(${t})`})]))}}var yr=S(()=>{l();Os()});function le(r,e){let t=[],i=[],n=0,s=!1;for(let a=0;a{l()});function qi(r){return le(r,",").map(t=>{let i=t.trim(),n={raw:i},s=i.split(cw),a=new Set;for(let o of s)xf.lastIndex=0,!a.has("KEYWORD")&&fw.has(o)?(n.keyword=o,a.add("KEYWORD")):xf.test(o)?a.has("X")?a.has("Y")?a.has("BLUR")?a.has("SPREAD")||(n.spread=o,a.add("SPREAD")):(n.blur=o,a.add("BLUR")):(n.y=o,a.add("Y")):(n.x=o,a.add("X")):n.color?(n.unknown||(n.unknown=[]),n.unknown.push(o)):n.color=o;return n.valid=n.x!==void 0&&n.y!==void 0,n})}function vf(r){return r.map(e=>e.valid?[e.keyword,e.x,e.y,e.blur,e.spread,e.color].filter(Boolean).join(" "):e.raw).join(", ")}var fw,cw,xf,_s=S(()=>{l();Dt();fw=new Set(["inset","inherit","initial","revert","unset"]),cw=/\ +(?![^(]*\))/g,xf=/^-?(\d+|\.\d+)(.*?)$/g});function Es(r){return pw.some(e=>new RegExp(`^${e}\\(.*\\)`).test(r))}function L(r,e=null,t=!0){let i=e&&dw.has(e.property);return r.startsWith("--")&&!i?`var(${r})`:r.includes("url(")?r.split(/(url\(.*?\))/g).filter(Boolean).map(n=>/^url\(.*?\)$/.test(n)?n:L(n,e,!1)).join(""):(r=r.replace(/([^\\])_+/g,(n,s)=>s+" ".repeat(n.length-1)).replace(/^_/g," ").replace(/\\_/g,"_"),t&&(r=r.trim()),r=hw(r),r)}function hw(r){let e=["theme"],t=["min-content","max-content","fit-content","safe-area-inset-top","safe-area-inset-right","safe-area-inset-bottom","safe-area-inset-left","titlebar-area-x","titlebar-area-y","titlebar-area-width","titlebar-area-height","keyboard-inset-top","keyboard-inset-right","keyboard-inset-bottom","keyboard-inset-left","keyboard-inset-width","keyboard-inset-height"];return r.replace(/(calc|min|max|clamp)\(.+\)/g,i=>{let n="";function s(){let a=n.trimEnd();return a[a.length-1]}for(let a=0;ai[a+p]===d)},u=function(f){let d=1/0;for(let g of f){let b=i.indexOf(g,a);b!==-1&&bo(f))){let f=t.find(d=>o(d));n+=f,a+=f.length-1}else e.some(f=>o(f))?n+=u([")"]):["+","-","*","/"].includes(c)&&!["(","+","-","*","/",","].includes(s())?n+=` ${c} `:n+=c}return n.replace(/\s+/g," ")})}function Ts(r){return r.startsWith("url(")}function Ps(r){return!isNaN(Number(r))||Es(r)}function br(r){return r.endsWith("%")&&Ps(r.slice(0,-1))||Es(r)}function wr(r){return r==="0"||new RegExp(`^[+-]?[0-9]*.?[0-9]+(?:[eE][+-]?[0-9]+)?${gw}$`).test(r)||Es(r)}function kf(r){return yw.has(r)}function Sf(r){let e=qi(L(r));for(let t of e)if(!t.valid)return!1;return!0}function Cf(r){let e=0;return le(r,"_").every(i=>(i=L(i),i.startsWith("var(")?!0:gr(i,{loose:!0})!==null?(e++,!0):!1))?e>0:!1}function Af(r){let e=0;return le(r,",").every(i=>(i=L(i),i.startsWith("var(")?!0:Ts(i)||ww(i)||["element(","image(","cross-fade(","image-set("].some(n=>i.startsWith(n))?(e++,!0):!1))?e>0:!1}function ww(r){r=L(r);for(let e of bw)if(r.startsWith(`${e}(`))return!0;return!1}function Of(r){let e=0;return le(r,"_").every(i=>(i=L(i),i.startsWith("var(")?!0:xw.has(i)||wr(i)||br(i)?(e++,!0):!1))?e>0:!1}function _f(r){let e=0;return le(r,",").every(i=>(i=L(i),i.startsWith("var(")?!0:i.includes(" ")&&!/(['"])([^"']+)\1/g.test(i)||/^\d/g.test(i)?!1:(e++,!0)))?e>0:!1}function Ef(r){return vw.has(r)}function Tf(r){return kw.has(r)}function Pf(r){return Sw.has(r)}var pw,dw,mw,gw,yw,bw,xw,vw,kw,Sw,xr=S(()=>{l();Os();_s();Dt();pw=["min","max","clamp","calc"];dw=new Set(["scroll-timeline-name","timeline-scope","view-timeline-name","font-palette","scroll-timeline","animation-timeline","view-timeline"]);mw=["cm","mm","Q","in","pc","pt","px","em","ex","ch","rem","lh","rlh","vw","vh","vmin","vmax","vb","vi","svw","svh","lvw","lvh","dvw","dvh","cqw","cqh","cqi","cqb","cqmin","cqmax"],gw=`(?:${mw.join("|")})`;yw=new Set(["thin","medium","thick"]);bw=new Set(["conic-gradient","linear-gradient","radial-gradient","repeating-conic-gradient","repeating-linear-gradient","repeating-radial-gradient"]);xw=new Set(["center","top","right","bottom","left"]);vw=new Set(["serif","sans-serif","monospace","cursive","fantasy","system-ui","ui-serif","ui-sans-serif","ui-monospace","ui-rounded","math","emoji","fangsong"]);kw=new Set(["xx-small","x-small","small","medium","large","x-large","x-large","xxx-large"]);Sw=new Set(["larger","smaller"])});function Df(r){let e=["cover","contain"];return le(r,",").every(t=>{let i=le(t,"_").filter(Boolean);return i.length===1&&e.includes(i[0])?!0:i.length!==1&&i.length!==2?!1:i.every(n=>wr(n)||br(n)||n==="auto")})}var If=S(()=>{l();xr();Dt()});function Rf(r,e){r.walkClasses(t=>{t.value=e(t.value),t.raws&&t.raws.value&&(t.raws.value=kt(t.raws.value))})}function qf(r,e){if(!nt(r))return;let t=r.slice(1,-1);if(!!e(t))return L(t)}function Cw(r,e={},t){let i=e[r];if(i!==void 0)return et(i);if(nt(r)){let n=qf(r,t);return n===void 0?void 0:et(n)}}function Fi(r,e={},{validate:t=()=>!0}={}){let i=e.values?.[r];return i!==void 0?i:e.supportsNegativeValues&&r.startsWith("-")?Cw(r.slice(1),e.values,t):qf(r,t)}function nt(r){return r.startsWith("[")&&r.endsWith("]")}function Ff(r){let e=r.lastIndexOf("/"),t=r.lastIndexOf("[",e),i=r.indexOf("]",e);return r[e-1]==="]"||r[e+1]==="["||t!==-1&&i!==-1&&t")){let e=r;return({opacityValue:t=1})=>e.replace("",t)}return r}function Bf(r){return L(r.slice(1,-1))}function Aw(r,e={},{tailwindConfig:t={}}={}){if(e.values?.[r]!==void 0)return It(e.values?.[r]);let[i,n]=Ff(r);if(n!==void 0){let s=e.values?.[i]??(nt(i)?i.slice(1,-1):void 0);return s===void 0?void 0:(s=It(s),nt(n)?Ie(s,Bf(n)):t.theme?.opacity?.[n]===void 0?void 0:Ie(s,t.theme.opacity[n]))}return Fi(r,e,{validate:Cf})}function Ow(r,e={}){return e.values?.[r]}function ge(r){return(e,t)=>Fi(e,t,{validate:r})}function _w(r,e){let t=r.indexOf(e);return t===-1?[void 0,r]:[r.slice(0,t),r.slice(t+1)]}function Is(r,e,t,i){if(t.values&&e in t.values)for(let{type:s}of r??[]){let a=Ds[s](e,t,{tailwindConfig:i});if(a!==void 0)return[a,s,null]}if(nt(e)){let s=e.slice(1,-1),[a,o]=_w(s,":");if(!/^[\w-_]+$/g.test(a))o=s;else if(a!==void 0&&!Mf.includes(a))return[];if(o.length>0&&Mf.includes(a))return[Fi(`[${o}]`,t),a,null]}let n=Rs(r,e,t,i);for(let s of n)return s;return[]}function*Rs(r,e,t,i){let n=Z(i,"generalizedModifiers"),[s,a]=Ff(e);if(n&&t.modifiers!=null&&(t.modifiers==="any"||typeof t.modifiers=="object"&&(a&&nt(a)||a in t.modifiers))||(s=e,a=void 0),a!==void 0&&s===""&&(s="DEFAULT"),a!==void 0&&typeof t.modifiers=="object"){let u=t.modifiers?.[a]??null;u!==null?a=u:nt(a)&&(a=Bf(a))}for(let{type:u}of r??[]){let c=Ds[u](s,t,{tailwindConfig:i});c!==void 0&&(yield[c,u,a??null])}}var Ds,Mf,vr=S(()=>{l();Ii();yr();xr();Ei();If();We();Ds={any:Fi,color:Aw,url:ge(Ts),image:ge(Af),length:ge(wr),percentage:ge(br),position:ge(Of),lookup:Ow,"generic-name":ge(Ef),"family-name":ge(_f),number:ge(Ps),"line-width":ge(kf),"absolute-size":ge(Tf),"relative-size":ge(Pf),shadow:ge(Sf),size:ge(Df)},Mf=Object.keys(Ds)});function $(r){return typeof r=="function"?r({}):r}var qs=S(()=>{l()});function Rt(r){return typeof r=="function"}function kr(r,...e){let t=e.pop();for(let i of e)for(let n in i){let s=t(r[n],i[n]);s===void 0?ne(r[n])&&ne(i[n])?r[n]=kr({},r[n],i[n],t):r[n]=i[n]:r[n]=s}return r}function Ew(r,...e){return Rt(r)?r(...e):r}function Tw(r){return r.reduce((e,{extend:t})=>kr(e,t,(i,n)=>i===void 0?[n]:Array.isArray(i)?[n,...i]:[n,i]),{})}function Pw(r){return{...r.reduce((e,t)=>Ss(e,t),{}),extend:Tw(r)}}function Lf(r,e){if(Array.isArray(r)&&ne(r[0]))return r.concat(e);if(Array.isArray(e)&&ne(e[0])&&ne(r))return[r,...e];if(Array.isArray(e))return e}function Dw({extend:r,...e}){return kr(e,r,(t,i)=>!Rt(t)&&!i.some(Rt)?kr({},t,...i,Lf):(n,s)=>kr({},...[t,...i].map(a=>Ew(a,n,s)),Lf))}function*Iw(r){let e=tt(r);if(e.length===0||(yield e,Array.isArray(r)))return;let t=/^(.*?)\s*\/\s*([^/]+)$/,i=r.match(t);if(i!==null){let[,n,s]=i,a=tt(n);a.alpha=s,yield a}}function Rw(r){let e=(t,i)=>{for(let n of Iw(t)){let s=0,a=r;for(;a!=null&&s(t[i]=Rt(r[i])?r[i](e,Fs):r[i],t),{})}function $f(r){let e=[];return r.forEach(t=>{e=[...e,t];let i=t?.plugins??[];i.length!==0&&i.forEach(n=>{n.__isOptionsFunction&&(n=n()),e=[...e,...$f([n?.config??{}])]})}),e}function qw(r){return[...r].reduceRight((t,i)=>Rt(i)?i({corePlugins:t}):sf(i,t),rf)}function Fw(r){return[...r].reduceRight((t,i)=>[...t,...i],[])}function Bs(r){let e=[...$f(r),{prefix:"",important:!1,separator:":"}];return mf(Ss({theme:Rw(Dw(Pw(e.map(t=>t?.theme??{})))),corePlugins:qw(e.map(t=>t.corePlugins)),plugins:Fw(r.map(t=>t?.plugins??[]))},...e))}var Fs,Nf=S(()=>{l();Ei();nf();af();ff();cf();Ti();gf();Pt();yf();vr();yr();qs();Fs={colors:uf,negative(r){return Object.keys(r).filter(e=>r[e]!=="0").reduce((e,t)=>{let i=et(r[t]);return i!==void 0&&(e[`-${t}`]=i),e},{})},breakpoints(r){return Object.keys(r).filter(e=>typeof r[e]=="string").reduce((e,t)=>({...e,[`screen-${t}`]:r[t]}),{})}}});var jf=x((c4,zf)=>{l();zf.exports={content:[],presets:[],darkMode:"media",theme:{accentColor:({theme:r})=>({...r("colors"),auto:"auto"}),animation:{none:"none",spin:"spin 1s linear infinite",ping:"ping 1s cubic-bezier(0, 0, 0.2, 1) infinite",pulse:"pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",bounce:"bounce 1s infinite"},aria:{busy:'busy="true"',checked:'checked="true"',disabled:'disabled="true"',expanded:'expanded="true"',hidden:'hidden="true"',pressed:'pressed="true"',readonly:'readonly="true"',required:'required="true"',selected:'selected="true"'},aspectRatio:{auto:"auto",square:"1 / 1",video:"16 / 9"},backdropBlur:({theme:r})=>r("blur"),backdropBrightness:({theme:r})=>r("brightness"),backdropContrast:({theme:r})=>r("contrast"),backdropGrayscale:({theme:r})=>r("grayscale"),backdropHueRotate:({theme:r})=>r("hueRotate"),backdropInvert:({theme:r})=>r("invert"),backdropOpacity:({theme:r})=>r("opacity"),backdropSaturate:({theme:r})=>r("saturate"),backdropSepia:({theme:r})=>r("sepia"),backgroundColor:({theme:r})=>r("colors"),backgroundImage:{none:"none","gradient-to-t":"linear-gradient(to top, var(--tw-gradient-stops))","gradient-to-tr":"linear-gradient(to top right, var(--tw-gradient-stops))","gradient-to-r":"linear-gradient(to right, var(--tw-gradient-stops))","gradient-to-br":"linear-gradient(to bottom right, var(--tw-gradient-stops))","gradient-to-b":"linear-gradient(to bottom, var(--tw-gradient-stops))","gradient-to-bl":"linear-gradient(to bottom left, var(--tw-gradient-stops))","gradient-to-l":"linear-gradient(to left, var(--tw-gradient-stops))","gradient-to-tl":"linear-gradient(to top left, var(--tw-gradient-stops))"},backgroundOpacity:({theme:r})=>r("opacity"),backgroundPosition:{bottom:"bottom",center:"center",left:"left","left-bottom":"left bottom","left-top":"left top",right:"right","right-bottom":"right bottom","right-top":"right top",top:"top"},backgroundSize:{auto:"auto",cover:"cover",contain:"contain"},blur:{0:"0",none:"0",sm:"4px",DEFAULT:"8px",md:"12px",lg:"16px",xl:"24px","2xl":"40px","3xl":"64px"},borderColor:({theme:r})=>({...r("colors"),DEFAULT:r("colors.gray.200","currentColor")}),borderOpacity:({theme:r})=>r("opacity"),borderRadius:{none:"0px",sm:"0.125rem",DEFAULT:"0.25rem",md:"0.375rem",lg:"0.5rem",xl:"0.75rem","2xl":"1rem","3xl":"1.5rem",full:"9999px"},borderSpacing:({theme:r})=>({...r("spacing")}),borderWidth:{DEFAULT:"1px",0:"0px",2:"2px",4:"4px",8:"8px"},boxShadow:{sm:"0 1px 2px 0 rgb(0 0 0 / 0.05)",DEFAULT:"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",md:"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",lg:"0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",xl:"0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)","2xl":"0 25px 50px -12px rgb(0 0 0 / 0.25)",inner:"inset 0 2px 4px 0 rgb(0 0 0 / 0.05)",none:"none"},boxShadowColor:({theme:r})=>r("colors"),brightness:{0:"0",50:".5",75:".75",90:".9",95:".95",100:"1",105:"1.05",110:"1.1",125:"1.25",150:"1.5",200:"2"},caretColor:({theme:r})=>r("colors"),colors:({colors:r})=>({inherit:r.inherit,current:r.current,transparent:r.transparent,black:r.black,white:r.white,slate:r.slate,gray:r.gray,zinc:r.zinc,neutral:r.neutral,stone:r.stone,red:r.red,orange:r.orange,amber:r.amber,yellow:r.yellow,lime:r.lime,green:r.green,emerald:r.emerald,teal:r.teal,cyan:r.cyan,sky:r.sky,blue:r.blue,indigo:r.indigo,violet:r.violet,purple:r.purple,fuchsia:r.fuchsia,pink:r.pink,rose:r.rose}),columns:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12","3xs":"16rem","2xs":"18rem",xs:"20rem",sm:"24rem",md:"28rem",lg:"32rem",xl:"36rem","2xl":"42rem","3xl":"48rem","4xl":"56rem","5xl":"64rem","6xl":"72rem","7xl":"80rem"},container:{},content:{none:"none"},contrast:{0:"0",50:".5",75:".75",100:"1",125:"1.25",150:"1.5",200:"2"},cursor:{auto:"auto",default:"default",pointer:"pointer",wait:"wait",text:"text",move:"move",help:"help","not-allowed":"not-allowed",none:"none","context-menu":"context-menu",progress:"progress",cell:"cell",crosshair:"crosshair","vertical-text":"vertical-text",alias:"alias",copy:"copy","no-drop":"no-drop",grab:"grab",grabbing:"grabbing","all-scroll":"all-scroll","col-resize":"col-resize","row-resize":"row-resize","n-resize":"n-resize","e-resize":"e-resize","s-resize":"s-resize","w-resize":"w-resize","ne-resize":"ne-resize","nw-resize":"nw-resize","se-resize":"se-resize","sw-resize":"sw-resize","ew-resize":"ew-resize","ns-resize":"ns-resize","nesw-resize":"nesw-resize","nwse-resize":"nwse-resize","zoom-in":"zoom-in","zoom-out":"zoom-out"},divideColor:({theme:r})=>r("borderColor"),divideOpacity:({theme:r})=>r("borderOpacity"),divideWidth:({theme:r})=>r("borderWidth"),dropShadow:{sm:"0 1px 1px rgb(0 0 0 / 0.05)",DEFAULT:["0 1px 2px rgb(0 0 0 / 0.1)","0 1px 1px rgb(0 0 0 / 0.06)"],md:["0 4px 3px rgb(0 0 0 / 0.07)","0 2px 2px rgb(0 0 0 / 0.06)"],lg:["0 10px 8px rgb(0 0 0 / 0.04)","0 4px 3px rgb(0 0 0 / 0.1)"],xl:["0 20px 13px rgb(0 0 0 / 0.03)","0 8px 5px rgb(0 0 0 / 0.08)"],"2xl":"0 25px 25px rgb(0 0 0 / 0.15)",none:"0 0 #0000"},fill:({theme:r})=>({none:"none",...r("colors")}),flex:{1:"1 1 0%",auto:"1 1 auto",initial:"0 1 auto",none:"none"},flexBasis:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%"}),flexGrow:{0:"0",DEFAULT:"1"},flexShrink:{0:"0",DEFAULT:"1"},fontFamily:{sans:["ui-sans-serif","system-ui","sans-serif",'"Apple Color Emoji"','"Segoe UI Emoji"','"Segoe UI Symbol"','"Noto Color Emoji"'],serif:["ui-serif","Georgia","Cambria",'"Times New Roman"',"Times","serif"],mono:["ui-monospace","SFMono-Regular","Menlo","Monaco","Consolas",'"Liberation Mono"','"Courier New"',"monospace"]},fontSize:{xs:["0.75rem",{lineHeight:"1rem"}],sm:["0.875rem",{lineHeight:"1.25rem"}],base:["1rem",{lineHeight:"1.5rem"}],lg:["1.125rem",{lineHeight:"1.75rem"}],xl:["1.25rem",{lineHeight:"1.75rem"}],"2xl":["1.5rem",{lineHeight:"2rem"}],"3xl":["1.875rem",{lineHeight:"2.25rem"}],"4xl":["2.25rem",{lineHeight:"2.5rem"}],"5xl":["3rem",{lineHeight:"1"}],"6xl":["3.75rem",{lineHeight:"1"}],"7xl":["4.5rem",{lineHeight:"1"}],"8xl":["6rem",{lineHeight:"1"}],"9xl":["8rem",{lineHeight:"1"}]},fontWeight:{thin:"100",extralight:"200",light:"300",normal:"400",medium:"500",semibold:"600",bold:"700",extrabold:"800",black:"900"},gap:({theme:r})=>r("spacing"),gradientColorStops:({theme:r})=>r("colors"),gradientColorStopPositions:{"0%":"0%","5%":"5%","10%":"10%","15%":"15%","20%":"20%","25%":"25%","30%":"30%","35%":"35%","40%":"40%","45%":"45%","50%":"50%","55%":"55%","60%":"60%","65%":"65%","70%":"70%","75%":"75%","80%":"80%","85%":"85%","90%":"90%","95%":"95%","100%":"100%"},grayscale:{0:"0",DEFAULT:"100%"},gridAutoColumns:{auto:"auto",min:"min-content",max:"max-content",fr:"minmax(0, 1fr)"},gridAutoRows:{auto:"auto",min:"min-content",max:"max-content",fr:"minmax(0, 1fr)"},gridColumn:{auto:"auto","span-1":"span 1 / span 1","span-2":"span 2 / span 2","span-3":"span 3 / span 3","span-4":"span 4 / span 4","span-5":"span 5 / span 5","span-6":"span 6 / span 6","span-7":"span 7 / span 7","span-8":"span 8 / span 8","span-9":"span 9 / span 9","span-10":"span 10 / span 10","span-11":"span 11 / span 11","span-12":"span 12 / span 12","span-full":"1 / -1"},gridColumnEnd:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13"},gridColumnStart:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13"},gridRow:{auto:"auto","span-1":"span 1 / span 1","span-2":"span 2 / span 2","span-3":"span 3 / span 3","span-4":"span 4 / span 4","span-5":"span 5 / span 5","span-6":"span 6 / span 6","span-7":"span 7 / span 7","span-8":"span 8 / span 8","span-9":"span 9 / span 9","span-10":"span 10 / span 10","span-11":"span 11 / span 11","span-12":"span 12 / span 12","span-full":"1 / -1"},gridRowEnd:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13"},gridRowStart:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13"},gridTemplateColumns:{none:"none",subgrid:"subgrid",1:"repeat(1, minmax(0, 1fr))",2:"repeat(2, minmax(0, 1fr))",3:"repeat(3, minmax(0, 1fr))",4:"repeat(4, minmax(0, 1fr))",5:"repeat(5, minmax(0, 1fr))",6:"repeat(6, minmax(0, 1fr))",7:"repeat(7, minmax(0, 1fr))",8:"repeat(8, minmax(0, 1fr))",9:"repeat(9, minmax(0, 1fr))",10:"repeat(10, minmax(0, 1fr))",11:"repeat(11, minmax(0, 1fr))",12:"repeat(12, minmax(0, 1fr))"},gridTemplateRows:{none:"none",subgrid:"subgrid",1:"repeat(1, minmax(0, 1fr))",2:"repeat(2, minmax(0, 1fr))",3:"repeat(3, minmax(0, 1fr))",4:"repeat(4, minmax(0, 1fr))",5:"repeat(5, minmax(0, 1fr))",6:"repeat(6, minmax(0, 1fr))",7:"repeat(7, minmax(0, 1fr))",8:"repeat(8, minmax(0, 1fr))",9:"repeat(9, minmax(0, 1fr))",10:"repeat(10, minmax(0, 1fr))",11:"repeat(11, minmax(0, 1fr))",12:"repeat(12, minmax(0, 1fr))"},height:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%",full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content"}),hueRotate:{0:"0deg",15:"15deg",30:"30deg",60:"60deg",90:"90deg",180:"180deg"},inset:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%",full:"100%"}),invert:{0:"0",DEFAULT:"100%"},keyframes:{spin:{to:{transform:"rotate(360deg)"}},ping:{"75%, 100%":{transform:"scale(2)",opacity:"0"}},pulse:{"50%":{opacity:".5"}},bounce:{"0%, 100%":{transform:"translateY(-25%)",animationTimingFunction:"cubic-bezier(0.8,0,1,1)"},"50%":{transform:"none",animationTimingFunction:"cubic-bezier(0,0,0.2,1)"}}},letterSpacing:{tighter:"-0.05em",tight:"-0.025em",normal:"0em",wide:"0.025em",wider:"0.05em",widest:"0.1em"},lineHeight:{none:"1",tight:"1.25",snug:"1.375",normal:"1.5",relaxed:"1.625",loose:"2",3:".75rem",4:"1rem",5:"1.25rem",6:"1.5rem",7:"1.75rem",8:"2rem",9:"2.25rem",10:"2.5rem"},listStyleType:{none:"none",disc:"disc",decimal:"decimal"},listStyleImage:{none:"none"},margin:({theme:r})=>({auto:"auto",...r("spacing")}),lineClamp:{1:"1",2:"2",3:"3",4:"4",5:"5",6:"6"},maxHeight:({theme:r})=>({...r("spacing"),none:"none",full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content"}),maxWidth:({theme:r,breakpoints:e})=>({...r("spacing"),none:"none",xs:"20rem",sm:"24rem",md:"28rem",lg:"32rem",xl:"36rem","2xl":"42rem","3xl":"48rem","4xl":"56rem","5xl":"64rem","6xl":"72rem","7xl":"80rem",full:"100%",min:"min-content",max:"max-content",fit:"fit-content",prose:"65ch",...e(r("screens"))}),minHeight:({theme:r})=>({...r("spacing"),full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content"}),minWidth:({theme:r})=>({...r("spacing"),full:"100%",min:"min-content",max:"max-content",fit:"fit-content"}),objectPosition:{bottom:"bottom",center:"center",left:"left","left-bottom":"left bottom","left-top":"left top",right:"right","right-bottom":"right bottom","right-top":"right top",top:"top"},opacity:{0:"0",5:"0.05",10:"0.1",15:"0.15",20:"0.2",25:"0.25",30:"0.3",35:"0.35",40:"0.4",45:"0.45",50:"0.5",55:"0.55",60:"0.6",65:"0.65",70:"0.7",75:"0.75",80:"0.8",85:"0.85",90:"0.9",95:"0.95",100:"1"},order:{first:"-9999",last:"9999",none:"0",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12"},outlineColor:({theme:r})=>r("colors"),outlineOffset:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},outlineWidth:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},padding:({theme:r})=>r("spacing"),placeholderColor:({theme:r})=>r("colors"),placeholderOpacity:({theme:r})=>r("opacity"),ringColor:({theme:r})=>({DEFAULT:r("colors.blue.500","#3b82f6"),...r("colors")}),ringOffsetColor:({theme:r})=>r("colors"),ringOffsetWidth:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},ringOpacity:({theme:r})=>({DEFAULT:"0.5",...r("opacity")}),ringWidth:{DEFAULT:"3px",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},rotate:{0:"0deg",1:"1deg",2:"2deg",3:"3deg",6:"6deg",12:"12deg",45:"45deg",90:"90deg",180:"180deg"},saturate:{0:"0",50:".5",100:"1",150:"1.5",200:"2"},scale:{0:"0",50:".5",75:".75",90:".9",95:".95",100:"1",105:"1.05",110:"1.1",125:"1.25",150:"1.5"},screens:{sm:"640px",md:"768px",lg:"1024px",xl:"1280px","2xl":"1536px"},scrollMargin:({theme:r})=>({...r("spacing")}),scrollPadding:({theme:r})=>r("spacing"),sepia:{0:"0",DEFAULT:"100%"},skew:{0:"0deg",1:"1deg",2:"2deg",3:"3deg",6:"6deg",12:"12deg"},space:({theme:r})=>({...r("spacing")}),spacing:{px:"1px",0:"0px",.5:"0.125rem",1:"0.25rem",1.5:"0.375rem",2:"0.5rem",2.5:"0.625rem",3:"0.75rem",3.5:"0.875rem",4:"1rem",5:"1.25rem",6:"1.5rem",7:"1.75rem",8:"2rem",9:"2.25rem",10:"2.5rem",11:"2.75rem",12:"3rem",14:"3.5rem",16:"4rem",20:"5rem",24:"6rem",28:"7rem",32:"8rem",36:"9rem",40:"10rem",44:"11rem",48:"12rem",52:"13rem",56:"14rem",60:"15rem",64:"16rem",72:"18rem",80:"20rem",96:"24rem"},stroke:({theme:r})=>({none:"none",...r("colors")}),strokeWidth:{0:"0",1:"1",2:"2"},supports:{},data:{},textColor:({theme:r})=>r("colors"),textDecorationColor:({theme:r})=>r("colors"),textDecorationThickness:{auto:"auto","from-font":"from-font",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},textIndent:({theme:r})=>({...r("spacing")}),textOpacity:({theme:r})=>r("opacity"),textUnderlineOffset:{auto:"auto",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},transformOrigin:{center:"center",top:"top","top-right":"top right",right:"right","bottom-right":"bottom right",bottom:"bottom","bottom-left":"bottom left",left:"left","top-left":"top left"},transitionDelay:{0:"0s",75:"75ms",100:"100ms",150:"150ms",200:"200ms",300:"300ms",500:"500ms",700:"700ms",1e3:"1000ms"},transitionDuration:{DEFAULT:"150ms",0:"0s",75:"75ms",100:"100ms",150:"150ms",200:"200ms",300:"300ms",500:"500ms",700:"700ms",1e3:"1000ms"},transitionProperty:{none:"none",all:"all",DEFAULT:"color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter",colors:"color, background-color, border-color, text-decoration-color, fill, stroke",opacity:"opacity",shadow:"box-shadow",transform:"transform"},transitionTimingFunction:{DEFAULT:"cubic-bezier(0.4, 0, 0.2, 1)",linear:"linear",in:"cubic-bezier(0.4, 0, 1, 1)",out:"cubic-bezier(0, 0, 0.2, 1)","in-out":"cubic-bezier(0.4, 0, 0.2, 1)"},translate:({theme:r})=>({...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%",full:"100%"}),size:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%",min:"min-content",max:"max-content",fit:"fit-content"}),width:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%",screen:"100vw",svw:"100svw",lvw:"100lvw",dvw:"100dvw",min:"min-content",max:"max-content",fit:"fit-content"}),willChange:{auto:"auto",scroll:"scroll-position",contents:"contents",transform:"transform"},zIndex:{auto:"auto",0:"0",10:"10",20:"20",30:"30",40:"40",50:"50"}},plugins:[]}});function Bi(r){let e=(r?.presets??[Uf.default]).slice().reverse().flatMap(n=>Bi(n instanceof Function?n():n)),t={respectDefaultRingColorOpacity:{theme:{ringColor:({theme:n})=>({DEFAULT:"#3b82f67f",...n("colors")})}},disableColorOpacityUtilitiesByDefault:{corePlugins:{backgroundOpacity:!1,borderOpacity:!1,divideOpacity:!1,placeholderOpacity:!1,ringOpacity:!1,textOpacity:!1}}},i=Object.keys(t).filter(n=>Z(r,n)).map(n=>t[n]);return[r,...i,...e]}var Uf,Vf=S(()=>{l();Uf=J(jf());We()});function Mi(...r){let[,...e]=Bi(r[0]);return Bs([...r,...e])}var Wf=S(()=>{l();Nf();Vf()});var Gf={};_e(Gf,{default:()=>ee});var ee,St=S(()=>{l();ee={resolve:r=>r,extname:r=>"."+r.split(".").pop()}});function Li(r){return typeof r=="object"&&r!==null}function Mw(r){return Object.keys(r).length===0}function Hf(r){return typeof r=="string"||r instanceof String}function Ms(r){return Li(r)&&r.config===void 0&&!Mw(r)?null:Li(r)&&r.config!==void 0&&Hf(r.config)?ee.resolve(r.config):Li(r)&&r.config!==void 0&&Li(r.config)?null:Hf(r)?ee.resolve(r):Lw()}function Lw(){for(let r of Bw)try{let e=ee.resolve(r);return re.accessSync(e),e}catch(e){}return null}var Bw,Yf=S(()=>{l();Ve();St();Bw=["./tailwind.config.js","./tailwind.config.cjs","./tailwind.config.mjs","./tailwind.config.ts"]});var Qf={};_e(Qf,{default:()=>Ls});var Ls,$s=S(()=>{l();Ls={parse:r=>({href:r})}});var Ns=x(()=>{l()});var $i=x((k4,Kf)=>{l();"use strict";var Jf=(Tt(),of),Xf=Ns(),qt=class extends Error{constructor(e,t,i,n,s,a){super(e);this.name="CssSyntaxError",this.reason=e,s&&(this.file=s),n&&(this.source=n),a&&(this.plugin=a),typeof t!="undefined"&&typeof i!="undefined"&&(typeof t=="number"?(this.line=t,this.column=i):(this.line=t.line,this.column=t.column,this.endLine=i.line,this.endColumn=i.column)),this.setMessage(),Error.captureStackTrace&&Error.captureStackTrace(this,qt)}setMessage(){this.message=this.plugin?this.plugin+": ":"",this.message+=this.file?this.file:"",typeof this.line!="undefined"&&(this.message+=":"+this.line+":"+this.column),this.message+=": "+this.reason}showSourceCode(e){if(!this.source)return"";let t=this.source;e==null&&(e=Jf.isColorSupported);let i=f=>f,n=f=>f,s=f=>f;if(e){let{bold:f,gray:d,red:p}=Jf.createColors(!0);n=g=>f(p(g)),i=g=>d(g),Xf&&(s=g=>Xf(g))}let a=t.split(/\r?\n/),o=Math.max(this.line-3,0),u=Math.min(this.line+2,a.length),c=String(u).length;return a.slice(o,u).map((f,d)=>{let p=o+1+d,g=" "+(" "+p).slice(-c)+" | ";if(p===this.line){if(f.length>160){let v=20,y=Math.max(0,this.column-v),w=Math.max(this.column+v,this.endColumn+v),k=f.slice(y,w),C=i(g.replace(/\d/g," "))+f.slice(0,Math.min(this.column-1,v-1)).replace(/[^\t]/g," ");return n(">")+i(g)+s(k)+`
+ `+C+n("^")}let b=i(g.replace(/\d/g," "))+f.slice(0,this.column-1).replace(/[^\t]/g," ");return n(">")+i(g)+s(f)+`
+ `+b+n("^")}return" "+i(g)+s(f)}).join(`
+`)}toString(){let e=this.showSourceCode();return e&&(e=`
+
+`+e+`
+`),this.name+": "+this.message+e}};Kf.exports=qt;qt.default=qt});var zs=x((S4,ec)=>{l();"use strict";var Zf={after:`
+`,beforeClose:`
+`,beforeComment:`
+`,beforeDecl:`
+`,beforeOpen:" ",beforeRule:`
+`,colon:": ",commentLeft:" ",commentRight:" ",emptyBody:"",indent:" ",semicolon:!1};function $w(r){return r[0].toUpperCase()+r.slice(1)}var Ni=class{constructor(e){this.builder=e}atrule(e,t){let i="@"+e.name,n=e.params?this.rawValue(e,"params"):"";if(typeof e.raws.afterName!="undefined"?i+=e.raws.afterName:n&&(i+=" "),e.nodes)this.block(e,i+n);else{let s=(e.raws.between||"")+(t?";":"");this.builder(i+n+s,e)}}beforeAfter(e,t){let i;e.type==="decl"?i=this.raw(e,null,"beforeDecl"):e.type==="comment"?i=this.raw(e,null,"beforeComment"):t==="before"?i=this.raw(e,null,"beforeRule"):i=this.raw(e,null,"beforeClose");let n=e.parent,s=0;for(;n&&n.type!=="root";)s+=1,n=n.parent;if(i.includes(`
+`)){let a=this.raw(e,null,"indent");if(a.length)for(let o=0;o0&&e.nodes[t].type==="comment";)t-=1;let i=this.raw(e,"semicolon");for(let n=0;n{if(n=u.raws[t],typeof n!="undefined")return!1})}return typeof n=="undefined"&&(n=Zf[i]),a.rawCache[i]=n,n}rawBeforeClose(e){let t;return e.walk(i=>{if(i.nodes&&i.nodes.length>0&&typeof i.raws.after!="undefined")return t=i.raws.after,t.includes(`
+`)&&(t=t.replace(/[^\n]+$/,"")),!1}),t&&(t=t.replace(/\S/g,"")),t}rawBeforeComment(e,t){let i;return e.walkComments(n=>{if(typeof n.raws.before!="undefined")return i=n.raws.before,i.includes(`
+`)&&(i=i.replace(/[^\n]+$/,"")),!1}),typeof i=="undefined"?i=this.raw(t,null,"beforeDecl"):i&&(i=i.replace(/\S/g,"")),i}rawBeforeDecl(e,t){let i;return e.walkDecls(n=>{if(typeof n.raws.before!="undefined")return i=n.raws.before,i.includes(`
+`)&&(i=i.replace(/[^\n]+$/,"")),!1}),typeof i=="undefined"?i=this.raw(t,null,"beforeRule"):i&&(i=i.replace(/\S/g,"")),i}rawBeforeOpen(e){let t;return e.walk(i=>{if(i.type!=="decl"&&(t=i.raws.between,typeof t!="undefined"))return!1}),t}rawBeforeRule(e){let t;return e.walk(i=>{if(i.nodes&&(i.parent!==e||e.first!==i)&&typeof i.raws.before!="undefined")return t=i.raws.before,t.includes(`
+`)&&(t=t.replace(/[^\n]+$/,"")),!1}),t&&(t=t.replace(/\S/g,"")),t}rawColon(e){let t;return e.walkDecls(i=>{if(typeof i.raws.between!="undefined")return t=i.raws.between.replace(/[^\s:]/g,""),!1}),t}rawEmptyBody(e){let t;return e.walk(i=>{if(i.nodes&&i.nodes.length===0&&(t=i.raws.after,typeof t!="undefined"))return!1}),t}rawIndent(e){if(e.raws.indent)return e.raws.indent;let t;return e.walk(i=>{let n=i.parent;if(n&&n!==e&&n.parent&&n.parent===e&&typeof i.raws.before!="undefined"){let s=i.raws.before.split(`
+`);return t=s[s.length-1],t=t.replace(/\S/g,""),!1}}),t}rawSemicolon(e){let t;return e.walk(i=>{if(i.nodes&&i.nodes.length&&i.last.type==="decl"&&(t=i.raws.semicolon,typeof t!="undefined"))return!1}),t}rawValue(e,t){let i=e[t],n=e.raws[t];return n&&n.value===i?n.raw:i}root(e){this.body(e),e.raws.after&&this.builder(e.raws.after)}rule(e){this.block(e,this.rawValue(e,"selector")),e.raws.ownSemicolon&&this.builder(e.raws.ownSemicolon,e,"end")}stringify(e,t){if(!this[e.type])throw new Error("Unknown AST node type "+e.type+". Maybe you need to change PostCSS stringifier.");this[e.type](e,t)}};ec.exports=Ni;Ni.default=Ni});var Sr=x((C4,tc)=>{l();"use strict";var Nw=zs();function js(r,e){new Nw(e).stringify(r)}tc.exports=js;js.default=js});var zi=x((A4,Us)=>{l();"use strict";Us.exports.isClean=Symbol("isClean");Us.exports.my=Symbol("my")});var Or=x((O4,rc)=>{l();"use strict";var zw=$i(),jw=zs(),Uw=Sr(),{isClean:Cr,my:Vw}=zi();function Vs(r,e){let t=new r.constructor;for(let i in r){if(!Object.prototype.hasOwnProperty.call(r,i)||i==="proxyCache")continue;let n=r[i],s=typeof n;i==="parent"&&s==="object"?e&&(t[i]=e):i==="source"?t[i]=n:Array.isArray(n)?t[i]=n.map(a=>Vs(a,t)):(s==="object"&&n!==null&&(n=Vs(n)),t[i]=n)}return t}function Ar(r,e){if(e&&typeof e.offset!="undefined")return e.offset;let t=1,i=1,n=0;for(let s=0;se.root().toProxy():e[t]},set(e,t,i){return e[t]===i||(e[t]=i,(t==="prop"||t==="value"||t==="name"||t==="params"||t==="important"||t==="text")&&e.markDirty()),!0}}}markClean(){this[Cr]=!0}markDirty(){if(this[Cr]){this[Cr]=!1;let e=this;for(;e=e.parent;)e[Cr]=!1}}next(){if(!this.parent)return;let e=this.parent.index(this);return this.parent.nodes[e+1]}positionBy(e){let t=this.source.start;if(e.index)t=this.positionInside(e.index);else if(e.word){let n=this.source.input.css.slice(Ar(this.source.input.css,this.source.start),Ar(this.source.input.css,this.source.end)).indexOf(e.word);n!==-1&&(t=this.positionInside(n))}return t}positionInside(e){let t=this.source.start.column,i=this.source.start.line,n=Ar(this.source.input.css,this.source.start),s=n+e;for(let a=n;atypeof u=="object"&&u.toJSON?u.toJSON(null,t):u);else if(typeof o=="object"&&o.toJSON)i[a]=o.toJSON(null,t);else if(a==="source"){let u=t.get(o.input);u==null&&(u=s,t.set(o.input,s),s++),i[a]={end:o.end,inputId:u,start:o.start}}else i[a]=o}return n&&(i.inputs=[...t.keys()].map(a=>a.toJSON())),i}toProxy(){return this.proxyCache||(this.proxyCache=new Proxy(this,this.getProxyProcessor())),this.proxyCache}toString(e=Uw){e.stringify&&(e=e.stringify);let t="";return e(this,i=>{t+=i}),t}warn(e,t,i){let n={node:this};for(let s in i)n[s]=i[s];return e.warn(t,n)}get proxyOf(){return this}};rc.exports=ji;ji.default=ji});var _r=x((_4,ic)=>{l();"use strict";var Ww=Or(),Ui=class extends Ww{constructor(e){super(e);this.type="comment"}};ic.exports=Ui;Ui.default=Ui});var Er=x((E4,nc)=>{l();"use strict";var Gw=Or(),Vi=class extends Gw{constructor(e){e&&typeof e.value!="undefined"&&typeof e.value!="string"&&(e={...e,value:String(e.value)});super(e);this.type="decl"}get variable(){return this.prop.startsWith("--")||this.prop[0]==="$"}};nc.exports=Vi;Vi.default=Vi});var st=x((T4,dc)=>{l();"use strict";var sc=_r(),ac=Er(),Hw=Or(),{isClean:oc,my:lc}=zi(),Ws,uc,fc,Gs;function cc(r){return r.map(e=>(e.nodes&&(e.nodes=cc(e.nodes)),delete e.source,e))}function pc(r){if(r[oc]=!1,r.proxyOf.nodes)for(let e of r.proxyOf.nodes)pc(e)}var xe=class extends Hw{append(...e){for(let t of e){let i=this.normalize(t,this.last);for(let n of i)this.proxyOf.nodes.push(n)}return this.markDirty(),this}cleanRaws(e){if(super.cleanRaws(e),this.nodes)for(let t of this.nodes)t.cleanRaws(e)}each(e){if(!this.proxyOf.nodes)return;let t=this.getIterator(),i,n;for(;this.indexes[t]e[t](...i.map(n=>typeof n=="function"?(s,a)=>n(s.toProxy(),a):n)):t==="every"||t==="some"?i=>e[t]((n,...s)=>i(n.toProxy(),...s)):t==="root"?()=>e.root().toProxy():t==="nodes"?e.nodes.map(i=>i.toProxy()):t==="first"||t==="last"?e[t].toProxy():e[t]:e[t]},set(e,t,i){return e[t]===i||(e[t]=i,(t==="name"||t==="params"||t==="selector")&&e.markDirty()),!0}}}index(e){return typeof e=="number"?e:(e.proxyOf&&(e=e.proxyOf),this.proxyOf.nodes.indexOf(e))}insertAfter(e,t){let i=this.index(e),n=this.normalize(t,this.proxyOf.nodes[i]).reverse();i=this.index(e);for(let a of n)this.proxyOf.nodes.splice(i+1,0,a);let s;for(let a in this.indexes)s=this.indexes[a],i(n[lc]||xe.rebuild(n),n=n.proxyOf,n.parent&&n.parent.removeChild(n),n[oc]&&pc(n),n.raws||(n.raws={}),typeof n.raws.before=="undefined"&&t&&typeof t.raws.before!="undefined"&&(n.raws.before=t.raws.before.replace(/\S/g,"")),n.parent=this.proxyOf,n))}prepend(...e){e=e.reverse();for(let t of e){let i=this.normalize(t,this.first,"prepend").reverse();for(let n of i)this.proxyOf.nodes.unshift(n);for(let n in this.indexes)this.indexes[n]=this.indexes[n]+i.length}return this.markDirty(),this}push(e){return e.parent=this,this.proxyOf.nodes.push(e),this}removeAll(){for(let e of this.proxyOf.nodes)e.parent=void 0;return this.proxyOf.nodes=[],this.markDirty(),this}removeChild(e){e=this.index(e),this.proxyOf.nodes[e].parent=void 0,this.proxyOf.nodes.splice(e,1);let t;for(let i in this.indexes)t=this.indexes[i],t>=e&&(this.indexes[i]=t-1);return this.markDirty(),this}replaceValues(e,t,i){return i||(i=t,t={}),this.walkDecls(n=>{t.props&&!t.props.includes(n.prop)||t.fast&&!n.value.includes(t.fast)||(n.value=n.value.replace(e,i))}),this.markDirty(),this}some(e){return this.nodes.some(e)}walk(e){return this.each((t,i)=>{let n;try{n=e(t,i)}catch(s){throw t.addToError(s)}return n!==!1&&t.walk&&(n=t.walk(e)),n})}walkAtRules(e,t){return t?e instanceof RegExp?this.walk((i,n)=>{if(i.type==="atrule"&&e.test(i.name))return t(i,n)}):this.walk((i,n)=>{if(i.type==="atrule"&&i.name===e)return t(i,n)}):(t=e,this.walk((i,n)=>{if(i.type==="atrule")return t(i,n)}))}walkComments(e){return this.walk((t,i)=>{if(t.type==="comment")return e(t,i)})}walkDecls(e,t){return t?e instanceof RegExp?this.walk((i,n)=>{if(i.type==="decl"&&e.test(i.prop))return t(i,n)}):this.walk((i,n)=>{if(i.type==="decl"&&i.prop===e)return t(i,n)}):(t=e,this.walk((i,n)=>{if(i.type==="decl")return t(i,n)}))}walkRules(e,t){return t?e instanceof RegExp?this.walk((i,n)=>{if(i.type==="rule"&&e.test(i.selector))return t(i,n)}):this.walk((i,n)=>{if(i.type==="rule"&&i.selector===e)return t(i,n)}):(t=e,this.walk((i,n)=>{if(i.type==="rule")return t(i,n)}))}get first(){if(!!this.proxyOf.nodes)return this.proxyOf.nodes[0]}get last(){if(!!this.proxyOf.nodes)return this.proxyOf.nodes[this.proxyOf.nodes.length-1]}};xe.registerParse=r=>{uc=r};xe.registerRule=r=>{Gs=r};xe.registerAtRule=r=>{Ws=r};xe.registerRoot=r=>{fc=r};dc.exports=xe;xe.default=xe;xe.rebuild=r=>{r.type==="atrule"?Object.setPrototypeOf(r,Ws.prototype):r.type==="rule"?Object.setPrototypeOf(r,Gs.prototype):r.type==="decl"?Object.setPrototypeOf(r,ac.prototype):r.type==="comment"?Object.setPrototypeOf(r,sc.prototype):r.type==="root"&&Object.setPrototypeOf(r,fc.prototype),r[lc]=!0,r.nodes&&r.nodes.forEach(e=>{xe.rebuild(e)})}});var Wi=x((P4,mc)=>{l();"use strict";var hc=st(),Tr=class extends hc{constructor(e){super(e);this.type="atrule"}append(...e){return this.proxyOf.nodes||(this.nodes=[]),super.append(...e)}prepend(...e){return this.proxyOf.nodes||(this.nodes=[]),super.prepend(...e)}};mc.exports=Tr;Tr.default=Tr;hc.registerAtRule(Tr)});var Gi=x((D4,bc)=>{l();"use strict";var Yw=st(),gc,yc,Ft=class extends Yw{constructor(e){super({type:"document",...e});this.nodes||(this.nodes=[])}toResult(e={}){return new gc(new yc,this,e).stringify()}};Ft.registerLazyResult=r=>{gc=r};Ft.registerProcessor=r=>{yc=r};bc.exports=Ft;Ft.default=Ft});var xc=x((I4,wc)=>{l();var Qw="useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict",Jw=(r,e=21)=>(t=e)=>{let i="",n=t;for(;n--;)i+=r[Math.random()*r.length|0];return i},Xw=(r=21)=>{let e="",t=r;for(;t--;)e+=Qw[Math.random()*64|0];return e};wc.exports={nanoid:Xw,customAlphabet:Jw}});var vc=x(()=>{l()});var Hs=x((F4,kc)=>{l();kc.exports={}});var Yi=x((B4,Oc)=>{l();"use strict";var{nanoid:Kw}=xc(),{isAbsolute:Ys,resolve:Qs}=(St(),Gf),{SourceMapConsumer:Zw,SourceMapGenerator:ex}=vc(),{fileURLToPath:Sc,pathToFileURL:Hi}=($s(),Qf),Cc=$i(),tx=Hs(),Js=Ns(),Xs=Symbol("fromOffsetCache"),rx=Boolean(Zw&&ex),Ac=Boolean(Qs&&Ys),Pr=class{constructor(e,t={}){if(e===null||typeof e=="undefined"||typeof e=="object"&&!e.toString)throw new Error(`PostCSS received ${e} instead of CSS string`);if(this.css=e.toString(),this.css[0]==="\uFEFF"||this.css[0]==="\uFFFE"?(this.hasBOM=!0,this.css=this.css.slice(1)):this.hasBOM=!1,t.from&&(!Ac||/^\w+:\/\//.test(t.from)||Ys(t.from)?this.file=t.from:this.file=Qs(t.from)),Ac&&rx){let i=new tx(this.css,t);if(i.text){this.map=i;let n=i.consumer().file;!this.file&&n&&(this.file=this.mapResolve(n))}}this.file||(this.id=" "),this.map&&(this.map.file=this.from)}error(e,t,i,n={}){let s,a,o;if(t&&typeof t=="object"){let c=t,f=i;if(typeof c.offset=="number"){let d=this.fromOffset(c.offset);t=d.line,i=d.col}else t=c.line,i=c.column;if(typeof f.offset=="number"){let d=this.fromOffset(f.offset);a=d.line,s=d.col}else a=f.line,s=f.column}else if(!i){let c=this.fromOffset(t);t=c.line,i=c.col}let u=this.origin(t,i,a,s);return u?o=new Cc(e,u.endLine===void 0?u.line:{column:u.column,line:u.line},u.endLine===void 0?u.column:{column:u.endColumn,line:u.endLine},u.source,u.file,n.plugin):o=new Cc(e,a===void 0?t:{column:i,line:t},a===void 0?i:{column:s,line:a},this.css,this.file,n.plugin),o.input={column:i,endColumn:s,endLine:a,line:t,source:this.css},this.file&&(Hi&&(o.input.url=Hi(this.file).toString()),o.input.file=this.file),o}fromOffset(e){let t,i;if(this[Xs])i=this[Xs];else{let s=this.css.split(`
+`);i=new Array(s.length);let a=0;for(let o=0,u=s.length;o=t)n=i.length-1;else{let s=i.length-2,a;for(;n>1),e=i[a+1])n=a+1;else{n=a;break}}return{col:e-i[n]+1,line:n+1}}mapResolve(e){return/^\w+:\/\//.test(e)?e:Qs(this.map.consumer().sourceRoot||this.map.root||".",e)}origin(e,t,i,n){if(!this.map)return!1;let s=this.map.consumer(),a=s.originalPositionFor({column:t,line:e});if(!a.source)return!1;let o;typeof i=="number"&&(o=s.originalPositionFor({column:n,line:i}));let u;Ys(a.source)?u=Hi(a.source):u=new URL(a.source,this.map.consumer().sourceRoot||Hi(this.map.mapFile));let c={column:a.column,endColumn:o&&o.column,endLine:o&&o.line,line:a.line,url:u.toString()};if(u.protocol==="file:")if(Sc)c.file=Sc(u);else throw new Error("file: protocol is not available in this PostCSS build");let f=s.sourceContentFor(a.source);return f&&(c.source=f),c}toJSON(){let e={};for(let t of["hasBOM","css","file","id"])this[t]!=null&&(e[t]=this[t]);return this.map&&(e.map={...this.map},e.map.consumerCache&&(e.map.consumerCache=void 0)),e}get from(){return this.file||this.id}};Oc.exports=Pr;Pr.default=Pr;Js&&Js.registerInput&&Js.registerInput(Pr)});var Bt=x((M4,Pc)=>{l();"use strict";var _c=st(),Ec,Tc,Ct=class extends _c{constructor(e){super(e);this.type="root",this.nodes||(this.nodes=[])}normalize(e,t,i){let n=super.normalize(e);if(t){if(i==="prepend")this.nodes.length>1?t.raws.before=this.nodes[1].raws.before:delete t.raws.before;else if(this.first!==t)for(let s of n)s.raws.before=t.raws.before}return n}removeChild(e,t){let i=this.index(e);return!t&&i===0&&this.nodes.length>1&&(this.nodes[1].raws.before=this.nodes[i].raws.before),super.removeChild(e)}toResult(e={}){return new Ec(new Tc,this,e).stringify()}};Ct.registerLazyResult=r=>{Ec=r};Ct.registerProcessor=r=>{Tc=r};Pc.exports=Ct;Ct.default=Ct;_c.registerRoot(Ct)});var Ks=x((L4,Dc)=>{l();"use strict";var Dr={comma(r){return Dr.split(r,[","],!0)},space(r){let e=[" ",`
+`," "];return Dr.split(r,e)},split(r,e,t){let i=[],n="",s=!1,a=0,o=!1,u="",c=!1;for(let f of r)c?c=!1:f==="\\"?c=!0:o?f===u&&(o=!1):f==='"'||f==="'"?(o=!0,u=f):f==="("?a+=1:f===")"?a>0&&(a-=1):a===0&&e.includes(f)&&(s=!0),s?(n!==""&&i.push(n.trim()),n="",s=!1):n+=f;return(t||n!=="")&&i.push(n.trim()),i}};Dc.exports=Dr;Dr.default=Dr});var Qi=x(($4,Rc)=>{l();"use strict";var Ic=st(),ix=Ks(),Ir=class extends Ic{constructor(e){super(e);this.type="rule",this.nodes||(this.nodes=[])}get selectors(){return ix.comma(this.selector)}set selectors(e){let t=this.selector?this.selector.match(/,\s*/):null,i=t?t[0]:","+this.raw("between","beforeOpen");this.selector=e.join(i)}};Rc.exports=Ir;Ir.default=Ir;Ic.registerRule(Ir)});var Fc=x((N4,qc)=>{l();"use strict";var nx=Wi(),sx=_r(),ax=Er(),ox=Yi(),lx=Hs(),ux=Bt(),fx=Qi();function Rr(r,e){if(Array.isArray(r))return r.map(n=>Rr(n));let{inputs:t,...i}=r;if(t){e=[];for(let n of t){let s={...n,__proto__:ox.prototype};s.map&&(s.map={...s.map,__proto__:lx.prototype}),e.push(s)}}if(i.nodes&&(i.nodes=r.nodes.map(n=>Rr(n,e))),i.source){let{inputId:n,...s}=i.source;i.source=s,n!=null&&(i.source.input=e[n])}if(i.type==="root")return new ux(i);if(i.type==="decl")return new ax(i);if(i.type==="rule")return new fx(i);if(i.type==="comment")return new sx(i);if(i.type==="atrule")return new nx(i);throw new Error("Unknown node type: "+r.type)}qc.exports=Rr;Rr.default=Rr});var Zs=x((z4,Bc)=>{l();Bc.exports=function(r,e){return{generate:()=>{let t="";return r(e,i=>{t+=i}),[t]}}}});var zc=x((j4,Nc)=>{l();"use strict";var ea="'".charCodeAt(0),Mc='"'.charCodeAt(0),Ji="\\".charCodeAt(0),Lc="/".charCodeAt(0),Xi=`
+`.charCodeAt(0),qr=" ".charCodeAt(0),Ki="\f".charCodeAt(0),Zi=" ".charCodeAt(0),en="\r".charCodeAt(0),cx="[".charCodeAt(0),px="]".charCodeAt(0),dx="(".charCodeAt(0),hx=")".charCodeAt(0),mx="{".charCodeAt(0),gx="}".charCodeAt(0),yx=";".charCodeAt(0),bx="*".charCodeAt(0),wx=":".charCodeAt(0),xx="@".charCodeAt(0),tn=/[\t\n\f\r "#'()/;[\\\]{}]/g,rn=/[\t\n\f\r !"#'():;@[\\\]{}]|\/(?=\*)/g,vx=/.[\r\n"'(/\\]/,$c=/[\da-f]/i;Nc.exports=function(e,t={}){let i=e.css.valueOf(),n=t.ignoreErrors,s,a,o,u,c,f,d,p,g,b,v=i.length,y=0,w=[],k=[];function C(){return y}function O(R){throw e.error("Unclosed "+R,y)}function _(){return k.length===0&&y>=v}function I(R){if(k.length)return k.pop();if(y>=v)return;let X=R?R.ignoreUnclosed:!1;switch(s=i.charCodeAt(y),s){case Xi:case qr:case Zi:case en:case Ki:{u=y;do u+=1,s=i.charCodeAt(u);while(s===qr||s===Xi||s===Zi||s===en||s===Ki);f=["space",i.slice(y,u)],y=u-1;break}case cx:case px:case mx:case gx:case wx:case yx:case hx:{let ue=String.fromCharCode(s);f=[ue,ue,y];break}case dx:{if(b=w.length?w.pop()[1]:"",g=i.charCodeAt(y+1),b==="url"&&g!==ea&&g!==Mc&&g!==qr&&g!==Xi&&g!==Zi&&g!==Ki&&g!==en){u=y;do{if(d=!1,u=i.indexOf(")",u+1),u===-1)if(n||X){u=y;break}else O("bracket");for(p=u;i.charCodeAt(p-1)===Ji;)p-=1,d=!d}while(d);f=["brackets",i.slice(y,u+1),y,u],y=u}else u=i.indexOf(")",y+1),a=i.slice(y,u+1),u===-1||vx.test(a)?f=["(","(",y]:(f=["brackets",a,y,u],y=u);break}case ea:case Mc:{c=s===ea?"'":'"',u=y;do{if(d=!1,u=i.indexOf(c,u+1),u===-1)if(n||X){u=y+1;break}else O("string");for(p=u;i.charCodeAt(p-1)===Ji;)p-=1,d=!d}while(d);f=["string",i.slice(y,u+1),y,u],y=u;break}case xx:{tn.lastIndex=y+1,tn.test(i),tn.lastIndex===0?u=i.length-1:u=tn.lastIndex-2,f=["at-word",i.slice(y,u+1),y,u],y=u;break}case Ji:{for(u=y,o=!0;i.charCodeAt(u+1)===Ji;)u+=1,o=!o;if(s=i.charCodeAt(u+1),o&&s!==Lc&&s!==qr&&s!==Xi&&s!==Zi&&s!==en&&s!==Ki&&(u+=1,$c.test(i.charAt(u)))){for(;$c.test(i.charAt(u+1));)u+=1;i.charCodeAt(u+1)===qr&&(u+=1)}f=["word",i.slice(y,u+1),y,u],y=u;break}default:{s===Lc&&i.charCodeAt(y+1)===bx?(u=i.indexOf("*/",y+2)+1,u===0&&(n||X?u=i.length:O("comment")),f=["comment",i.slice(y,u+1),y,u],y=u):(rn.lastIndex=y+1,rn.test(i),rn.lastIndex===0?u=i.length-1:u=rn.lastIndex-2,f=["word",i.slice(y,u+1),y,u],w.push(f),y=u);break}}return y++,f}function B(R){k.push(R)}return{back:B,endOfFile:_,nextToken:I,position:C}}});var Gc=x((U4,Wc)=>{l();"use strict";var kx=Wi(),Sx=_r(),Cx=Er(),Ax=Bt(),jc=Qi(),Ox=zc(),Uc={empty:!0,space:!0};function _x(r){for(let e=r.length-1;e>=0;e--){let t=r[e],i=t[3]||t[2];if(i)return i}}var Vc=class{constructor(e){this.input=e,this.root=new Ax,this.current=this.root,this.spaces="",this.semicolon=!1,this.createTokenizer(),this.root.source={input:e,start:{column:1,line:1,offset:0}}}atrule(e){let t=new kx;t.name=e[1].slice(1),t.name===""&&this.unnamedAtrule(t,e),this.init(t,e[2]);let i,n,s,a=!1,o=!1,u=[],c=[];for(;!this.tokenizer.endOfFile();){if(e=this.tokenizer.nextToken(),i=e[0],i==="("||i==="["?c.push(i==="("?")":"]"):i==="{"&&c.length>0?c.push("}"):i===c[c.length-1]&&c.pop(),c.length===0)if(i===";"){t.source.end=this.getPosition(e[2]),t.source.end.offset++,this.semicolon=!0;break}else if(i==="{"){o=!0;break}else if(i==="}"){if(u.length>0){for(s=u.length-1,n=u[s];n&&n[0]==="space";)n=u[--s];n&&(t.source.end=this.getPosition(n[3]||n[2]),t.source.end.offset++)}this.end(e);break}else u.push(e);else u.push(e);if(this.tokenizer.endOfFile()){a=!0;break}}t.raws.between=this.spacesAndCommentsFromEnd(u),u.length?(t.raws.afterName=this.spacesAndCommentsFromStart(u),this.raw(t,"params",u),a&&(e=u[u.length-1],t.source.end=this.getPosition(e[3]||e[2]),t.source.end.offset++,this.spaces=t.raws.between,t.raws.between="")):(t.raws.afterName="",t.params=""),o&&(t.nodes=[],this.current=t)}checkMissedSemicolon(e){let t=this.colon(e);if(t===!1)return;let i=0,n;for(let s=t-1;s>=0&&(n=e[s],!(n[0]!=="space"&&(i+=1,i===2)));s--);throw this.input.error("Missed semicolon",n[0]==="word"?n[3]+1:n[2])}colon(e){let t=0,i,n,s;for(let[a,o]of e.entries()){if(n=o,s=n[0],s==="("&&(t+=1),s===")"&&(t-=1),t===0&&s===":")if(!i)this.doubleColon(n);else{if(i[0]==="word"&&i[1]==="progid")continue;return a}i=n}return!1}comment(e){let t=new Sx;this.init(t,e[2]),t.source.end=this.getPosition(e[3]||e[2]),t.source.end.offset++;let i=e[1].slice(2,-2);if(/^\s*$/.test(i))t.text="",t.raws.left=i,t.raws.right="";else{let n=i.match(/^(\s*)([^]*\S)(\s*)$/);t.text=n[2],t.raws.left=n[1],t.raws.right=n[3]}}createTokenizer(){this.tokenizer=Ox(this.input)}decl(e,t){let i=new Cx;this.init(i,e[0][2]);let n=e[e.length-1];for(n[0]===";"&&(this.semicolon=!0,e.pop()),i.source.end=this.getPosition(n[3]||n[2]||_x(e)),i.source.end.offset++;e[0][0]!=="word";)e.length===1&&this.unknownWord(e),i.raws.before+=e.shift()[1];for(i.source.start=this.getPosition(e[0][2]),i.prop="";e.length;){let c=e[0][0];if(c===":"||c==="space"||c==="comment")break;i.prop+=e.shift()[1]}i.raws.between="";let s;for(;e.length;)if(s=e.shift(),s[0]===":"){i.raws.between+=s[1];break}else s[0]==="word"&&/\w/.test(s[1])&&this.unknownWord([s]),i.raws.between+=s[1];(i.prop[0]==="_"||i.prop[0]==="*")&&(i.raws.before+=i.prop[0],i.prop=i.prop.slice(1));let a=[],o;for(;e.length&&(o=e[0][0],!(o!=="space"&&o!=="comment"));)a.push(e.shift());this.precheckMissedSemicolon(e);for(let c=e.length-1;c>=0;c--){if(s=e[c],s[1].toLowerCase()==="!important"){i.important=!0;let f=this.stringFrom(e,c);f=this.spacesFromEnd(e)+f,f!==" !important"&&(i.raws.important=f);break}else if(s[1].toLowerCase()==="important"){let f=e.slice(0),d="";for(let p=c;p>0;p--){let g=f[p][0];if(d.trim().startsWith("!")&&g!=="space")break;d=f.pop()[1]+d}d.trim().startsWith("!")&&(i.important=!0,i.raws.important=d,e=f)}if(s[0]!=="space"&&s[0]!=="comment")break}e.some(c=>c[0]!=="space"&&c[0]!=="comment")&&(i.raws.between+=a.map(c=>c[1]).join(""),a=[]),this.raw(i,"value",a.concat(e),t),i.value.includes(":")&&!t&&this.checkMissedSemicolon(e)}doubleColon(e){throw this.input.error("Double colon",{offset:e[2]},{offset:e[2]+e[1].length})}emptyRule(e){let t=new jc;this.init(t,e[2]),t.selector="",t.raws.between="",this.current=t}end(e){this.current.nodes&&this.current.nodes.length&&(this.current.raws.semicolon=this.semicolon),this.semicolon=!1,this.current.raws.after=(this.current.raws.after||"")+this.spaces,this.spaces="",this.current.parent?(this.current.source.end=this.getPosition(e[2]),this.current.source.end.offset++,this.current=this.current.parent):this.unexpectedClose(e)}endFile(){this.current.parent&&this.unclosedBlock(),this.current.nodes&&this.current.nodes.length&&(this.current.raws.semicolon=this.semicolon),this.current.raws.after=(this.current.raws.after||"")+this.spaces,this.root.source.end=this.getPosition(this.tokenizer.position())}freeSemicolon(e){if(this.spaces+=e[1],this.current.nodes){let t=this.current.nodes[this.current.nodes.length-1];t&&t.type==="rule"&&!t.raws.ownSemicolon&&(t.raws.ownSemicolon=this.spaces,this.spaces="")}}getPosition(e){let t=this.input.fromOffset(e);return{column:t.col,line:t.line,offset:e}}init(e,t){this.current.push(e),e.source={input:this.input,start:this.getPosition(t)},e.raws.before=this.spaces,this.spaces="",e.type!=="comment"&&(this.semicolon=!1)}other(e){let t=!1,i=null,n=!1,s=null,a=[],o=e[1].startsWith("--"),u=[],c=e;for(;c;){if(i=c[0],u.push(c),i==="("||i==="[")s||(s=c),a.push(i==="("?")":"]");else if(o&&n&&i==="{")s||(s=c),a.push("}");else if(a.length===0)if(i===";")if(n){this.decl(u,o);return}else break;else if(i==="{"){this.rule(u);return}else if(i==="}"){this.tokenizer.back(u.pop()),t=!0;break}else i===":"&&(n=!0);else i===a[a.length-1]&&(a.pop(),a.length===0&&(s=null));c=this.tokenizer.nextToken()}if(this.tokenizer.endOfFile()&&(t=!0),a.length>0&&this.unclosedBracket(s),t&&n){if(!o)for(;u.length&&(c=u[u.length-1][0],!(c!=="space"&&c!=="comment"));)this.tokenizer.back(u.pop());this.decl(u,o)}else this.unknownWord(u)}parse(){let e;for(;!this.tokenizer.endOfFile();)switch(e=this.tokenizer.nextToken(),e[0]){case"space":this.spaces+=e[1];break;case";":this.freeSemicolon(e);break;case"}":this.end(e);break;case"comment":this.comment(e);break;case"at-word":this.atrule(e);break;case"{":this.emptyRule(e);break;default:this.other(e);break}this.endFile()}precheckMissedSemicolon(){}raw(e,t,i,n){let s,a,o=i.length,u="",c=!0,f,d;for(let p=0;pg+b[1],"");e.raws[t]={raw:p,value:u}}e[t]=u}rule(e){e.pop();let t=new jc;this.init(t,e[0][2]),t.raws.between=this.spacesAndCommentsFromEnd(e),this.raw(t,"selector",e),this.current=t}spacesAndCommentsFromEnd(e){let t,i="";for(;e.length&&(t=e[e.length-1][0],!(t!=="space"&&t!=="comment"));)i=e.pop()[1]+i;return i}spacesAndCommentsFromStart(e){let t,i="";for(;e.length&&(t=e[0][0],!(t!=="space"&&t!=="comment"));)i+=e.shift()[1];return i}spacesFromEnd(e){let t,i="";for(;e.length&&(t=e[e.length-1][0],t==="space");)i=e.pop()[1]+i;return i}stringFrom(e,t){let i="";for(let n=t;n{l();"use strict";var Ex=st(),Tx=Yi(),Px=Gc();function nn(r,e){let t=new Tx(r,e),i=new Px(t);try{i.parse()}catch(n){throw n}return i.root}Hc.exports=nn;nn.default=nn;Ex.registerParse(nn)});var ta=x((W4,Yc)=>{l();"use strict";var an=class{constructor(e,t={}){if(this.type="warning",this.text=e,t.node&&t.node.source){let i=t.node.rangeBy(t);this.line=i.start.line,this.column=i.start.column,this.endLine=i.end.line,this.endColumn=i.end.column}for(let i in t)this[i]=t[i]}toString(){return this.node?this.node.error(this.text,{index:this.index,plugin:this.plugin,word:this.word}).message:this.plugin?this.plugin+": "+this.text:this.text}};Yc.exports=an;an.default=an});var ln=x((G4,Qc)=>{l();"use strict";var Dx=ta(),on=class{constructor(e,t,i){this.processor=e,this.messages=[],this.root=t,this.opts=i,this.css=void 0,this.map=void 0}toString(){return this.css}warn(e,t={}){t.plugin||this.lastPlugin&&this.lastPlugin.postcssPlugin&&(t.plugin=this.lastPlugin.postcssPlugin);let i=new Dx(e,t);return this.messages.push(i),i}warnings(){return this.messages.filter(e=>e.type==="warning")}get content(){return this.css}};Qc.exports=on;on.default=on});var ra=x((H4,Xc)=>{l();"use strict";var Jc={};Xc.exports=function(e){Jc[e]||(Jc[e]=!0,typeof console!="undefined"&&console.warn&&console.warn(e))}});var sa=x((Q4,tp)=>{l();"use strict";var Ix=st(),Rx=Gi(),qx=Zs(),Fx=sn(),Kc=ln(),Bx=Bt(),Mx=Sr(),{isClean:Re,my:Lx}=zi(),Y4=ra(),$x={atrule:"AtRule",comment:"Comment",decl:"Declaration",document:"Document",root:"Root",rule:"Rule"},Nx={AtRule:!0,AtRuleExit:!0,Comment:!0,CommentExit:!0,Declaration:!0,DeclarationExit:!0,Document:!0,DocumentExit:!0,Once:!0,OnceExit:!0,postcssPlugin:!0,prepare:!0,Root:!0,RootExit:!0,Rule:!0,RuleExit:!0},zx={Once:!0,postcssPlugin:!0,prepare:!0},Mt=0;function Fr(r){return typeof r=="object"&&typeof r.then=="function"}function Zc(r){let e=!1,t=$x[r.type];return r.type==="decl"?e=r.prop.toLowerCase():r.type==="atrule"&&(e=r.name.toLowerCase()),e&&r.append?[t,t+"-"+e,Mt,t+"Exit",t+"Exit-"+e]:e?[t,t+"-"+e,t+"Exit",t+"Exit-"+e]:r.append?[t,Mt,t+"Exit"]:[t,t+"Exit"]}function ep(r){let e;return r.type==="document"?e=["Document",Mt,"DocumentExit"]:r.type==="root"?e=["Root",Mt,"RootExit"]:e=Zc(r),{eventIndex:0,events:e,iterator:0,node:r,visitorIndex:0,visitors:[]}}function ia(r){return r[Re]=!1,r.nodes&&r.nodes.forEach(e=>ia(e)),r}var na={},Ge=class{constructor(e,t,i){this.stringified=!1,this.processed=!1;let n;if(typeof t=="object"&&t!==null&&(t.type==="root"||t.type==="document"))n=ia(t);else if(t instanceof Ge||t instanceof Kc)n=ia(t.root),t.map&&(typeof i.map=="undefined"&&(i.map={}),i.map.inline||(i.map.inline=!1),i.map.prev=t.map);else{let s=Fx;i.syntax&&(s=i.syntax.parse),i.parser&&(s=i.parser),s.parse&&(s=s.parse);try{n=s(t,i)}catch(a){this.processed=!0,this.error=a}n&&!n[Lx]&&Ix.rebuild(n)}this.result=new Kc(e,n,i),this.helpers={...na,postcss:na,result:this.result},this.plugins=this.processor.plugins.map(s=>typeof s=="object"&&s.prepare?{...s,...s.prepare(this.result)}:s)}async(){return this.error?Promise.reject(this.error):this.processed?Promise.resolve(this.result):(this.processing||(this.processing=this.runAsync()),this.processing)}catch(e){return this.async().catch(e)}finally(e){return this.async().then(e,e)}getAsyncError(){throw new Error("Use process(css).then(cb) to work with async plugins")}handleError(e,t){let i=this.result.lastPlugin;try{t&&t.addToError(e),this.error=e,e.name==="CssSyntaxError"&&!e.plugin?(e.plugin=i.postcssPlugin,e.setMessage()):i.postcssVersion}catch(n){console&&console.error&&console.error(n)}return e}prepareVisitors(){this.listeners={};let e=(t,i,n)=>{this.listeners[i]||(this.listeners[i]=[]),this.listeners[i].push([t,n])};for(let t of this.plugins)if(typeof t=="object")for(let i in t){if(!Nx[i]&&/^[A-Z]/.test(i))throw new Error(`Unknown event ${i} in ${t.postcssPlugin}. Try to update PostCSS (${this.processor.version} now).`);if(!zx[i])if(typeof t[i]=="object")for(let n in t[i])n==="*"?e(t,i,t[i][n]):e(t,i+"-"+n.toLowerCase(),t[i][n]);else typeof t[i]=="function"&&e(t,i,t[i])}this.hasListener=Object.keys(this.listeners).length>0}async runAsync(){this.plugin=0;for(let e=0;e0;){let i=this.visitTick(t);if(Fr(i))try{await i}catch(n){let s=t[t.length-1].node;throw this.handleError(n,s)}}}if(this.listeners.OnceExit)for(let[t,i]of this.listeners.OnceExit){this.result.lastPlugin=t;try{if(e.type==="document"){let n=e.nodes.map(s=>i(s,this.helpers));await Promise.all(n)}else await i(e,this.helpers)}catch(n){throw this.handleError(n)}}}return this.processed=!0,this.stringify()}runOnRoot(e){this.result.lastPlugin=e;try{if(typeof e=="object"&&e.Once){if(this.result.root.type==="document"){let t=this.result.root.nodes.map(i=>e.Once(i,this.helpers));return Fr(t[0])?Promise.all(t):t}return e.Once(this.result.root,this.helpers)}else if(typeof e=="function")return e(this.result.root,this.result)}catch(t){throw this.handleError(t)}}stringify(){if(this.error)throw this.error;if(this.stringified)return this.result;this.stringified=!0,this.sync();let e=this.result.opts,t=Mx;e.syntax&&(t=e.syntax.stringify),e.stringifier&&(t=e.stringifier),t.stringify&&(t=t.stringify);let n=new qx(t,this.result.root,this.result.opts).generate();return this.result.css=n[0],this.result.map=n[1],this.result}sync(){if(this.error)throw this.error;if(this.processed)return this.result;if(this.processed=!0,this.processing)throw this.getAsyncError();for(let e of this.plugins){let t=this.runOnRoot(e);if(Fr(t))throw this.getAsyncError()}if(this.prepareVisitors(),this.hasListener){let e=this.result.root;for(;!e[Re];)e[Re]=!0,this.walkSync(e);if(this.listeners.OnceExit)if(e.type==="document")for(let t of e.nodes)this.visitSync(this.listeners.OnceExit,t);else this.visitSync(this.listeners.OnceExit,e)}return this.result}then(e,t){return this.async().then(e,t)}toString(){return this.css}visitSync(e,t){for(let[i,n]of e){this.result.lastPlugin=i;let s;try{s=n(t,this.helpers)}catch(a){throw this.handleError(a,t.proxyOf)}if(t.type!=="root"&&t.type!=="document"&&!t.parent)return!0;if(Fr(s))throw this.getAsyncError()}}visitTick(e){let t=e[e.length-1],{node:i,visitors:n}=t;if(i.type!=="root"&&i.type!=="document"&&!i.parent){e.pop();return}if(n.length>0&&t.visitorIndex{n[Re]||this.walkSync(n)});else{let n=this.listeners[i];if(n&&this.visitSync(n,e.toProxy()))return}}warnings(){return this.sync().warnings()}get content(){return this.stringify().content}get css(){return this.stringify().css}get map(){return this.stringify().map}get messages(){return this.sync().messages}get opts(){return this.result.opts}get processor(){return this.result.processor}get root(){return this.sync().root}get[Symbol.toStringTag](){return"LazyResult"}};Ge.registerPostcss=r=>{na=r};tp.exports=Ge;Ge.default=Ge;Bx.registerLazyResult(Ge);Rx.registerLazyResult(Ge)});var ip=x((X4,rp)=>{l();"use strict";var jx=Zs(),Ux=sn(),Vx=ln(),Wx=Sr(),J4=ra(),un=class{constructor(e,t,i){t=t.toString(),this.stringified=!1,this._processor=e,this._css=t,this._opts=i,this._map=void 0;let n,s=Wx;this.result=new Vx(this._processor,n,this._opts),this.result.css=t;let a=this;Object.defineProperty(this.result,"root",{get(){return a.root}});let o=new jx(s,n,this._opts,t);if(o.isMap()){let[u,c]=o.generate();u&&(this.result.css=u),c&&(this.result.map=c)}else o.clearAnnotation(),this.result.css=o.css}async(){return this.error?Promise.reject(this.error):Promise.resolve(this.result)}catch(e){return this.async().catch(e)}finally(e){return this.async().then(e,e)}sync(){if(this.error)throw this.error;return this.result}then(e,t){return this.async().then(e,t)}toString(){return this._css}warnings(){return[]}get content(){return this.result.css}get css(){return this.result.css}get map(){return this.result.map}get messages(){return[]}get opts(){return this.result.opts}get processor(){return this.result.processor}get root(){if(this._root)return this._root;let e,t=Ux;try{e=t(this._css,this._opts)}catch(i){this.error=i}if(this.error)throw this.error;return this._root=e,e}get[Symbol.toStringTag](){return"NoWorkResult"}};rp.exports=un;un.default=un});var sp=x((K4,np)=>{l();"use strict";var Gx=Gi(),Hx=sa(),Yx=ip(),Qx=Bt(),Lt=class{constructor(e=[]){this.version="8.4.49",this.plugins=this.normalize(e)}normalize(e){let t=[];for(let i of e)if(i.postcss===!0?i=i():i.postcss&&(i=i.postcss),typeof i=="object"&&Array.isArray(i.plugins))t=t.concat(i.plugins);else if(typeof i=="object"&&i.postcssPlugin)t.push(i);else if(typeof i=="function")t.push(i);else if(!(typeof i=="object"&&(i.parse||i.stringify)))throw new Error(i+" is not a PostCSS plugin");return t}process(e,t={}){return!this.plugins.length&&!t.parser&&!t.stringifier&&!t.syntax?new Yx(this,e,t):new Hx(this,e,t)}use(e){return this.plugins=this.plugins.concat(this.normalize([e])),this}};np.exports=Lt;Lt.default=Lt;Qx.registerProcessor(Lt);Gx.registerProcessor(Lt)});var ye=x((Z4,pp)=>{l();"use strict";var ap=Wi(),op=_r(),Jx=st(),Xx=$i(),lp=Er(),up=Gi(),Kx=Fc(),Zx=Yi(),ev=sa(),tv=Ks(),rv=Or(),iv=sn(),aa=sp(),nv=ln(),fp=Bt(),cp=Qi(),sv=Sr(),av=ta();function j(...r){return r.length===1&&Array.isArray(r[0])&&(r=r[0]),new aa(r)}j.plugin=function(e,t){let i=!1;function n(...a){console&&console.warn&&!i&&(i=!0,console.warn(e+`: postcss.plugin was deprecated. Migration guide:
+https://evilmartians.com/chronicles/postcss-8-plugin-migration`),h.env.LANG&&h.env.LANG.startsWith("cn")&&console.warn(e+`: \u91CC\u9762 postcss.plugin \u88AB\u5F03\u7528. \u8FC1\u79FB\u6307\u5357:
+https://www.w3ctech.com/topic/2226`));let o=t(...a);return o.postcssPlugin=e,o.postcssVersion=new aa().version,o}let s;return Object.defineProperty(n,"postcss",{get(){return s||(s=n()),s}}),n.process=function(a,o,u){return j([n(u)]).process(a,o)},n};j.stringify=sv;j.parse=iv;j.fromJSON=Kx;j.list=tv;j.comment=r=>new op(r);j.atRule=r=>new ap(r);j.decl=r=>new lp(r);j.rule=r=>new cp(r);j.root=r=>new fp(r);j.document=r=>new up(r);j.CssSyntaxError=Xx;j.Declaration=lp;j.Container=Jx;j.Processor=aa;j.Document=up;j.Comment=op;j.Warning=av;j.AtRule=ap;j.Result=nv;j.Input=Zx;j.Rule=cp;j.Root=fp;j.Node=rv;ev.registerPostcss(j);pp.exports=j;j.default=j});var W,U,eT,tT,rT,iT,nT,sT,aT,oT,lT,uT,fT,cT,pT,dT,hT,mT,gT,yT,bT,wT,xT,vT,kT,ST,at=S(()=>{l();W=J(ye()),U=W.default,eT=W.default.stringify,tT=W.default.fromJSON,rT=W.default.plugin,iT=W.default.parse,nT=W.default.list,sT=W.default.document,aT=W.default.comment,oT=W.default.atRule,lT=W.default.rule,uT=W.default.decl,fT=W.default.root,cT=W.default.CssSyntaxError,pT=W.default.Declaration,dT=W.default.Container,hT=W.default.Processor,mT=W.default.Document,gT=W.default.Comment,yT=W.default.Warning,bT=W.default.AtRule,wT=W.default.Result,xT=W.default.Input,vT=W.default.Rule,kT=W.default.Root,ST=W.default.Node});var oa=x((AT,dp)=>{l();dp.exports=function(r,e,t,i,n){for(e=e.split?e.split("."):e,i=0;i{l();"use strict";fn.__esModule=!0;fn.default=uv;function ov(r){for(var e=r.toLowerCase(),t="",i=!1,n=0;n<6&&e[n]!==void 0;n++){var s=e.charCodeAt(n),a=s>=97&&s<=102||s>=48&&s<=57;if(i=s===32,!a)break;t+=e[n]}if(t.length!==0){var o=parseInt(t,16),u=o>=55296&&o<=57343;return u||o===0||o>1114111?["\uFFFD",t.length+(i?1:0)]:[String.fromCodePoint(o),t.length+(i?1:0)]}}var lv=/\\/;function uv(r){var e=lv.test(r);if(!e)return r;for(var t="",i=0;i{l();"use strict";pn.__esModule=!0;pn.default=fv;function fv(r){for(var e=arguments.length,t=new Array(e>1?e-1:0),i=1;i0;){var n=t.shift();if(!r[n])return;r=r[n]}return r}mp.exports=pn.default});var bp=x((dn,yp)=>{l();"use strict";dn.__esModule=!0;dn.default=cv;function cv(r){for(var e=arguments.length,t=new Array(e>1?e-1:0),i=1;i0;){var n=t.shift();r[n]||(r[n]={}),r=r[n]}}yp.exports=dn.default});var xp=x((hn,wp)=>{l();"use strict";hn.__esModule=!0;hn.default=pv;function pv(r){for(var e="",t=r.indexOf("/*"),i=0;t>=0;){e=e+r.slice(i,t);var n=r.indexOf("*/",t+2);if(n<0)return e;i=n+2,t=r.indexOf("/*",i)}return e=e+r.slice(i),e}wp.exports=hn.default});var Br=x(qe=>{l();"use strict";qe.__esModule=!0;qe.unesc=qe.stripComments=qe.getProp=qe.ensureObject=void 0;var dv=mn(cn());qe.unesc=dv.default;var hv=mn(gp());qe.getProp=hv.default;var mv=mn(bp());qe.ensureObject=mv.default;var gv=mn(xp());qe.stripComments=gv.default;function mn(r){return r&&r.__esModule?r:{default:r}}});var He=x((Mr,Sp)=>{l();"use strict";Mr.__esModule=!0;Mr.default=void 0;var vp=Br();function kp(r,e){for(var t=0;ti||this.source.end.linen||this.source.end.line===i&&this.source.end.column{l();"use strict";G.__esModule=!0;G.UNIVERSAL=G.TAG=G.STRING=G.SELECTOR=G.ROOT=G.PSEUDO=G.NESTING=G.ID=G.COMMENT=G.COMBINATOR=G.CLASS=G.ATTRIBUTE=void 0;var xv="tag";G.TAG=xv;var vv="string";G.STRING=vv;var kv="selector";G.SELECTOR=kv;var Sv="root";G.ROOT=Sv;var Cv="pseudo";G.PSEUDO=Cv;var Av="nesting";G.NESTING=Av;var Ov="id";G.ID=Ov;var _v="comment";G.COMMENT=_v;var Ev="combinator";G.COMBINATOR=Ev;var Tv="class";G.CLASS=Tv;var Pv="attribute";G.ATTRIBUTE=Pv;var Dv="universal";G.UNIVERSAL=Dv});var gn=x((Lr,_p)=>{l();"use strict";Lr.__esModule=!0;Lr.default=void 0;var Iv=qv(He()),Ye=Rv(se());function Cp(r){if(typeof WeakMap!="function")return null;var e=new WeakMap,t=new WeakMap;return(Cp=function(n){return n?t:e})(r)}function Rv(r,e){if(!e&&r&&r.__esModule)return r;if(r===null||typeof r!="object"&&typeof r!="function")return{default:r};var t=Cp(e);if(t&&t.has(r))return t.get(r);var i={},n=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var s in r)if(s!=="default"&&Object.prototype.hasOwnProperty.call(r,s)){var a=n?Object.getOwnPropertyDescriptor(r,s):null;a&&(a.get||a.set)?Object.defineProperty(i,s,a):i[s]=r[s]}return i.default=r,t&&t.set(r,i),i}function qv(r){return r&&r.__esModule?r:{default:r}}function Fv(r,e){var t=typeof Symbol!="undefined"&&r[Symbol.iterator]||r["@@iterator"];if(t)return(t=t.call(r)).next.bind(t);if(Array.isArray(r)||(t=Bv(r))||e&&r&&typeof r.length=="number"){t&&(r=t);var i=0;return function(){return i>=r.length?{done:!0}:{done:!1,value:r[i++]}}}throw new TypeError(`Invalid attempt to iterate non-iterable instance.
+In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function Bv(r,e){if(!!r){if(typeof r=="string")return Ap(r,e);var t=Object.prototype.toString.call(r).slice(8,-1);if(t==="Object"&&r.constructor&&(t=r.constructor.name),t==="Map"||t==="Set")return Array.from(r);if(t==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t))return Ap(r,e)}}function Ap(r,e){(e==null||e>r.length)&&(e=r.length);for(var t=0,i=new Array(e);t=n&&(this.indexes[a]=s-1);return this},t.removeAll=function(){for(var n=Fv(this.nodes),s;!(s=n()).done;){var a=s.value;a.parent=void 0}return this.nodes=[],this},t.empty=function(){return this.removeAll()},t.insertAfter=function(n,s){s.parent=this;var a=this.index(n);this.nodes.splice(a+1,0,s),s.parent=this;var o;for(var u in this.indexes)o=this.indexes[u],a<=o&&(this.indexes[u]=o+1);return this},t.insertBefore=function(n,s){s.parent=this;var a=this.index(n);this.nodes.splice(a,0,s),s.parent=this;var o;for(var u in this.indexes)o=this.indexes[u],o<=a&&(this.indexes[u]=o+1);return this},t._findChildAtPosition=function(n,s){var a=void 0;return this.each(function(o){if(o.atPosition){var u=o.atPosition(n,s);if(u)return a=u,!1}else if(o.isAtPosition(n,s))return a=o,!1}),a},t.atPosition=function(n,s){if(this.isAtPosition(n,s))return this._findChildAtPosition(n,s)||this},t._inferEndPosition=function(){this.last&&this.last.source&&this.last.source.end&&(this.source=this.source||{},this.source.end=this.source.end||{},Object.assign(this.source.end,this.last.source.end))},t.each=function(n){this.lastEach||(this.lastEach=0),this.indexes||(this.indexes={}),this.lastEach++;var s=this.lastEach;if(this.indexes[s]=0,!!this.length){for(var a,o;this.indexes[s]{l();"use strict";$r.__esModule=!0;$r.default=void 0;var Nv=jv(gn()),zv=se();function jv(r){return r&&r.__esModule?r:{default:r}}function Ep(r,e){for(var t=0;t{l();"use strict";Nr.__esModule=!0;Nr.default=void 0;var Gv=Yv(gn()),Hv=se();function Yv(r){return r&&r.__esModule?r:{default:r}}function Qv(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,ca(r,e)}function ca(r,e){return ca=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},ca(r,e)}var Jv=function(r){Qv(e,r);function e(t){var i;return i=r.call(this,t)||this,i.type=Hv.SELECTOR,i}return e}(Gv.default);Nr.default=Jv;Pp.exports=Nr.default});var yn=x((ET,Dp)=>{l();"use strict";var Xv={},Kv=Xv.hasOwnProperty,Zv=function(e,t){if(!e)return t;var i={};for(var n in t)i[n]=Kv.call(e,n)?e[n]:t[n];return i},e2=/[ -,\.\/:-@\[-\^`\{-~]/,t2=/[ -,\.\/:-@\[\]\^`\{-~]/,r2=/(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g,da=function r(e,t){t=Zv(t,r.options),t.quotes!="single"&&t.quotes!="double"&&(t.quotes="single");for(var i=t.quotes=="double"?'"':"'",n=t.isIdentifier,s=e.charAt(0),a="",o=0,u=e.length;o126){if(f>=55296&&f<=56319&&o{l();"use strict";zr.__esModule=!0;zr.default=void 0;var i2=Ip(yn()),n2=Br(),s2=Ip(He()),a2=se();function Ip(r){return r&&r.__esModule?r:{default:r}}function Rp(r,e){for(var t=0;t{l();"use strict";jr.__esModule=!0;jr.default=void 0;var f2=p2(He()),c2=se();function p2(r){return r&&r.__esModule?r:{default:r}}function d2(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,ga(r,e)}function ga(r,e){return ga=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},ga(r,e)}var h2=function(r){d2(e,r);function e(t){var i;return i=r.call(this,t)||this,i.type=c2.COMMENT,i}return e}(f2.default);jr.default=h2;Fp.exports=jr.default});var wa=x((Ur,Bp)=>{l();"use strict";Ur.__esModule=!0;Ur.default=void 0;var m2=y2(He()),g2=se();function y2(r){return r&&r.__esModule?r:{default:r}}function b2(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,ba(r,e)}function ba(r,e){return ba=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},ba(r,e)}var w2=function(r){b2(e,r);function e(i){var n;return n=r.call(this,i)||this,n.type=g2.ID,n}var t=e.prototype;return t.valueToString=function(){return"#"+r.prototype.valueToString.call(this)},e}(m2.default);Ur.default=w2;Bp.exports=Ur.default});var bn=x((Vr,$p)=>{l();"use strict";Vr.__esModule=!0;Vr.default=void 0;var x2=Mp(yn()),v2=Br(),k2=Mp(He());function Mp(r){return r&&r.__esModule?r:{default:r}}function Lp(r,e){for(var t=0;t{l();"use strict";Wr.__esModule=!0;Wr.default=void 0;var O2=E2(bn()),_2=se();function E2(r){return r&&r.__esModule?r:{default:r}}function T2(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,va(r,e)}function va(r,e){return va=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},va(r,e)}var P2=function(r){T2(e,r);function e(t){var i;return i=r.call(this,t)||this,i.type=_2.TAG,i}return e}(O2.default);Wr.default=P2;Np.exports=Wr.default});var Ca=x((Gr,zp)=>{l();"use strict";Gr.__esModule=!0;Gr.default=void 0;var D2=R2(He()),I2=se();function R2(r){return r&&r.__esModule?r:{default:r}}function q2(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,Sa(r,e)}function Sa(r,e){return Sa=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},Sa(r,e)}var F2=function(r){q2(e,r);function e(t){var i;return i=r.call(this,t)||this,i.type=I2.STRING,i}return e}(D2.default);Gr.default=F2;zp.exports=Gr.default});var Oa=x((Hr,jp)=>{l();"use strict";Hr.__esModule=!0;Hr.default=void 0;var B2=L2(gn()),M2=se();function L2(r){return r&&r.__esModule?r:{default:r}}function $2(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,Aa(r,e)}function Aa(r,e){return Aa=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},Aa(r,e)}var N2=function(r){$2(e,r);function e(i){var n;return n=r.call(this,i)||this,n.type=M2.PSEUDO,n}var t=e.prototype;return t.toString=function(){var n=this.length?"("+this.map(String).join(",")+")":"";return[this.rawSpaceBefore,this.stringifyProperty("value"),n,this.rawSpaceAfter].join("")},e}(B2.default);Hr.default=N2;jp.exports=Hr.default});var Up={};_e(Up,{deprecate:()=>z2});function z2(r){return r}var Vp=S(()=>{l()});var Gp=x((TT,Wp)=>{l();Wp.exports=(Vp(),Up).deprecate});var Ia=x(Jr=>{l();"use strict";Jr.__esModule=!0;Jr.default=void 0;Jr.unescapeValue=Pa;var Yr=Ea(yn()),j2=Ea(cn()),U2=Ea(bn()),V2=se(),_a;function Ea(r){return r&&r.__esModule?r:{default:r}}function Hp(r,e){for(var t=0;t0&&!n.quoted&&o.before.length===0&&!(n.spaces.value&&n.spaces.value.after)&&(o.before=" "),Yp(a,o)}))),s.push("]"),s.push(this.rawSpaceAfter),s.join("")},W2(e,[{key:"quoted",get:function(){var n=this.quoteMark;return n==="'"||n==='"'},set:function(n){Q2()}},{key:"quoteMark",get:function(){return this._quoteMark},set:function(n){if(!this._constructed){this._quoteMark=n;return}this._quoteMark!==n&&(this._quoteMark=n,this._syncRawValue())}},{key:"qualifiedAttribute",get:function(){return this.qualifiedName(this.raws.attribute||this.attribute)}},{key:"insensitiveFlag",get:function(){return this.insensitive?"i":""}},{key:"value",get:function(){return this._value},set:function(n){if(this._constructed){var s=Pa(n),a=s.deprecatedUsage,o=s.unescaped,u=s.quoteMark;if(a&&Y2(),o===this._value&&u===this._quoteMark)return;this._value=o,this._quoteMark=u,this._syncRawValue()}else this._value=n}},{key:"insensitive",get:function(){return this._insensitive},set:function(n){n||(this._insensitive=!1,this.raws&&(this.raws.insensitiveFlag==="I"||this.raws.insensitiveFlag==="i")&&(this.raws.insensitiveFlag=void 0)),this._insensitive=n}},{key:"attribute",get:function(){return this._attribute},set:function(n){this._handleEscapes("attribute",n),this._attribute=n}}]),e}(U2.default);Jr.default=wn;wn.NO_QUOTE=null;wn.SINGLE_QUOTE="'";wn.DOUBLE_QUOTE='"';var Da=(_a={"'":{quotes:"single",wrap:!0},'"':{quotes:"double",wrap:!0}},_a[null]={isIdentifier:!0},_a);function Yp(r,e){return""+e.before+r+e.after}});var qa=x((Xr,Qp)=>{l();"use strict";Xr.__esModule=!0;Xr.default=void 0;var K2=ek(bn()),Z2=se();function ek(r){return r&&r.__esModule?r:{default:r}}function tk(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,Ra(r,e)}function Ra(r,e){return Ra=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},Ra(r,e)}var rk=function(r){tk(e,r);function e(t){var i;return i=r.call(this,t)||this,i.type=Z2.UNIVERSAL,i.value="*",i}return e}(K2.default);Xr.default=rk;Qp.exports=Xr.default});var Ba=x((Kr,Jp)=>{l();"use strict";Kr.__esModule=!0;Kr.default=void 0;var ik=sk(He()),nk=se();function sk(r){return r&&r.__esModule?r:{default:r}}function ak(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,Fa(r,e)}function Fa(r,e){return Fa=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},Fa(r,e)}var ok=function(r){ak(e,r);function e(t){var i;return i=r.call(this,t)||this,i.type=nk.COMBINATOR,i}return e}(ik.default);Kr.default=ok;Jp.exports=Kr.default});var La=x((Zr,Xp)=>{l();"use strict";Zr.__esModule=!0;Zr.default=void 0;var lk=fk(He()),uk=se();function fk(r){return r&&r.__esModule?r:{default:r}}function ck(r,e){r.prototype=Object.create(e.prototype),r.prototype.constructor=r,Ma(r,e)}function Ma(r,e){return Ma=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(i,n){return i.__proto__=n,i},Ma(r,e)}var pk=function(r){ck(e,r);function e(t){var i;return i=r.call(this,t)||this,i.type=uk.NESTING,i.value="&",i}return e}(lk.default);Zr.default=pk;Xp.exports=Zr.default});var Zp=x((xn,Kp)=>{l();"use strict";xn.__esModule=!0;xn.default=dk;function dk(r){return r.sort(function(e,t){return e-t})}Kp.exports=xn.default});var $a=x(D=>{l();"use strict";D.__esModule=!0;D.word=D.tilde=D.tab=D.str=D.space=D.slash=D.singleQuote=D.semicolon=D.plus=D.pipe=D.openSquare=D.openParenthesis=D.newline=D.greaterThan=D.feed=D.equals=D.doubleQuote=D.dollar=D.cr=D.comment=D.comma=D.combinator=D.colon=D.closeSquare=D.closeParenthesis=D.caret=D.bang=D.backslash=D.at=D.asterisk=D.ampersand=void 0;var hk=38;D.ampersand=hk;var mk=42;D.asterisk=mk;var gk=64;D.at=gk;var yk=44;D.comma=yk;var bk=58;D.colon=bk;var wk=59;D.semicolon=wk;var xk=40;D.openParenthesis=xk;var vk=41;D.closeParenthesis=vk;var kk=91;D.openSquare=kk;var Sk=93;D.closeSquare=Sk;var Ck=36;D.dollar=Ck;var Ak=126;D.tilde=Ak;var Ok=94;D.caret=Ok;var _k=43;D.plus=_k;var Ek=61;D.equals=Ek;var Tk=124;D.pipe=Tk;var Pk=62;D.greaterThan=Pk;var Dk=32;D.space=Dk;var ed=39;D.singleQuote=ed;var Ik=34;D.doubleQuote=Ik;var Rk=47;D.slash=Rk;var qk=33;D.bang=qk;var Fk=92;D.backslash=Fk;var Bk=13;D.cr=Bk;var Mk=12;D.feed=Mk;var Lk=10;D.newline=Lk;var $k=9;D.tab=$k;var Nk=ed;D.str=Nk;var zk=-1;D.comment=zk;var jk=-2;D.word=jk;var Uk=-3;D.combinator=Uk});var id=x(ei=>{l();"use strict";ei.__esModule=!0;ei.FIELDS=void 0;ei.default=Jk;var E=Vk($a()),$t,V;function td(r){if(typeof WeakMap!="function")return null;var e=new WeakMap,t=new WeakMap;return(td=function(n){return n?t:e})(r)}function Vk(r,e){if(!e&&r&&r.__esModule)return r;if(r===null||typeof r!="object"&&typeof r!="function")return{default:r};var t=td(e);if(t&&t.has(r))return t.get(r);var i={},n=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var s in r)if(s!=="default"&&Object.prototype.hasOwnProperty.call(r,s)){var a=n?Object.getOwnPropertyDescriptor(r,s):null;a&&(a.get||a.set)?Object.defineProperty(i,s,a):i[s]=r[s]}return i.default=r,t&&t.set(r,i),i}var Wk=($t={},$t[E.tab]=!0,$t[E.newline]=!0,$t[E.cr]=!0,$t[E.feed]=!0,$t),Gk=(V={},V[E.space]=!0,V[E.tab]=!0,V[E.newline]=!0,V[E.cr]=!0,V[E.feed]=!0,V[E.ampersand]=!0,V[E.asterisk]=!0,V[E.bang]=!0,V[E.comma]=!0,V[E.colon]=!0,V[E.semicolon]=!0,V[E.openParenthesis]=!0,V[E.closeParenthesis]=!0,V[E.openSquare]=!0,V[E.closeSquare]=!0,V[E.singleQuote]=!0,V[E.doubleQuote]=!0,V[E.plus]=!0,V[E.pipe]=!0,V[E.tilde]=!0,V[E.greaterThan]=!0,V[E.equals]=!0,V[E.dollar]=!0,V[E.caret]=!0,V[E.slash]=!0,V),Na={},rd="0123456789abcdefABCDEF";for(vn=0;vn0?(k=a+v,C=w-y[v].length):(k=a,C=s),_=E.comment,a=k,p=k,d=w-C):c===E.slash?(w=o,_=c,p=a,d=o-s,u=w+1):(w=Hk(t,o),_=E.word,p=a,d=w-s),u=w+1;break}e.push([_,a,o-s,p,d,o,u]),C&&(s=C,C=null),o=u}return e}});var cd=x((ti,fd)=>{l();"use strict";ti.__esModule=!0;ti.default=void 0;var Xk=ve(fa()),za=ve(pa()),Kk=ve(ma()),nd=ve(ya()),Zk=ve(wa()),e5=ve(ka()),ja=ve(Ca()),t5=ve(Oa()),sd=kn(Ia()),r5=ve(qa()),Ua=ve(Ba()),i5=ve(La()),n5=ve(Zp()),A=kn(id()),T=kn($a()),s5=kn(se()),Y=Br(),At,Va;function ad(r){if(typeof WeakMap!="function")return null;var e=new WeakMap,t=new WeakMap;return(ad=function(n){return n?t:e})(r)}function kn(r,e){if(!e&&r&&r.__esModule)return r;if(r===null||typeof r!="object"&&typeof r!="function")return{default:r};var t=ad(e);if(t&&t.has(r))return t.get(r);var i={},n=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var s in r)if(s!=="default"&&Object.prototype.hasOwnProperty.call(r,s)){var a=n?Object.getOwnPropertyDescriptor(r,s):null;a&&(a.get||a.set)?Object.defineProperty(i,s,a):i[s]=r[s]}return i.default=r,t&&t.set(r,i),i}function ve(r){return r&&r.__esModule?r:{default:r}}function od(r,e){for(var t=0;t0){var a=this.current.last;if(a){var o=this.convertWhitespaceNodesToSpace(s),u=o.space,c=o.rawSpace;c!==void 0&&(a.rawSpaceAfter+=c),a.spaces.after+=u}else s.forEach(function(_){return i.newNode(_)})}return}var f=this.currToken,d=void 0;n>this.position&&(d=this.parseWhitespaceEquivalentTokens(n));var p;if(this.isNamedCombinator()?p=this.namedCombinator():this.currToken[A.FIELDS.TYPE]===T.combinator?(p=new Ua.default({value:this.content(),source:Nt(this.currToken),sourceIndex:this.currToken[A.FIELDS.START_POS]}),this.position++):Wa[this.currToken[A.FIELDS.TYPE]]||d||this.unexpected(),p){if(d){var g=this.convertWhitespaceNodesToSpace(d),b=g.space,v=g.rawSpace;p.spaces.before=b,p.rawSpaceBefore=v}}else{var y=this.convertWhitespaceNodesToSpace(d,!0),w=y.space,k=y.rawSpace;k||(k=w);var C={},O={spaces:{}};w.endsWith(" ")&&k.endsWith(" ")?(C.before=w.slice(0,w.length-1),O.spaces.before=k.slice(0,k.length-1)):w.startsWith(" ")&&k.startsWith(" ")?(C.after=w.slice(1),O.spaces.after=k.slice(1)):O.value=k,p=new Ua.default({value:" ",source:Ga(f,this.tokens[this.position-1]),sourceIndex:f[A.FIELDS.START_POS],spaces:C,raws:O})}return this.currToken&&this.currToken[A.FIELDS.TYPE]===T.space&&(p.spaces.after=this.optionalSpace(this.content()),this.position++),this.newNode(p)},e.comma=function(){if(this.position===this.tokens.length-1){this.root.trailingComma=!0,this.position++;return}this.current._inferEndPosition();var i=new za.default({source:{start:ld(this.tokens[this.position+1])}});this.current.parent.append(i),this.current=i,this.position++},e.comment=function(){var i=this.currToken;this.newNode(new nd.default({value:this.content(),source:Nt(i),sourceIndex:i[A.FIELDS.START_POS]})),this.position++},e.error=function(i,n){throw this.root.error(i,n)},e.missingBackslash=function(){return this.error("Expected a backslash preceding the semicolon.",{index:this.currToken[A.FIELDS.START_POS]})},e.missingParenthesis=function(){return this.expected("opening parenthesis",this.currToken[A.FIELDS.START_POS])},e.missingSquareBracket=function(){return this.expected("opening square bracket",this.currToken[A.FIELDS.START_POS])},e.unexpected=function(){return this.error("Unexpected '"+this.content()+"'. Escaping special characters with \\ may help.",this.currToken[A.FIELDS.START_POS])},e.unexpectedPipe=function(){return this.error("Unexpected '|'.",this.currToken[A.FIELDS.START_POS])},e.namespace=function(){var i=this.prevToken&&this.content(this.prevToken)||!0;if(this.nextToken[A.FIELDS.TYPE]===T.word)return this.position++,this.word(i);if(this.nextToken[A.FIELDS.TYPE]===T.asterisk)return this.position++,this.universal(i);this.unexpectedPipe()},e.nesting=function(){if(this.nextToken){var i=this.content(this.nextToken);if(i==="|"){this.position++;return}}var n=this.currToken;this.newNode(new i5.default({value:this.content(),source:Nt(n),sourceIndex:n[A.FIELDS.START_POS]})),this.position++},e.parentheses=function(){var i=this.current.last,n=1;if(this.position++,i&&i.type===s5.PSEUDO){var s=new za.default({source:{start:ld(this.tokens[this.position-1])}}),a=this.current;for(i.append(s),this.current=s;this.position1&&i.nextToken&&i.nextToken[A.FIELDS.TYPE]===T.openParenthesis&&i.error("Misplaced parenthesis.",{index:i.nextToken[A.FIELDS.START_POS]})});else return this.expected(["pseudo-class","pseudo-element"],this.currToken[A.FIELDS.START_POS])},e.space=function(){var i=this.content();this.position===0||this.prevToken[A.FIELDS.TYPE]===T.comma||this.prevToken[A.FIELDS.TYPE]===T.openParenthesis||this.current.nodes.every(function(n){return n.type==="comment"})?(this.spaces=this.optionalSpace(i),this.position++):this.position===this.tokens.length-1||this.nextToken[A.FIELDS.TYPE]===T.comma||this.nextToken[A.FIELDS.TYPE]===T.closeParenthesis?(this.current.last.spaces.after=this.optionalSpace(i),this.position++):this.combinator()},e.string=function(){var i=this.currToken;this.newNode(new ja.default({value:this.content(),source:Nt(i),sourceIndex:i[A.FIELDS.START_POS]})),this.position++},e.universal=function(i){var n=this.nextToken;if(n&&this.content(n)==="|")return this.position++,this.namespace();var s=this.currToken;this.newNode(new r5.default({value:this.content(),source:Nt(s),sourceIndex:s[A.FIELDS.START_POS]}),i),this.position++},e.splitWord=function(i,n){for(var s=this,a=this.nextToken,o=this.content();a&&~[T.dollar,T.caret,T.equals,T.word].indexOf(a[A.FIELDS.TYPE]);){this.position++;var u=this.content();if(o+=u,u.lastIndexOf("\\")===u.length-1){var c=this.nextToken;c&&c[A.FIELDS.TYPE]===T.space&&(o+=this.requiredSpace(this.content(c)),this.position++)}a=this.nextToken}var f=Ha(o,".").filter(function(b){var v=o[b-1]==="\\",y=/^\d+\.\d+%$/.test(o);return!v&&!y}),d=Ha(o,"#").filter(function(b){return o[b-1]!=="\\"}),p=Ha(o,"#{");p.length&&(d=d.filter(function(b){return!~p.indexOf(b)}));var g=(0,n5.default)(l5([0].concat(f,d)));g.forEach(function(b,v){var y=g[v+1]||o.length,w=o.slice(b,y);if(v===0&&n)return n.call(s,w,g.length);var k,C=s.currToken,O=C[A.FIELDS.START_POS]+g[v],_=Ot(C[1],C[2]+b,C[3],C[2]+(y-1));if(~f.indexOf(b)){var I={value:w.slice(1),source:_,sourceIndex:O};k=new Kk.default(zt(I,"value"))}else if(~d.indexOf(b)){var B={value:w.slice(1),source:_,sourceIndex:O};k=new Zk.default(zt(B,"value"))}else{var R={value:w,source:_,sourceIndex:O};zt(R,"value"),k=new e5.default(R)}s.newNode(k,i),i=null}),this.position++},e.word=function(i){var n=this.nextToken;return n&&this.content(n)==="|"?(this.position++,this.namespace()):this.splitWord(i)},e.loop=function(){for(;this.position{l();"use strict";ri.__esModule=!0;ri.default=void 0;var f5=c5(cd());function c5(r){return r&&r.__esModule?r:{default:r}}var p5=function(){function r(t,i){this.func=t||function(){},this.funcRes=null,this.options=i}var e=r.prototype;return e._shouldUpdateSelector=function(i,n){n===void 0&&(n={});var s=Object.assign({},this.options,n);return s.updateSelector===!1?!1:typeof i!="string"},e._isLossy=function(i){i===void 0&&(i={});var n=Object.assign({},this.options,i);return n.lossless===!1},e._root=function(i,n){n===void 0&&(n={});var s=new f5.default(i,this._parseOptions(n));return s.root},e._parseOptions=function(i){return{lossy:this._isLossy(i)}},e._run=function(i,n){var s=this;return n===void 0&&(n={}),new Promise(function(a,o){try{var u=s._root(i,n);Promise.resolve(s.func(u)).then(function(c){var f=void 0;return s._shouldUpdateSelector(i,n)&&(f=u.toString(),i.selector=f),{transform:c,root:u,string:f}}).then(a,o)}catch(c){o(c);return}})},e._runSync=function(i,n){n===void 0&&(n={});var s=this._root(i,n),a=this.func(s);if(a&&typeof a.then=="function")throw new Error("Selector processor returned a promise to a synchronous call.");var o=void 0;return n.updateSelector&&typeof i!="string"&&(o=s.toString(),i.selector=o),{transform:a,root:s,string:o}},e.ast=function(i,n){return this._run(i,n).then(function(s){return s.root})},e.astSync=function(i,n){return this._runSync(i,n).root},e.transform=function(i,n){return this._run(i,n).then(function(s){return s.transform})},e.transformSync=function(i,n){return this._runSync(i,n).transform},e.process=function(i,n){return this._run(i,n).then(function(s){return s.string||s.root.toString()})},e.processSync=function(i,n){var s=this._runSync(i,n);return s.string||s.root.toString()},r}();ri.default=p5;pd.exports=ri.default});var hd=x(H=>{l();"use strict";H.__esModule=!0;H.universal=H.tag=H.string=H.selector=H.root=H.pseudo=H.nesting=H.id=H.comment=H.combinator=H.className=H.attribute=void 0;var d5=ke(Ia()),h5=ke(ma()),m5=ke(Ba()),g5=ke(ya()),y5=ke(wa()),b5=ke(La()),w5=ke(Oa()),x5=ke(fa()),v5=ke(pa()),k5=ke(Ca()),S5=ke(ka()),C5=ke(qa());function ke(r){return r&&r.__esModule?r:{default:r}}var A5=function(e){return new d5.default(e)};H.attribute=A5;var O5=function(e){return new h5.default(e)};H.className=O5;var _5=function(e){return new m5.default(e)};H.combinator=_5;var E5=function(e){return new g5.default(e)};H.comment=E5;var T5=function(e){return new y5.default(e)};H.id=T5;var P5=function(e){return new b5.default(e)};H.nesting=P5;var D5=function(e){return new w5.default(e)};H.pseudo=D5;var I5=function(e){return new x5.default(e)};H.root=I5;var R5=function(e){return new v5.default(e)};H.selector=R5;var q5=function(e){return new k5.default(e)};H.string=q5;var F5=function(e){return new S5.default(e)};H.tag=F5;var B5=function(e){return new C5.default(e)};H.universal=B5});var bd=x(N=>{l();"use strict";N.__esModule=!0;N.isComment=N.isCombinator=N.isClassName=N.isAttribute=void 0;N.isContainer=Y5;N.isIdentifier=void 0;N.isNamespace=Q5;N.isNesting=void 0;N.isNode=Ya;N.isPseudo=void 0;N.isPseudoClass=H5;N.isPseudoElement=yd;N.isUniversal=N.isTag=N.isString=N.isSelector=N.isRoot=void 0;var Q=se(),de,M5=(de={},de[Q.ATTRIBUTE]=!0,de[Q.CLASS]=!0,de[Q.COMBINATOR]=!0,de[Q.COMMENT]=!0,de[Q.ID]=!0,de[Q.NESTING]=!0,de[Q.PSEUDO]=!0,de[Q.ROOT]=!0,de[Q.SELECTOR]=!0,de[Q.STRING]=!0,de[Q.TAG]=!0,de[Q.UNIVERSAL]=!0,de);function Ya(r){return typeof r=="object"&&M5[r.type]}function Se(r,e){return Ya(e)&&e.type===r}var md=Se.bind(null,Q.ATTRIBUTE);N.isAttribute=md;var L5=Se.bind(null,Q.CLASS);N.isClassName=L5;var $5=Se.bind(null,Q.COMBINATOR);N.isCombinator=$5;var N5=Se.bind(null,Q.COMMENT);N.isComment=N5;var z5=Se.bind(null,Q.ID);N.isIdentifier=z5;var j5=Se.bind(null,Q.NESTING);N.isNesting=j5;var Qa=Se.bind(null,Q.PSEUDO);N.isPseudo=Qa;var U5=Se.bind(null,Q.ROOT);N.isRoot=U5;var V5=Se.bind(null,Q.SELECTOR);N.isSelector=V5;var W5=Se.bind(null,Q.STRING);N.isString=W5;var gd=Se.bind(null,Q.TAG);N.isTag=gd;var G5=Se.bind(null,Q.UNIVERSAL);N.isUniversal=G5;function yd(r){return Qa(r)&&r.value&&(r.value.startsWith("::")||r.value.toLowerCase()===":before"||r.value.toLowerCase()===":after"||r.value.toLowerCase()===":first-letter"||r.value.toLowerCase()===":first-line")}function H5(r){return Qa(r)&&!yd(r)}function Y5(r){return!!(Ya(r)&&r.walk)}function Q5(r){return md(r)||gd(r)}});var wd=x(Te=>{l();"use strict";Te.__esModule=!0;var Ja=se();Object.keys(Ja).forEach(function(r){r==="default"||r==="__esModule"||r in Te&&Te[r]===Ja[r]||(Te[r]=Ja[r])});var Xa=hd();Object.keys(Xa).forEach(function(r){r==="default"||r==="__esModule"||r in Te&&Te[r]===Xa[r]||(Te[r]=Xa[r])});var Ka=bd();Object.keys(Ka).forEach(function(r){r==="default"||r==="__esModule"||r in Te&&Te[r]===Ka[r]||(Te[r]=Ka[r])})});var Fe=x((ii,vd)=>{l();"use strict";ii.__esModule=!0;ii.default=void 0;var J5=Z5(dd()),X5=K5(wd());function xd(r){if(typeof WeakMap!="function")return null;var e=new WeakMap,t=new WeakMap;return(xd=function(n){return n?t:e})(r)}function K5(r,e){if(!e&&r&&r.__esModule)return r;if(r===null||typeof r!="object"&&typeof r!="function")return{default:r};var t=xd(e);if(t&&t.has(r))return t.get(r);var i={},n=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var s in r)if(s!=="default"&&Object.prototype.hasOwnProperty.call(r,s)){var a=n?Object.getOwnPropertyDescriptor(r,s):null;a&&(a.get||a.set)?Object.defineProperty(i,s,a):i[s]=r[s]}return i.default=r,t&&t.set(r,i),i}function Z5(r){return r&&r.__esModule?r:{default:r}}var Za=function(e){return new J5.default(e)};Object.assign(Za,X5);delete Za.__esModule;var eS=Za;ii.default=eS;vd.exports=ii.default});function Qe(r){return["fontSize","outline"].includes(r)?e=>(typeof e=="function"&&(e=e({})),Array.isArray(e)&&(e=e[0]),e):r==="fontFamily"?e=>{typeof e=="function"&&(e=e({}));let t=Array.isArray(e)&&ne(e[1])?e[0]:e;return Array.isArray(t)?t.join(", "):t}:["boxShadow","transitionProperty","transitionDuration","transitionDelay","transitionTimingFunction","backgroundImage","backgroundSize","backgroundColor","cursor","animation"].includes(r)?e=>(typeof e=="function"&&(e=e({})),Array.isArray(e)&&(e=e.join(", ")),e):["gridTemplateColumns","gridTemplateRows","objectPosition"].includes(r)?e=>(typeof e=="function"&&(e=e({})),typeof e=="string"&&(e=U.list.comma(e).join(" ")),e):(e,t={})=>(typeof e=="function"&&(e=e(t)),e)}var ni=S(()=>{l();at();Pt()});var Ed=x(($T,no)=>{l();var{Rule:kd,AtRule:tS}=ye(),Sd=Fe();function eo(r,e){let t;try{Sd(i=>{t=i}).processSync(r)}catch(i){throw r.includes(":")?e?e.error("Missed semicolon"):i:e?e.error(i.message):i}return t.at(0)}function Cd(r,e){let t=!1;return r.each(i=>{if(i.type==="nesting"){let n=e.clone({});i.value!=="&"?i.replaceWith(eo(i.value.replace("&",n.toString()))):i.replaceWith(n),t=!0}else"nodes"in i&&i.nodes&&Cd(i,e)&&(t=!0)}),t}function Ad(r,e){let t=[];return r.selectors.forEach(i=>{let n=eo(i,r);e.selectors.forEach(s=>{if(!s)return;let a=eo(s,e);Cd(a,n)||(a.prepend(Sd.combinator({value:" "})),a.prepend(n.clone({}))),t.push(a.toString())})}),t}function Sn(r,e){let t=r.prev();for(e.after(r);t&&t.type==="comment";){let i=t.prev();e.after(t),t=i}return r}function rS(r){return function e(t,i,n,s=n){let a=[];if(i.each(o=>{o.type==="rule"&&n?s&&(o.selectors=Ad(t,o)):o.type==="atrule"&&o.nodes?r[o.name]?e(t,o,s):i[ro]!==!1&&a.push(o):a.push(o)}),n&&a.length){let o=t.clone({nodes:[]});for(let u of a)o.append(u);i.prepend(o)}}}function to(r,e,t){let i=new kd({selector:r,nodes:[]});return i.append(e),t.after(i),i}function Od(r,e){let t={};for(let i of r)t[i]=!0;if(e)for(let i of e)t[i.replace(/^@/,"")]=!0;return t}function iS(r){r=r.trim();let e=r.match(/^\((.*)\)$/);if(!e)return{type:"basic",selector:r};let t=e[1].match(/^(with(?:out)?):(.+)$/);if(t){let i=t[1]==="with",n=Object.fromEntries(t[2].trim().split(/\s+/).map(a=>[a,!0]));if(i&&n.all)return{type:"noop"};let s=a=>!!n[a];return n.all?s=()=>!0:i&&(s=a=>a==="all"?!1:!n[a]),{type:"withrules",escapes:s}}return{type:"unknown"}}function nS(r){let e=[],t=r.parent;for(;t&&t instanceof tS;)e.push(t),t=t.parent;return e}function sS(r){let e=r[_d];if(!e)r.after(r.nodes);else{let t=r.nodes,i,n=-1,s,a,o,u=nS(r);if(u.forEach((c,f)=>{if(e(c.name))i=c,n=f,a=o;else{let d=o;o=c.clone({nodes:[]}),d&&o.append(d),s=s||o}}),i?a?(s.append(t),i.after(a)):i.after(t):r.after(t),r.next()&&i){let c;u.slice(0,n+1).forEach((f,d,p)=>{let g=c;c=f.clone({nodes:[]}),g&&c.append(g);let b=[],y=(p[d-1]||r).next();for(;y;)b.push(y),y=y.next();c.append(b)}),c&&(a||t[t.length-1]).after(c)}}r.remove()}var ro=Symbol("rootRuleMergeSel"),_d=Symbol("rootRuleEscapes");function aS(r){let{params:e}=r,{type:t,selector:i,escapes:n}=iS(e);if(t==="unknown")throw r.error(`Unknown @${r.name} parameter ${JSON.stringify(e)}`);if(t==="basic"&&i){let s=new kd({selector:i,nodes:r.nodes});r.removeAll(),r.append(s)}r[_d]=n,r[ro]=n?!n("all"):t==="noop"}var io=Symbol("hasRootRule");no.exports=(r={})=>{let e=Od(["media","supports","layer","container"],r.bubble),t=rS(e),i=Od(["document","font-face","keyframes","-webkit-keyframes","-moz-keyframes"],r.unwrap),n=(r.rootRuleName||"at-root").replace(/^@/,""),s=r.preserveEmpty;return{postcssPlugin:"postcss-nested",Once(a){a.walkAtRules(n,o=>{aS(o),a[io]=!0})},Rule(a){let o=!1,u=a,c=!1,f=[];a.each(d=>{d.type==="rule"?(f.length&&(u=to(a.selector,f,u),f=[]),c=!0,o=!0,d.selectors=Ad(a,d),u=Sn(d,u)):d.type==="atrule"?(f.length&&(u=to(a.selector,f,u),f=[]),d.name===n?(o=!0,t(a,d,!0,d[ro]),u=Sn(d,u)):e[d.name]?(c=!0,o=!0,t(a,d,!0),u=Sn(d,u)):i[d.name]?(c=!0,o=!0,t(a,d,!1),u=Sn(d,u)):c&&f.push(d)):d.type==="decl"&&c&&f.push(d)}),f.length&&(u=to(a.selector,f,u)),o&&s!==!0&&(a.raws.semicolon=!0,a.nodes.length===0&&a.remove())},RootExit(a){a[io]&&(a.walkAtRules(n,sS),a[io]=!1)}}};no.exports.postcss=!0});var Id=x((NT,Dd)=>{l();"use strict";var Td=/-(\w|$)/g,Pd=(r,e)=>e.toUpperCase(),oS=r=>(r=r.toLowerCase(),r==="float"?"cssFloat":r.startsWith("-ms-")?r.substr(1).replace(Td,Pd):r.replace(Td,Pd));Dd.exports=oS});var oo=x((zT,Rd)=>{l();var lS=Id(),uS={boxFlex:!0,boxFlexGroup:!0,columnCount:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,strokeDashoffset:!0,strokeOpacity:!0,strokeWidth:!0};function so(r){return typeof r.nodes=="undefined"?!0:ao(r)}function ao(r){let e,t={};return r.each(i=>{if(i.type==="atrule")e="@"+i.name,i.params&&(e+=" "+i.params),typeof t[e]=="undefined"?t[e]=so(i):Array.isArray(t[e])?t[e].push(so(i)):t[e]=[t[e],so(i)];else if(i.type==="rule"){let n=ao(i);if(t[i.selector])for(let s in n)t[i.selector][s]=n[s];else t[i.selector]=n}else if(i.type==="decl"){i.prop[0]==="-"&&i.prop[1]==="-"||i.parent&&i.parent.selector===":export"?e=i.prop:e=lS(i.prop);let n=i.value;!isNaN(i.value)&&uS[e]&&(n=parseFloat(i.value)),i.important&&(n+=" !important"),typeof t[e]=="undefined"?t[e]=n:Array.isArray(t[e])?t[e].push(n):t[e]=[t[e],n]}}),t}Rd.exports=ao});var Cn=x((jT,Md)=>{l();var si=ye(),qd=/\s*!important\s*$/i,fS={"box-flex":!0,"box-flex-group":!0,"column-count":!0,flex:!0,"flex-grow":!0,"flex-positive":!0,"flex-shrink":!0,"flex-negative":!0,"font-weight":!0,"line-clamp":!0,"line-height":!0,opacity:!0,order:!0,orphans:!0,"tab-size":!0,widows:!0,"z-index":!0,zoom:!0,"fill-opacity":!0,"stroke-dashoffset":!0,"stroke-opacity":!0,"stroke-width":!0};function cS(r){return r.replace(/([A-Z])/g,"-$1").replace(/^ms-/,"-ms-").toLowerCase()}function Fd(r,e,t){t===!1||t===null||(e.startsWith("--")||(e=cS(e)),typeof t=="number"&&(t===0||fS[e]?t=t.toString():t+="px"),e==="css-float"&&(e="float"),qd.test(t)?(t=t.replace(qd,""),r.push(si.decl({prop:e,value:t,important:!0}))):r.push(si.decl({prop:e,value:t})))}function Bd(r,e,t){let i=si.atRule({name:e[1],params:e[3]||""});typeof t=="object"&&(i.nodes=[],lo(t,i)),r.push(i)}function lo(r,e){let t,i,n;for(t in r)if(i=r[t],!(i===null||typeof i=="undefined"))if(t[0]==="@"){let s=t.match(/@(\S+)(\s+([\W\w]*)\s*)?/);if(Array.isArray(i))for(let a of i)Bd(e,s,a);else Bd(e,s,i)}else if(Array.isArray(i))for(let s of i)Fd(e,t,s);else typeof i=="object"?(n=si.rule({selector:t}),lo(i,n),e.push(n)):Fd(e,t,i)}Md.exports=function(r){let e=si.root();return lo(r,e),e}});var uo=x((UT,Ld)=>{l();var pS=oo();Ld.exports=function(e){return console&&console.warn&&e.warnings().forEach(t=>{let i=t.plugin||"PostCSS";console.warn(i+": "+t.text)}),pS(e.root)}});var Nd=x((VT,$d)=>{l();var dS=ye(),hS=uo(),mS=Cn();$d.exports=function(e){let t=dS(e);return async i=>{let n=await t.process(i,{parser:mS,from:void 0});return hS(n)}}});var jd=x((WT,zd)=>{l();var gS=ye(),yS=uo(),bS=Cn();zd.exports=function(r){let e=gS(r);return t=>{let i=e.process(t,{parser:bS,from:void 0});return yS(i)}}});var Vd=x((GT,Ud)=>{l();var wS=oo(),xS=Cn(),vS=Nd(),kS=jd();Ud.exports={objectify:wS,parse:xS,async:vS,sync:kS}});var jt,Wd,HT,YT,QT,JT,Gd=S(()=>{l();jt=J(Vd()),Wd=jt.default,HT=jt.default.objectify,YT=jt.default.parse,QT=jt.default.async,JT=jt.default.sync});function Ut(r){return Array.isArray(r)?r.flatMap(e=>U([(0,Hd.default)({bubble:["screen"]})]).process(e,{parser:Wd}).root.nodes):Ut([r])}var Hd,fo=S(()=>{l();at();Hd=J(Ed());Gd()});function Vt(r,e,t=!1){if(r==="")return e;let i=typeof e=="string"?(0,Yd.default)().astSync(e):e;return i.walkClasses(n=>{let s=n.value,a=t&&s.startsWith("-");n.value=a?`-${r}${s.slice(1)}`:`${r}${s}`}),typeof e=="string"?i.toString():i}var Yd,An=S(()=>{l();Yd=J(Fe())});function he(r){let e=Qd.default.className();return e.value=r,kt(e?.raws?.value??e.value)}var Qd,Wt=S(()=>{l();Qd=J(Fe());Ii()});function co(r){return kt(`.${he(r)}`)}function On(r,e){return co(ai(r,e))}function ai(r,e){return e==="DEFAULT"?r:e==="-"||e==="-DEFAULT"?`-${r}`:e.startsWith("-")?`-${r}${e}`:e.startsWith("/")?`${r}${e}`:`${r}-${e}`}var po=S(()=>{l();Wt();Ii()});function P(r,e=[[r,[r]]],{filterDefault:t=!1,...i}={}){let n=Qe(r);return function({matchUtilities:s,theme:a}){for(let o of e){let u=Array.isArray(o[0])?o:[o];s(u.reduce((c,[f,d])=>Object.assign(c,{[f]:p=>d.reduce((g,b)=>Array.isArray(b)?Object.assign(g,{[b[0]]:b[1]}):Object.assign(g,{[b]:n(p)}),{})}),{}),{...i,values:t?Object.fromEntries(Object.entries(a(r)??{}).filter(([c])=>c!=="DEFAULT")):a(r)})}}}var Jd=S(()=>{l();ni()});function ot(r){return r=Array.isArray(r)?r:[r],r.map(e=>{let t=e.values.map(i=>i.raw!==void 0?i.raw:[i.min&&`(min-width: ${i.min})`,i.max&&`(max-width: ${i.max})`].filter(Boolean).join(" and "));return e.not?`not all and ${t}`:t}).join(", ")}var _n=S(()=>{l()});function ho(r){return r.split(TS).map(t=>{let i=t.trim(),n={value:i},s=i.split(PS),a=new Set;for(let o of s)!a.has("DIRECTIONS")&&SS.has(o)?(n.direction=o,a.add("DIRECTIONS")):!a.has("PLAY_STATES")&&CS.has(o)?(n.playState=o,a.add("PLAY_STATES")):!a.has("FILL_MODES")&&AS.has(o)?(n.fillMode=o,a.add("FILL_MODES")):!a.has("ITERATION_COUNTS")&&(OS.has(o)||DS.test(o))?(n.iterationCount=o,a.add("ITERATION_COUNTS")):!a.has("TIMING_FUNCTION")&&_S.has(o)||!a.has("TIMING_FUNCTION")&&ES.some(u=>o.startsWith(`${u}(`))?(n.timingFunction=o,a.add("TIMING_FUNCTION")):!a.has("DURATION")&&Xd.test(o)?(n.duration=o,a.add("DURATION")):!a.has("DELAY")&&Xd.test(o)?(n.delay=o,a.add("DELAY")):a.has("NAME")?(n.unknown||(n.unknown=[]),n.unknown.push(o)):(n.name=o,a.add("NAME"));return n})}var SS,CS,AS,OS,_S,ES,TS,PS,Xd,DS,Kd=S(()=>{l();SS=new Set(["normal","reverse","alternate","alternate-reverse"]),CS=new Set(["running","paused"]),AS=new Set(["none","forwards","backwards","both"]),OS=new Set(["infinite"]),_S=new Set(["linear","ease","ease-in","ease-out","ease-in-out","step-start","step-end"]),ES=["cubic-bezier","steps"],TS=/\,(?![^(]*\))/g,PS=/\ +(?![^(]*\))/g,Xd=/^(-?[\d.]+m?s)$/,DS=/^(\d+)$/});var Zd,ie,eh=S(()=>{l();Zd=r=>Object.assign({},...Object.entries(r??{}).flatMap(([e,t])=>typeof t=="object"?Object.entries(Zd(t)).map(([i,n])=>({[e+(i==="DEFAULT"?"":`-${i}`)]:n})):[{[`${e}`]:t}])),ie=Zd});var IS,go,RS,qS,FS,BS,MS,LS,$S,NS,zS,jS,US,VS,WS,GS,HS,YS,yo,mo=S(()=>{IS="tailwindcss",go="3.4.0",RS="A utility-first CSS framework for rapidly building custom user interfaces.",qS="MIT",FS="lib/index.js",BS="types/index.d.ts",MS="https://github.com/tailwindlabs/tailwindcss.git",LS="https://github.com/tailwindlabs/tailwindcss/issues",$S="https://tailwindcss.com",NS={tailwind:"lib/cli.js",tailwindcss:"lib/cli.js"},zS={engine:"stable"},jS={prebuild:"npm run generate && rimraf lib",build:`swc src --out-dir lib --copy-files --config jsc.transform.optimizer.globals.vars.__OXIDE__='"false"'`,postbuild:"esbuild lib/cli-peer-dependencies.js --bundle --platform=node --outfile=peers/index.js --define:process.env.CSS_TRANSFORMER_WASM=false","rebuild-fixtures":"npm run build && node -r @swc/register scripts/rebuildFixtures.js",style:"eslint .",pretest:"npm run generate",test:"jest","test:integrations":"npm run test --prefix ./integrations","install:integrations":"node scripts/install-integrations.js","generate:plugin-list":"node -r @swc/register scripts/create-plugin-list.js","generate:types":"node -r @swc/register scripts/generate-types.js",generate:"npm run generate:plugin-list && npm run generate:types","release-channel":"node ./scripts/release-channel.js","release-notes":"node ./scripts/release-notes.js",prepublishOnly:"npm install --force && npm run build"},US=["src/*","cli/*","lib/*","peers/*","scripts/*.js","stubs/*","nesting/*","types/**/*","*.d.ts","*.css","*.js"],VS={"@swc/cli":"^0.1.62","@swc/core":"^1.3.55","@swc/jest":"^0.2.26","@swc/register":"^0.1.10",autoprefixer:"^10.4.14",browserslist:"^4.21.5",concurrently:"^8.0.1",cssnano:"^6.0.0",esbuild:"^0.17.18",eslint:"^8.39.0","eslint-config-prettier":"^8.8.0","eslint-plugin-prettier":"^4.2.1",jest:"^29.6.0","jest-diff":"^29.6.0",lightningcss:"1.18.0",prettier:"^2.8.8",rimraf:"^5.0.0","source-map-js":"^1.0.2",turbo:"^1.9.3"},WS={"@alloc/quick-lru":"^5.2.0",arg:"^5.0.2",chokidar:"^3.5.3",didyoumean:"^1.2.2",dlv:"^1.1.3","fast-glob":"^3.3.0","glob-parent":"^6.0.2","is-glob":"^4.0.3",jiti:"^1.19.1",lilconfig:"^2.1.0",micromatch:"^4.0.5","normalize-path":"^3.0.0","object-hash":"^3.0.0",picocolors:"^1.0.0",postcss:"^8.4.23","postcss-import":"^15.1.0","postcss-js":"^4.0.1","postcss-load-config":"^4.0.1","postcss-nested":"^6.0.1","postcss-selector-parser":"^6.0.11",resolve:"^1.22.2",sucrase:"^3.32.0"},GS=["> 1%","not edge <= 18","not ie 11","not op_mini all"],HS={testTimeout:3e4,setupFilesAfterEnv:["/jest/customMatchers.js"],testPathIgnorePatterns:["/node_modules/","/integrations/","/standalone-cli/","\\.test\\.skip\\.js$"],transformIgnorePatterns:["node_modules/(?!lightningcss)"],transform:{"\\.js$":"@swc/jest","\\.ts$":"@swc/jest"}},YS={node:">=14.0.0"},yo={name:IS,version:go,description:RS,license:qS,main:FS,types:BS,repository:MS,bugs:LS,homepage:$S,bin:NS,tailwindcss:zS,scripts:jS,files:US,devDependencies:VS,dependencies:WS,browserslist:GS,jest:HS,engines:YS}});function lt(r,e=!0){return Array.isArray(r)?r.map(t=>{if(e&&Array.isArray(t))throw new Error("The tuple syntax is not supported for `screens`.");if(typeof t=="string")return{name:t.toString(),not:!1,values:[{min:t,max:void 0}]};let[i,n]=t;return i=i.toString(),typeof n=="string"?{name:i,not:!1,values:[{min:n,max:void 0}]}:Array.isArray(n)?{name:i,not:!1,values:n.map(s=>rh(s))}:{name:i,not:!1,values:[rh(n)]}}):lt(Object.entries(r??{}),!1)}function En(r){return r.values.length!==1?{result:!1,reason:"multiple-values"}:r.values[0].raw!==void 0?{result:!1,reason:"raw-values"}:r.values[0].min!==void 0&&r.values[0].max!==void 0?{result:!1,reason:"min-and-max"}:{result:!0,reason:null}}function th(r,e,t){let i=Tn(e,r),n=Tn(t,r),s=En(i),a=En(n);if(s.reason==="multiple-values"||a.reason==="multiple-values")throw new Error("Attempted to sort a screen with multiple values. This should never happen. Please open a bug report.");if(s.reason==="raw-values"||a.reason==="raw-values")throw new Error("Attempted to sort a screen with raw values. This should never happen. Please open a bug report.");if(s.reason==="min-and-max"||a.reason==="min-and-max")throw new Error("Attempted to sort a screen with both min and max values. This should never happen. Please open a bug report.");let{min:o,max:u}=i.values[0],{min:c,max:f}=n.values[0];e.not&&([o,u]=[u,o]),t.not&&([c,f]=[f,c]),o=o===void 0?o:parseFloat(o),u=u===void 0?u:parseFloat(u),c=c===void 0?c:parseFloat(c),f=f===void 0?f:parseFloat(f);let[d,p]=r==="min"?[o,c]:[f,u];return d-p}function Tn(r,e){return typeof r=="object"?r:{name:"arbitrary-screen",values:[{[e]:r}]}}function rh({"min-width":r,min:e=r,max:t,raw:i}={}){return{min:e,max:t,raw:i}}var Pn=S(()=>{l()});function Dn(r,e){r.walkDecls(t=>{if(e.includes(t.prop)){t.remove();return}for(let i of e)t.value.includes(`/ var(${i})`)&&(t.value=t.value.replace(`/ var(${i})`,""))})}var ih=S(()=>{l()});var ae,Pe,Be,Me,nh,sh=S(()=>{l();Ve();St();at();Jd();_n();Wt();Kd();eh();yr();qs();Pt();ni();mo();Ee();Pn();_s();ih();We();xr();li();ae={childVariant:({addVariant:r})=>{r("*","& > *")},pseudoElementVariants:({addVariant:r})=>{r("first-letter","&::first-letter"),r("first-line","&::first-line"),r("marker",[({container:e})=>(Dn(e,["--tw-text-opacity"]),"& *::marker"),({container:e})=>(Dn(e,["--tw-text-opacity"]),"&::marker")]),r("selection",["& *::selection","&::selection"]),r("file","&::file-selector-button"),r("placeholder","&::placeholder"),r("backdrop","&::backdrop"),r("before",({container:e})=>(e.walkRules(t=>{let i=!1;t.walkDecls("content",()=>{i=!0}),i||t.prepend(U.decl({prop:"content",value:"var(--tw-content)"}))}),"&::before")),r("after",({container:e})=>(e.walkRules(t=>{let i=!1;t.walkDecls("content",()=>{i=!0}),i||t.prepend(U.decl({prop:"content",value:"var(--tw-content)"}))}),"&::after"))},pseudoClassVariants:({addVariant:r,matchVariant:e,config:t,prefix:i})=>{let n=[["first","&:first-child"],["last","&:last-child"],["only","&:only-child"],["odd","&:nth-child(odd)"],["even","&:nth-child(even)"],"first-of-type","last-of-type","only-of-type",["visited",({container:a})=>(Dn(a,["--tw-text-opacity","--tw-border-opacity","--tw-bg-opacity"]),"&:visited")],"target",["open","&[open]"],"default","checked","indeterminate","placeholder-shown","autofill","optional","required","valid","invalid","in-range","out-of-range","read-only","empty","focus-within",["hover",Z(t(),"hoverOnlyWhenSupported")?"@media (hover: hover) and (pointer: fine) { &:hover }":"&:hover"],"focus","focus-visible","active","enabled","disabled"].map(a=>Array.isArray(a)?a:[a,`&:${a}`]);for(let[a,o]of n)r(a,u=>typeof o=="function"?o(u):o);let s={group:(a,{modifier:o})=>o?[`:merge(${i(".group")}\\/${he(o)})`," &"]:[`:merge(${i(".group")})`," &"],peer:(a,{modifier:o})=>o?[`:merge(${i(".peer")}\\/${he(o)})`," ~ &"]:[`:merge(${i(".peer")})`," ~ &"]};for(let[a,o]of Object.entries(s))e(a,(u="",c)=>{let f=L(typeof u=="function"?u(c):u);f.includes("&")||(f="&"+f);let[d,p]=o("",c),g=null,b=null,v=0;for(let y=0;y{r("ltr",':is(:where([dir="ltr"]) &)'),r("rtl",':is(:where([dir="rtl"]) &)')},reducedMotionVariants:({addVariant:r})=>{r("motion-safe","@media (prefers-reduced-motion: no-preference)"),r("motion-reduce","@media (prefers-reduced-motion: reduce)")},darkVariants:({config:r,addVariant:e})=>{let[t,i=".dark"]=[].concat(r("darkMode","media"));t===!1&&(t="media",M.warn("darkmode-false",["The `darkMode` option in your Tailwind CSS configuration is set to `false`, which now behaves the same as `media`.","Change `darkMode` to `media` or remove it entirely.","https://tailwindcss.com/docs/upgrade-guide#remove-dark-mode-configuration"])),t==="class"?e("dark",`:is(:where(${i}) &)`):t==="media"&&e("dark","@media (prefers-color-scheme: dark)")},printVariant:({addVariant:r})=>{r("print","@media print")},screenVariants:({theme:r,addVariant:e,matchVariant:t})=>{let i=r("screens")??{},n=Object.values(i).every(w=>typeof w=="string"),s=lt(r("screens")),a=new Set([]);function o(w){return w.match(/(\D+)$/)?.[1]??"(none)"}function u(w){w!==void 0&&a.add(o(w))}function c(w){return u(w),a.size===1}for(let w of s)for(let k of w.values)u(k.min),u(k.max);let f=a.size<=1;function d(w){return Object.fromEntries(s.filter(k=>En(k).result).map(k=>{let{min:C,max:O}=k.values[0];if(w==="min"&&C!==void 0)return k;if(w==="min"&&O!==void 0)return{...k,not:!k.not};if(w==="max"&&O!==void 0)return k;if(w==="max"&&C!==void 0)return{...k,not:!k.not}}).map(k=>[k.name,k]))}function p(w){return(k,C)=>th(w,k.value,C.value)}let g=p("max"),b=p("min");function v(w){return k=>{if(n)if(f){if(typeof k=="string"&&!c(k))return M.warn("minmax-have-mixed-units",["The `min-*` and `max-*` variants are not supported with a `screens` configuration containing mixed units."]),[]}else return M.warn("mixed-screen-units",["The `min-*` and `max-*` variants are not supported with a `screens` configuration containing mixed units."]),[];else return M.warn("complex-screen-config",["The `min-*` and `max-*` variants are not supported with a `screens` configuration containing objects."]),[];return[`@media ${ot(Tn(k,w))}`]}}t("max",v("max"),{sort:g,values:n?d("max"):{}});let y="min-screens";for(let w of s)e(w.name,`@media ${ot(w)}`,{id:y,sort:n&&f?b:void 0,value:w});t("min",v("min"),{id:y,sort:b})},supportsVariants:({matchVariant:r,theme:e})=>{r("supports",(t="")=>{let i=L(t),n=/^\w*\s*\(/.test(i);return i=n?i.replace(/\b(and|or|not)\b/g," $1 "):i,n?`@supports ${i}`:(i.includes(":")||(i=`${i}: var(--tw)`),i.startsWith("(")&&i.endsWith(")")||(i=`(${i})`),`@supports ${i}`)},{values:e("supports")??{}})},hasVariants:({matchVariant:r})=>{r("has",e=>`&:has(${L(e)})`,{values:{}}),r("group-has",(e,{modifier:t})=>t?`:merge(.group\\/${t}):has(${L(e)}) &`:`:merge(.group):has(${L(e)}) &`,{values:{}}),r("peer-has",(e,{modifier:t})=>t?`:merge(.peer\\/${t}):has(${L(e)}) ~ &`:`:merge(.peer):has(${L(e)}) ~ &`,{values:{}})},ariaVariants:({matchVariant:r,theme:e})=>{r("aria",t=>`&[aria-${L(t)}]`,{values:e("aria")??{}}),r("group-aria",(t,{modifier:i})=>i?`:merge(.group\\/${i})[aria-${L(t)}] &`:`:merge(.group)[aria-${L(t)}] &`,{values:e("aria")??{}}),r("peer-aria",(t,{modifier:i})=>i?`:merge(.peer\\/${i})[aria-${L(t)}] ~ &`:`:merge(.peer)[aria-${L(t)}] ~ &`,{values:e("aria")??{}})},dataVariants:({matchVariant:r,theme:e})=>{r("data",t=>`&[data-${L(t)}]`,{values:e("data")??{}}),r("group-data",(t,{modifier:i})=>i?`:merge(.group\\/${i})[data-${L(t)}] &`:`:merge(.group)[data-${L(t)}] &`,{values:e("data")??{}}),r("peer-data",(t,{modifier:i})=>i?`:merge(.peer\\/${i})[data-${L(t)}] ~ &`:`:merge(.peer)[data-${L(t)}] ~ &`,{values:e("data")??{}})},orientationVariants:({addVariant:r})=>{r("portrait","@media (orientation: portrait)"),r("landscape","@media (orientation: landscape)")},prefersContrastVariants:({addVariant:r})=>{r("contrast-more","@media (prefers-contrast: more)"),r("contrast-less","@media (prefers-contrast: less)")},forcedColorsVariants:({addVariant:r})=>{r("forced-colors","@media (forced-colors: active)")}},Pe=["translate(var(--tw-translate-x), var(--tw-translate-y))","rotate(var(--tw-rotate))","skewX(var(--tw-skew-x))","skewY(var(--tw-skew-y))","scaleX(var(--tw-scale-x))","scaleY(var(--tw-scale-y))"].join(" "),Be=["var(--tw-blur)","var(--tw-brightness)","var(--tw-contrast)","var(--tw-grayscale)","var(--tw-hue-rotate)","var(--tw-invert)","var(--tw-saturate)","var(--tw-sepia)","var(--tw-drop-shadow)"].join(" "),Me=["var(--tw-backdrop-blur)","var(--tw-backdrop-brightness)","var(--tw-backdrop-contrast)","var(--tw-backdrop-grayscale)","var(--tw-backdrop-hue-rotate)","var(--tw-backdrop-invert)","var(--tw-backdrop-opacity)","var(--tw-backdrop-saturate)","var(--tw-backdrop-sepia)"].join(" "),nh={preflight:({addBase:r})=>{let e=U.parse(`*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:theme('borderColor.DEFAULT', currentColor)}::after,::before{--tw-content:''}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:theme('fontFamily.sans', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:theme('fontFamily.sans[1].fontFeatureSettings', normal);font-variation-settings:theme('fontFamily.sans[1].fontVariationSettings', normal);-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:theme('fontFamily.mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:theme('fontFamily.mono[1].fontFeatureSettings', normal);font-variation-settings:theme('fontFamily.mono[1].fontVariationSettings', normal);font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:theme('colors.gray.4', #9ca3af)}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}`);r([U.comment({text:`! tailwindcss v${go} | MIT License | https://tailwindcss.com`}),...e.nodes])},container:(()=>{function r(t=[]){return t.flatMap(i=>i.values.map(n=>n.min)).filter(i=>i!==void 0)}function e(t,i,n){if(typeof n=="undefined")return[];if(!(typeof n=="object"&&n!==null))return[{screen:"DEFAULT",minWidth:0,padding:n}];let s=[];n.DEFAULT&&s.push({screen:"DEFAULT",minWidth:0,padding:n.DEFAULT});for(let a of t)for(let o of i)for(let{min:u}of o.values)u===a&&s.push({minWidth:a,padding:n[o.name]});return s}return function({addComponents:t,theme:i}){let n=lt(i("container.screens",i("screens"))),s=r(n),a=e(s,n,i("container.padding")),o=c=>{let f=a.find(d=>d.minWidth===c);return f?{paddingRight:f.padding,paddingLeft:f.padding}:{}},u=Array.from(new Set(s.slice().sort((c,f)=>parseInt(c)-parseInt(f)))).map(c=>({[`@media (min-width: ${c})`]:{".container":{"max-width":c,...o(c)}}}));t([{".container":Object.assign({width:"100%"},i("container.center",!1)?{marginRight:"auto",marginLeft:"auto"}:{},o(0))},...u])}})(),accessibility:({addUtilities:r})=>{r({".sr-only":{position:"absolute",width:"1px",height:"1px",padding:"0",margin:"-1px",overflow:"hidden",clip:"rect(0, 0, 0, 0)",whiteSpace:"nowrap",borderWidth:"0"},".not-sr-only":{position:"static",width:"auto",height:"auto",padding:"0",margin:"0",overflow:"visible",clip:"auto",whiteSpace:"normal"}})},pointerEvents:({addUtilities:r})=>{r({".pointer-events-none":{"pointer-events":"none"},".pointer-events-auto":{"pointer-events":"auto"}})},visibility:({addUtilities:r})=>{r({".visible":{visibility:"visible"},".invisible":{visibility:"hidden"},".collapse":{visibility:"collapse"}})},position:({addUtilities:r})=>{r({".static":{position:"static"},".fixed":{position:"fixed"},".absolute":{position:"absolute"},".relative":{position:"relative"},".sticky":{position:"sticky"}})},inset:P("inset",[["inset",["inset"]],[["inset-x",["left","right"]],["inset-y",["top","bottom"]]],[["start",["inset-inline-start"]],["end",["inset-inline-end"]],["top",["top"]],["right",["right"]],["bottom",["bottom"]],["left",["left"]]]],{supportsNegativeValues:!0}),isolation:({addUtilities:r})=>{r({".isolate":{isolation:"isolate"},".isolation-auto":{isolation:"auto"}})},zIndex:P("zIndex",[["z",["zIndex"]]],{supportsNegativeValues:!0}),order:P("order",void 0,{supportsNegativeValues:!0}),gridColumn:P("gridColumn",[["col",["gridColumn"]]]),gridColumnStart:P("gridColumnStart",[["col-start",["gridColumnStart"]]]),gridColumnEnd:P("gridColumnEnd",[["col-end",["gridColumnEnd"]]]),gridRow:P("gridRow",[["row",["gridRow"]]]),gridRowStart:P("gridRowStart",[["row-start",["gridRowStart"]]]),gridRowEnd:P("gridRowEnd",[["row-end",["gridRowEnd"]]]),float:({addUtilities:r})=>{r({".float-start":{float:"inline-start"},".float-end":{float:"inline-end"},".float-right":{float:"right"},".float-left":{float:"left"},".float-none":{float:"none"}})},clear:({addUtilities:r})=>{r({".clear-start":{clear:"inline-start"},".clear-end":{clear:"inline-end"},".clear-left":{clear:"left"},".clear-right":{clear:"right"},".clear-both":{clear:"both"},".clear-none":{clear:"none"}})},margin:P("margin",[["m",["margin"]],[["mx",["margin-left","margin-right"]],["my",["margin-top","margin-bottom"]]],[["ms",["margin-inline-start"]],["me",["margin-inline-end"]],["mt",["margin-top"]],["mr",["margin-right"]],["mb",["margin-bottom"]],["ml",["margin-left"]]]],{supportsNegativeValues:!0}),boxSizing:({addUtilities:r})=>{r({".box-border":{"box-sizing":"border-box"},".box-content":{"box-sizing":"content-box"}})},lineClamp:({matchUtilities:r,addUtilities:e,theme:t})=>{r({"line-clamp":i=>({overflow:"hidden",display:"-webkit-box","-webkit-box-orient":"vertical","-webkit-line-clamp":`${i}`})},{values:t("lineClamp")}),e({".line-clamp-none":{overflow:"visible",display:"block","-webkit-box-orient":"horizontal","-webkit-line-clamp":"none"}})},display:({addUtilities:r})=>{r({".block":{display:"block"},".inline-block":{display:"inline-block"},".inline":{display:"inline"},".flex":{display:"flex"},".inline-flex":{display:"inline-flex"},".table":{display:"table"},".inline-table":{display:"inline-table"},".table-caption":{display:"table-caption"},".table-cell":{display:"table-cell"},".table-column":{display:"table-column"},".table-column-group":{display:"table-column-group"},".table-footer-group":{display:"table-footer-group"},".table-header-group":{display:"table-header-group"},".table-row-group":{display:"table-row-group"},".table-row":{display:"table-row"},".flow-root":{display:"flow-root"},".grid":{display:"grid"},".inline-grid":{display:"inline-grid"},".contents":{display:"contents"},".list-item":{display:"list-item"},".hidden":{display:"none"}})},aspectRatio:P("aspectRatio",[["aspect",["aspect-ratio"]]]),size:P("size",[["size",["width","height"]]]),height:P("height",[["h",["height"]]]),maxHeight:P("maxHeight",[["max-h",["maxHeight"]]]),minHeight:P("minHeight",[["min-h",["minHeight"]]]),width:P("width",[["w",["width"]]]),minWidth:P("minWidth",[["min-w",["minWidth"]]]),maxWidth:P("maxWidth",[["max-w",["maxWidth"]]]),flex:P("flex"),flexShrink:P("flexShrink",[["flex-shrink",["flex-shrink"]],["shrink",["flex-shrink"]]]),flexGrow:P("flexGrow",[["flex-grow",["flex-grow"]],["grow",["flex-grow"]]]),flexBasis:P("flexBasis",[["basis",["flex-basis"]]]),tableLayout:({addUtilities:r})=>{r({".table-auto":{"table-layout":"auto"},".table-fixed":{"table-layout":"fixed"}})},captionSide:({addUtilities:r})=>{r({".caption-top":{"caption-side":"top"},".caption-bottom":{"caption-side":"bottom"}})},borderCollapse:({addUtilities:r})=>{r({".border-collapse":{"border-collapse":"collapse"},".border-separate":{"border-collapse":"separate"}})},borderSpacing:({addDefaults:r,matchUtilities:e,theme:t})=>{r("border-spacing",{"--tw-border-spacing-x":0,"--tw-border-spacing-y":0}),e({"border-spacing":i=>({"--tw-border-spacing-x":i,"--tw-border-spacing-y":i,"@defaults border-spacing":{},"border-spacing":"var(--tw-border-spacing-x) var(--tw-border-spacing-y)"}),"border-spacing-x":i=>({"--tw-border-spacing-x":i,"@defaults border-spacing":{},"border-spacing":"var(--tw-border-spacing-x) var(--tw-border-spacing-y)"}),"border-spacing-y":i=>({"--tw-border-spacing-y":i,"@defaults border-spacing":{},"border-spacing":"var(--tw-border-spacing-x) var(--tw-border-spacing-y)"})},{values:t("borderSpacing")})},transformOrigin:P("transformOrigin",[["origin",["transformOrigin"]]]),translate:P("translate",[[["translate-x",[["@defaults transform",{}],"--tw-translate-x",["transform",Pe]]],["translate-y",[["@defaults transform",{}],"--tw-translate-y",["transform",Pe]]]]],{supportsNegativeValues:!0}),rotate:P("rotate",[["rotate",[["@defaults transform",{}],"--tw-rotate",["transform",Pe]]]],{supportsNegativeValues:!0}),skew:P("skew",[[["skew-x",[["@defaults transform",{}],"--tw-skew-x",["transform",Pe]]],["skew-y",[["@defaults transform",{}],"--tw-skew-y",["transform",Pe]]]]],{supportsNegativeValues:!0}),scale:P("scale",[["scale",[["@defaults transform",{}],"--tw-scale-x","--tw-scale-y",["transform",Pe]]],[["scale-x",[["@defaults transform",{}],"--tw-scale-x",["transform",Pe]]],["scale-y",[["@defaults transform",{}],"--tw-scale-y",["transform",Pe]]]]],{supportsNegativeValues:!0}),transform:({addDefaults:r,addUtilities:e})=>{r("transform",{"--tw-translate-x":"0","--tw-translate-y":"0","--tw-rotate":"0","--tw-skew-x":"0","--tw-skew-y":"0","--tw-scale-x":"1","--tw-scale-y":"1"}),e({".transform":{"@defaults transform":{},transform:Pe},".transform-cpu":{transform:Pe},".transform-gpu":{transform:Pe.replace("translate(var(--tw-translate-x), var(--tw-translate-y))","translate3d(var(--tw-translate-x), var(--tw-translate-y), 0)")},".transform-none":{transform:"none"}})},animation:({matchUtilities:r,theme:e,config:t})=>{let i=s=>he(t("prefix")+s),n=Object.fromEntries(Object.entries(e("keyframes")??{}).map(([s,a])=>[s,{[`@keyframes ${i(s)}`]:a}]));r({animate:s=>{let a=ho(s);return[...a.flatMap(o=>n[o.name]),{animation:a.map(({name:o,value:u})=>o===void 0||n[o]===void 0?u:u.replace(o,i(o))).join(", ")}]}},{values:e("animation")})},cursor:P("cursor"),touchAction:({addDefaults:r,addUtilities:e})=>{r("touch-action",{"--tw-pan-x":" ","--tw-pan-y":" ","--tw-pinch-zoom":" "});let t="var(--tw-pan-x) var(--tw-pan-y) var(--tw-pinch-zoom)";e({".touch-auto":{"touch-action":"auto"},".touch-none":{"touch-action":"none"},".touch-pan-x":{"@defaults touch-action":{},"--tw-pan-x":"pan-x","touch-action":t},".touch-pan-left":{"@defaults touch-action":{},"--tw-pan-x":"pan-left","touch-action":t},".touch-pan-right":{"@defaults touch-action":{},"--tw-pan-x":"pan-right","touch-action":t},".touch-pan-y":{"@defaults touch-action":{},"--tw-pan-y":"pan-y","touch-action":t},".touch-pan-up":{"@defaults touch-action":{},"--tw-pan-y":"pan-up","touch-action":t},".touch-pan-down":{"@defaults touch-action":{},"--tw-pan-y":"pan-down","touch-action":t},".touch-pinch-zoom":{"@defaults touch-action":{},"--tw-pinch-zoom":"pinch-zoom","touch-action":t},".touch-manipulation":{"touch-action":"manipulation"}})},userSelect:({addUtilities:r})=>{r({".select-none":{"user-select":"none"},".select-text":{"user-select":"text"},".select-all":{"user-select":"all"},".select-auto":{"user-select":"auto"}})},resize:({addUtilities:r})=>{r({".resize-none":{resize:"none"},".resize-y":{resize:"vertical"},".resize-x":{resize:"horizontal"},".resize":{resize:"both"}})},scrollSnapType:({addDefaults:r,addUtilities:e})=>{r("scroll-snap-type",{"--tw-scroll-snap-strictness":"proximity"}),e({".snap-none":{"scroll-snap-type":"none"},".snap-x":{"@defaults scroll-snap-type":{},"scroll-snap-type":"x var(--tw-scroll-snap-strictness)"},".snap-y":{"@defaults scroll-snap-type":{},"scroll-snap-type":"y var(--tw-scroll-snap-strictness)"},".snap-both":{"@defaults scroll-snap-type":{},"scroll-snap-type":"both var(--tw-scroll-snap-strictness)"},".snap-mandatory":{"--tw-scroll-snap-strictness":"mandatory"},".snap-proximity":{"--tw-scroll-snap-strictness":"proximity"}})},scrollSnapAlign:({addUtilities:r})=>{r({".snap-start":{"scroll-snap-align":"start"},".snap-end":{"scroll-snap-align":"end"},".snap-center":{"scroll-snap-align":"center"},".snap-align-none":{"scroll-snap-align":"none"}})},scrollSnapStop:({addUtilities:r})=>{r({".snap-normal":{"scroll-snap-stop":"normal"},".snap-always":{"scroll-snap-stop":"always"}})},scrollMargin:P("scrollMargin",[["scroll-m",["scroll-margin"]],[["scroll-mx",["scroll-margin-left","scroll-margin-right"]],["scroll-my",["scroll-margin-top","scroll-margin-bottom"]]],[["scroll-ms",["scroll-margin-inline-start"]],["scroll-me",["scroll-margin-inline-end"]],["scroll-mt",["scroll-margin-top"]],["scroll-mr",["scroll-margin-right"]],["scroll-mb",["scroll-margin-bottom"]],["scroll-ml",["scroll-margin-left"]]]],{supportsNegativeValues:!0}),scrollPadding:P("scrollPadding",[["scroll-p",["scroll-padding"]],[["scroll-px",["scroll-padding-left","scroll-padding-right"]],["scroll-py",["scroll-padding-top","scroll-padding-bottom"]]],[["scroll-ps",["scroll-padding-inline-start"]],["scroll-pe",["scroll-padding-inline-end"]],["scroll-pt",["scroll-padding-top"]],["scroll-pr",["scroll-padding-right"]],["scroll-pb",["scroll-padding-bottom"]],["scroll-pl",["scroll-padding-left"]]]]),listStylePosition:({addUtilities:r})=>{r({".list-inside":{"list-style-position":"inside"},".list-outside":{"list-style-position":"outside"}})},listStyleType:P("listStyleType",[["list",["listStyleType"]]]),listStyleImage:P("listStyleImage",[["list-image",["listStyleImage"]]]),appearance:({addUtilities:r})=>{r({".appearance-none":{appearance:"none"},".appearance-auto":{appearance:"auto"}})},columns:P("columns",[["columns",["columns"]]]),breakBefore:({addUtilities:r})=>{r({".break-before-auto":{"break-before":"auto"},".break-before-avoid":{"break-before":"avoid"},".break-before-all":{"break-before":"all"},".break-before-avoid-page":{"break-before":"avoid-page"},".break-before-page":{"break-before":"page"},".break-before-left":{"break-before":"left"},".break-before-right":{"break-before":"right"},".break-before-column":{"break-before":"column"}})},breakInside:({addUtilities:r})=>{r({".break-inside-auto":{"break-inside":"auto"},".break-inside-avoid":{"break-inside":"avoid"},".break-inside-avoid-page":{"break-inside":"avoid-page"},".break-inside-avoid-column":{"break-inside":"avoid-column"}})},breakAfter:({addUtilities:r})=>{r({".break-after-auto":{"break-after":"auto"},".break-after-avoid":{"break-after":"avoid"},".break-after-all":{"break-after":"all"},".break-after-avoid-page":{"break-after":"avoid-page"},".break-after-page":{"break-after":"page"},".break-after-left":{"break-after":"left"},".break-after-right":{"break-after":"right"},".break-after-column":{"break-after":"column"}})},gridAutoColumns:P("gridAutoColumns",[["auto-cols",["gridAutoColumns"]]]),gridAutoFlow:({addUtilities:r})=>{r({".grid-flow-row":{gridAutoFlow:"row"},".grid-flow-col":{gridAutoFlow:"column"},".grid-flow-dense":{gridAutoFlow:"dense"},".grid-flow-row-dense":{gridAutoFlow:"row dense"},".grid-flow-col-dense":{gridAutoFlow:"column dense"}})},gridAutoRows:P("gridAutoRows",[["auto-rows",["gridAutoRows"]]]),gridTemplateColumns:P("gridTemplateColumns",[["grid-cols",["gridTemplateColumns"]]]),gridTemplateRows:P("gridTemplateRows",[["grid-rows",["gridTemplateRows"]]]),flexDirection:({addUtilities:r})=>{r({".flex-row":{"flex-direction":"row"},".flex-row-reverse":{"flex-direction":"row-reverse"},".flex-col":{"flex-direction":"column"},".flex-col-reverse":{"flex-direction":"column-reverse"}})},flexWrap:({addUtilities:r})=>{r({".flex-wrap":{"flex-wrap":"wrap"},".flex-wrap-reverse":{"flex-wrap":"wrap-reverse"},".flex-nowrap":{"flex-wrap":"nowrap"}})},placeContent:({addUtilities:r})=>{r({".place-content-center":{"place-content":"center"},".place-content-start":{"place-content":"start"},".place-content-end":{"place-content":"end"},".place-content-between":{"place-content":"space-between"},".place-content-around":{"place-content":"space-around"},".place-content-evenly":{"place-content":"space-evenly"},".place-content-baseline":{"place-content":"baseline"},".place-content-stretch":{"place-content":"stretch"}})},placeItems:({addUtilities:r})=>{r({".place-items-start":{"place-items":"start"},".place-items-end":{"place-items":"end"},".place-items-center":{"place-items":"center"},".place-items-baseline":{"place-items":"baseline"},".place-items-stretch":{"place-items":"stretch"}})},alignContent:({addUtilities:r})=>{r({".content-normal":{"align-content":"normal"},".content-center":{"align-content":"center"},".content-start":{"align-content":"flex-start"},".content-end":{"align-content":"flex-end"},".content-between":{"align-content":"space-between"},".content-around":{"align-content":"space-around"},".content-evenly":{"align-content":"space-evenly"},".content-baseline":{"align-content":"baseline"},".content-stretch":{"align-content":"stretch"}})},alignItems:({addUtilities:r})=>{r({".items-start":{"align-items":"flex-start"},".items-end":{"align-items":"flex-end"},".items-center":{"align-items":"center"},".items-baseline":{"align-items":"baseline"},".items-stretch":{"align-items":"stretch"}})},justifyContent:({addUtilities:r})=>{r({".justify-normal":{"justify-content":"normal"},".justify-start":{"justify-content":"flex-start"},".justify-end":{"justify-content":"flex-end"},".justify-center":{"justify-content":"center"},".justify-between":{"justify-content":"space-between"},".justify-around":{"justify-content":"space-around"},".justify-evenly":{"justify-content":"space-evenly"},".justify-stretch":{"justify-content":"stretch"}})},justifyItems:({addUtilities:r})=>{r({".justify-items-start":{"justify-items":"start"},".justify-items-end":{"justify-items":"end"},".justify-items-center":{"justify-items":"center"},".justify-items-stretch":{"justify-items":"stretch"}})},gap:P("gap",[["gap",["gap"]],[["gap-x",["columnGap"]],["gap-y",["rowGap"]]]]),space:({matchUtilities:r,addUtilities:e,theme:t})=>{r({"space-x":i=>(i=i==="0"?"0px":i,{"& > :not([hidden]) ~ :not([hidden])":{"--tw-space-x-reverse":"0","margin-right":`calc(${i} * var(--tw-space-x-reverse))`,"margin-left":`calc(${i} * calc(1 - var(--tw-space-x-reverse)))`}}),"space-y":i=>(i=i==="0"?"0px":i,{"& > :not([hidden]) ~ :not([hidden])":{"--tw-space-y-reverse":"0","margin-top":`calc(${i} * calc(1 - var(--tw-space-y-reverse)))`,"margin-bottom":`calc(${i} * var(--tw-space-y-reverse))`}})},{values:t("space"),supportsNegativeValues:!0}),e({".space-y-reverse > :not([hidden]) ~ :not([hidden])":{"--tw-space-y-reverse":"1"},".space-x-reverse > :not([hidden]) ~ :not([hidden])":{"--tw-space-x-reverse":"1"}})},divideWidth:({matchUtilities:r,addUtilities:e,theme:t})=>{r({"divide-x":i=>(i=i==="0"?"0px":i,{"& > :not([hidden]) ~ :not([hidden])":{"@defaults border-width":{},"--tw-divide-x-reverse":"0","border-right-width":`calc(${i} * var(--tw-divide-x-reverse))`,"border-left-width":`calc(${i} * calc(1 - var(--tw-divide-x-reverse)))`}}),"divide-y":i=>(i=i==="0"?"0px":i,{"& > :not([hidden]) ~ :not([hidden])":{"@defaults border-width":{},"--tw-divide-y-reverse":"0","border-top-width":`calc(${i} * calc(1 - var(--tw-divide-y-reverse)))`,"border-bottom-width":`calc(${i} * var(--tw-divide-y-reverse))`}})},{values:t("divideWidth"),type:["line-width","length","any"]}),e({".divide-y-reverse > :not([hidden]) ~ :not([hidden])":{"@defaults border-width":{},"--tw-divide-y-reverse":"1"},".divide-x-reverse > :not([hidden]) ~ :not([hidden])":{"@defaults border-width":{},"--tw-divide-x-reverse":"1"}})},divideStyle:({addUtilities:r})=>{r({".divide-solid > :not([hidden]) ~ :not([hidden])":{"border-style":"solid"},".divide-dashed > :not([hidden]) ~ :not([hidden])":{"border-style":"dashed"},".divide-dotted > :not([hidden]) ~ :not([hidden])":{"border-style":"dotted"},".divide-double > :not([hidden]) ~ :not([hidden])":{"border-style":"double"},".divide-none > :not([hidden]) ~ :not([hidden])":{"border-style":"none"}})},divideColor:({matchUtilities:r,theme:e,corePlugins:t})=>{r({divide:i=>t("divideOpacity")?{["& > :not([hidden]) ~ :not([hidden])"]:oe({color:i,property:"border-color",variable:"--tw-divide-opacity"})}:{["& > :not([hidden]) ~ :not([hidden])"]:{"border-color":$(i)}}},{values:(({DEFAULT:i,...n})=>n)(ie(e("divideColor"))),type:["color","any"]})},divideOpacity:({matchUtilities:r,theme:e})=>{r({"divide-opacity":t=>({["& > :not([hidden]) ~ :not([hidden])"]:{"--tw-divide-opacity":t}})},{values:e("divideOpacity")})},placeSelf:({addUtilities:r})=>{r({".place-self-auto":{"place-self":"auto"},".place-self-start":{"place-self":"start"},".place-self-end":{"place-self":"end"},".place-self-center":{"place-self":"center"},".place-self-stretch":{"place-self":"stretch"}})},alignSelf:({addUtilities:r})=>{r({".self-auto":{"align-self":"auto"},".self-start":{"align-self":"flex-start"},".self-end":{"align-self":"flex-end"},".self-center":{"align-self":"center"},".self-stretch":{"align-self":"stretch"},".self-baseline":{"align-self":"baseline"}})},justifySelf:({addUtilities:r})=>{r({".justify-self-auto":{"justify-self":"auto"},".justify-self-start":{"justify-self":"start"},".justify-self-end":{"justify-self":"end"},".justify-self-center":{"justify-self":"center"},".justify-self-stretch":{"justify-self":"stretch"}})},overflow:({addUtilities:r})=>{r({".overflow-auto":{overflow:"auto"},".overflow-hidden":{overflow:"hidden"},".overflow-clip":{overflow:"clip"},".overflow-visible":{overflow:"visible"},".overflow-scroll":{overflow:"scroll"},".overflow-x-auto":{"overflow-x":"auto"},".overflow-y-auto":{"overflow-y":"auto"},".overflow-x-hidden":{"overflow-x":"hidden"},".overflow-y-hidden":{"overflow-y":"hidden"},".overflow-x-clip":{"overflow-x":"clip"},".overflow-y-clip":{"overflow-y":"clip"},".overflow-x-visible":{"overflow-x":"visible"},".overflow-y-visible":{"overflow-y":"visible"},".overflow-x-scroll":{"overflow-x":"scroll"},".overflow-y-scroll":{"overflow-y":"scroll"}})},overscrollBehavior:({addUtilities:r})=>{r({".overscroll-auto":{"overscroll-behavior":"auto"},".overscroll-contain":{"overscroll-behavior":"contain"},".overscroll-none":{"overscroll-behavior":"none"},".overscroll-y-auto":{"overscroll-behavior-y":"auto"},".overscroll-y-contain":{"overscroll-behavior-y":"contain"},".overscroll-y-none":{"overscroll-behavior-y":"none"},".overscroll-x-auto":{"overscroll-behavior-x":"auto"},".overscroll-x-contain":{"overscroll-behavior-x":"contain"},".overscroll-x-none":{"overscroll-behavior-x":"none"}})},scrollBehavior:({addUtilities:r})=>{r({".scroll-auto":{"scroll-behavior":"auto"},".scroll-smooth":{"scroll-behavior":"smooth"}})},textOverflow:({addUtilities:r})=>{r({".truncate":{overflow:"hidden","text-overflow":"ellipsis","white-space":"nowrap"},".overflow-ellipsis":{"text-overflow":"ellipsis"},".text-ellipsis":{"text-overflow":"ellipsis"},".text-clip":{"text-overflow":"clip"}})},hyphens:({addUtilities:r})=>{r({".hyphens-none":{hyphens:"none"},".hyphens-manual":{hyphens:"manual"},".hyphens-auto":{hyphens:"auto"}})},whitespace:({addUtilities:r})=>{r({".whitespace-normal":{"white-space":"normal"},".whitespace-nowrap":{"white-space":"nowrap"},".whitespace-pre":{"white-space":"pre"},".whitespace-pre-line":{"white-space":"pre-line"},".whitespace-pre-wrap":{"white-space":"pre-wrap"},".whitespace-break-spaces":{"white-space":"break-spaces"}})},textWrap:({addUtilities:r})=>{r({".text-wrap":{"text-wrap":"wrap"},".text-nowrap":{"text-wrap":"nowrap"},".text-balance":{"text-wrap":"balance"},".text-pretty":{"text-wrap":"pretty"}})},wordBreak:({addUtilities:r})=>{r({".break-normal":{"overflow-wrap":"normal","word-break":"normal"},".break-words":{"overflow-wrap":"break-word"},".break-all":{"word-break":"break-all"},".break-keep":{"word-break":"keep-all"}})},borderRadius:P("borderRadius",[["rounded",["border-radius"]],[["rounded-s",["border-start-start-radius","border-end-start-radius"]],["rounded-e",["border-start-end-radius","border-end-end-radius"]],["rounded-t",["border-top-left-radius","border-top-right-radius"]],["rounded-r",["border-top-right-radius","border-bottom-right-radius"]],["rounded-b",["border-bottom-right-radius","border-bottom-left-radius"]],["rounded-l",["border-top-left-radius","border-bottom-left-radius"]]],[["rounded-ss",["border-start-start-radius"]],["rounded-se",["border-start-end-radius"]],["rounded-ee",["border-end-end-radius"]],["rounded-es",["border-end-start-radius"]],["rounded-tl",["border-top-left-radius"]],["rounded-tr",["border-top-right-radius"]],["rounded-br",["border-bottom-right-radius"]],["rounded-bl",["border-bottom-left-radius"]]]]),borderWidth:P("borderWidth",[["border",[["@defaults border-width",{}],"border-width"]],[["border-x",[["@defaults border-width",{}],"border-left-width","border-right-width"]],["border-y",[["@defaults border-width",{}],"border-top-width","border-bottom-width"]]],[["border-s",[["@defaults border-width",{}],"border-inline-start-width"]],["border-e",[["@defaults border-width",{}],"border-inline-end-width"]],["border-t",[["@defaults border-width",{}],"border-top-width"]],["border-r",[["@defaults border-width",{}],"border-right-width"]],["border-b",[["@defaults border-width",{}],"border-bottom-width"]],["border-l",[["@defaults border-width",{}],"border-left-width"]]]],{type:["line-width","length"]}),borderStyle:({addUtilities:r})=>{r({".border-solid":{"border-style":"solid"},".border-dashed":{"border-style":"dashed"},".border-dotted":{"border-style":"dotted"},".border-double":{"border-style":"double"},".border-hidden":{"border-style":"hidden"},".border-none":{"border-style":"none"}})},borderColor:({matchUtilities:r,theme:e,corePlugins:t})=>{r({border:i=>t("borderOpacity")?oe({color:i,property:"border-color",variable:"--tw-border-opacity"}):{"border-color":$(i)}},{values:(({DEFAULT:i,...n})=>n)(ie(e("borderColor"))),type:["color","any"]}),r({"border-x":i=>t("borderOpacity")?oe({color:i,property:["border-left-color","border-right-color"],variable:"--tw-border-opacity"}):{"border-left-color":$(i),"border-right-color":$(i)},"border-y":i=>t("borderOpacity")?oe({color:i,property:["border-top-color","border-bottom-color"],variable:"--tw-border-opacity"}):{"border-top-color":$(i),"border-bottom-color":$(i)}},{values:(({DEFAULT:i,...n})=>n)(ie(e("borderColor"))),type:["color","any"]}),r({"border-s":i=>t("borderOpacity")?oe({color:i,property:"border-inline-start-color",variable:"--tw-border-opacity"}):{"border-inline-start-color":$(i)},"border-e":i=>t("borderOpacity")?oe({color:i,property:"border-inline-end-color",variable:"--tw-border-opacity"}):{"border-inline-end-color":$(i)},"border-t":i=>t("borderOpacity")?oe({color:i,property:"border-top-color",variable:"--tw-border-opacity"}):{"border-top-color":$(i)},"border-r":i=>t("borderOpacity")?oe({color:i,property:"border-right-color",variable:"--tw-border-opacity"}):{"border-right-color":$(i)},"border-b":i=>t("borderOpacity")?oe({color:i,property:"border-bottom-color",variable:"--tw-border-opacity"}):{"border-bottom-color":$(i)},"border-l":i=>t("borderOpacity")?oe({color:i,property:"border-left-color",variable:"--tw-border-opacity"}):{"border-left-color":$(i)}},{values:(({DEFAULT:i,...n})=>n)(ie(e("borderColor"))),type:["color","any"]})},borderOpacity:P("borderOpacity",[["border-opacity",["--tw-border-opacity"]]]),backgroundColor:({matchUtilities:r,theme:e,corePlugins:t})=>{r({bg:i=>t("backgroundOpacity")?oe({color:i,property:"background-color",variable:"--tw-bg-opacity"}):{"background-color":$(i)}},{values:ie(e("backgroundColor")),type:["color","any"]})},backgroundOpacity:P("backgroundOpacity",[["bg-opacity",["--tw-bg-opacity"]]]),backgroundImage:P("backgroundImage",[["bg",["background-image"]]],{type:["lookup","image","url"]}),gradientColorStops:(()=>{function r(e){return Ie(e,0,"rgb(255 255 255 / 0)")}return function({matchUtilities:e,theme:t,addDefaults:i}){i("gradient-color-stops",{"--tw-gradient-from-position":" ","--tw-gradient-via-position":" ","--tw-gradient-to-position":" "});let n={values:ie(t("gradientColorStops")),type:["color","any"]},s={values:t("gradientColorStopPositions"),type:["length","percentage"]};e({from:a=>{let o=r(a);return{"@defaults gradient-color-stops":{},"--tw-gradient-from":`${$(a)} var(--tw-gradient-from-position)`,"--tw-gradient-to":`${o} var(--tw-gradient-to-position)`,"--tw-gradient-stops":"var(--tw-gradient-from), var(--tw-gradient-to)"}}},n),e({from:a=>({"--tw-gradient-from-position":a})},s),e({via:a=>{let o=r(a);return{"@defaults gradient-color-stops":{},"--tw-gradient-to":`${o} var(--tw-gradient-to-position)`,"--tw-gradient-stops":`var(--tw-gradient-from), ${$(a)} var(--tw-gradient-via-position), var(--tw-gradient-to)`}}},n),e({via:a=>({"--tw-gradient-via-position":a})},s),e({to:a=>({"@defaults gradient-color-stops":{},"--tw-gradient-to":`${$(a)} var(--tw-gradient-to-position)`})},n),e({to:a=>({"--tw-gradient-to-position":a})},s)}})(),boxDecorationBreak:({addUtilities:r})=>{r({".decoration-slice":{"box-decoration-break":"slice"},".decoration-clone":{"box-decoration-break":"clone"},".box-decoration-slice":{"box-decoration-break":"slice"},".box-decoration-clone":{"box-decoration-break":"clone"}})},backgroundSize:P("backgroundSize",[["bg",["background-size"]]],{type:["lookup","length","percentage","size"]}),backgroundAttachment:({addUtilities:r})=>{r({".bg-fixed":{"background-attachment":"fixed"},".bg-local":{"background-attachment":"local"},".bg-scroll":{"background-attachment":"scroll"}})},backgroundClip:({addUtilities:r})=>{r({".bg-clip-border":{"background-clip":"border-box"},".bg-clip-padding":{"background-clip":"padding-box"},".bg-clip-content":{"background-clip":"content-box"},".bg-clip-text":{"background-clip":"text"}})},backgroundPosition:P("backgroundPosition",[["bg",["background-position"]]],{type:["lookup",["position",{preferOnConflict:!0}]]}),backgroundRepeat:({addUtilities:r})=>{r({".bg-repeat":{"background-repeat":"repeat"},".bg-no-repeat":{"background-repeat":"no-repeat"},".bg-repeat-x":{"background-repeat":"repeat-x"},".bg-repeat-y":{"background-repeat":"repeat-y"},".bg-repeat-round":{"background-repeat":"round"},".bg-repeat-space":{"background-repeat":"space"}})},backgroundOrigin:({addUtilities:r})=>{r({".bg-origin-border":{"background-origin":"border-box"},".bg-origin-padding":{"background-origin":"padding-box"},".bg-origin-content":{"background-origin":"content-box"}})},fill:({matchUtilities:r,theme:e})=>{r({fill:t=>({fill:$(t)})},{values:ie(e("fill")),type:["color","any"]})},stroke:({matchUtilities:r,theme:e})=>{r({stroke:t=>({stroke:$(t)})},{values:ie(e("stroke")),type:["color","url","any"]})},strokeWidth:P("strokeWidth",[["stroke",["stroke-width"]]],{type:["length","number","percentage"]}),objectFit:({addUtilities:r})=>{r({".object-contain":{"object-fit":"contain"},".object-cover":{"object-fit":"cover"},".object-fill":{"object-fit":"fill"},".object-none":{"object-fit":"none"},".object-scale-down":{"object-fit":"scale-down"}})},objectPosition:P("objectPosition",[["object",["object-position"]]]),padding:P("padding",[["p",["padding"]],[["px",["padding-left","padding-right"]],["py",["padding-top","padding-bottom"]]],[["ps",["padding-inline-start"]],["pe",["padding-inline-end"]],["pt",["padding-top"]],["pr",["padding-right"]],["pb",["padding-bottom"]],["pl",["padding-left"]]]]),textAlign:({addUtilities:r})=>{r({".text-left":{"text-align":"left"},".text-center":{"text-align":"center"},".text-right":{"text-align":"right"},".text-justify":{"text-align":"justify"},".text-start":{"text-align":"start"},".text-end":{"text-align":"end"}})},textIndent:P("textIndent",[["indent",["text-indent"]]],{supportsNegativeValues:!0}),verticalAlign:({addUtilities:r,matchUtilities:e})=>{r({".align-baseline":{"vertical-align":"baseline"},".align-top":{"vertical-align":"top"},".align-middle":{"vertical-align":"middle"},".align-bottom":{"vertical-align":"bottom"},".align-text-top":{"vertical-align":"text-top"},".align-text-bottom":{"vertical-align":"text-bottom"},".align-sub":{"vertical-align":"sub"},".align-super":{"vertical-align":"super"}}),e({align:t=>({"vertical-align":t})})},fontFamily:({matchUtilities:r,theme:e})=>{r({font:t=>{let[i,n={}]=Array.isArray(t)&&ne(t[1])?t:[t],{fontFeatureSettings:s,fontVariationSettings:a}=n;return{"font-family":Array.isArray(i)?i.join(", "):i,...s===void 0?{}:{"font-feature-settings":s},...a===void 0?{}:{"font-variation-settings":a}}}},{values:e("fontFamily"),type:["lookup","generic-name","family-name"]})},fontSize:({matchUtilities:r,theme:e})=>{r({text:(t,{modifier:i})=>{let[n,s]=Array.isArray(t)?t:[t];if(i)return{"font-size":n,"line-height":i};let{lineHeight:a,letterSpacing:o,fontWeight:u}=ne(s)?s:{lineHeight:s};return{"font-size":n,...a===void 0?{}:{"line-height":a},...o===void 0?{}:{"letter-spacing":o},...u===void 0?{}:{"font-weight":u}}}},{values:e("fontSize"),modifiers:e("lineHeight"),type:["absolute-size","relative-size","length","percentage"]})},fontWeight:P("fontWeight",[["font",["fontWeight"]]],{type:["lookup","number","any"]}),textTransform:({addUtilities:r})=>{r({".uppercase":{"text-transform":"uppercase"},".lowercase":{"text-transform":"lowercase"},".capitalize":{"text-transform":"capitalize"},".normal-case":{"text-transform":"none"}})},fontStyle:({addUtilities:r})=>{r({".italic":{"font-style":"italic"},".not-italic":{"font-style":"normal"}})},fontVariantNumeric:({addDefaults:r,addUtilities:e})=>{let t="var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)";r("font-variant-numeric",{"--tw-ordinal":" ","--tw-slashed-zero":" ","--tw-numeric-figure":" ","--tw-numeric-spacing":" ","--tw-numeric-fraction":" "}),e({".normal-nums":{"font-variant-numeric":"normal"},".ordinal":{"@defaults font-variant-numeric":{},"--tw-ordinal":"ordinal","font-variant-numeric":t},".slashed-zero":{"@defaults font-variant-numeric":{},"--tw-slashed-zero":"slashed-zero","font-variant-numeric":t},".lining-nums":{"@defaults font-variant-numeric":{},"--tw-numeric-figure":"lining-nums","font-variant-numeric":t},".oldstyle-nums":{"@defaults font-variant-numeric":{},"--tw-numeric-figure":"oldstyle-nums","font-variant-numeric":t},".proportional-nums":{"@defaults font-variant-numeric":{},"--tw-numeric-spacing":"proportional-nums","font-variant-numeric":t},".tabular-nums":{"@defaults font-variant-numeric":{},"--tw-numeric-spacing":"tabular-nums","font-variant-numeric":t},".diagonal-fractions":{"@defaults font-variant-numeric":{},"--tw-numeric-fraction":"diagonal-fractions","font-variant-numeric":t},".stacked-fractions":{"@defaults font-variant-numeric":{},"--tw-numeric-fraction":"stacked-fractions","font-variant-numeric":t}})},lineHeight:P("lineHeight",[["leading",["lineHeight"]]]),letterSpacing:P("letterSpacing",[["tracking",["letterSpacing"]]],{supportsNegativeValues:!0}),textColor:({matchUtilities:r,theme:e,corePlugins:t})=>{r({text:i=>t("textOpacity")?oe({color:i,property:"color",variable:"--tw-text-opacity"}):{color:$(i)}},{values:ie(e("textColor")),type:["color","any"]})},textOpacity:P("textOpacity",[["text-opacity",["--tw-text-opacity"]]]),textDecoration:({addUtilities:r})=>{r({".underline":{"text-decoration-line":"underline"},".overline":{"text-decoration-line":"overline"},".line-through":{"text-decoration-line":"line-through"},".no-underline":{"text-decoration-line":"none"}})},textDecorationColor:({matchUtilities:r,theme:e})=>{r({decoration:t=>({"text-decoration-color":$(t)})},{values:ie(e("textDecorationColor")),type:["color","any"]})},textDecorationStyle:({addUtilities:r})=>{r({".decoration-solid":{"text-decoration-style":"solid"},".decoration-double":{"text-decoration-style":"double"},".decoration-dotted":{"text-decoration-style":"dotted"},".decoration-dashed":{"text-decoration-style":"dashed"},".decoration-wavy":{"text-decoration-style":"wavy"}})},textDecorationThickness:P("textDecorationThickness",[["decoration",["text-decoration-thickness"]]],{type:["length","percentage"]}),textUnderlineOffset:P("textUnderlineOffset",[["underline-offset",["text-underline-offset"]]],{type:["length","percentage","any"]}),fontSmoothing:({addUtilities:r})=>{r({".antialiased":{"-webkit-font-smoothing":"antialiased","-moz-osx-font-smoothing":"grayscale"},".subpixel-antialiased":{"-webkit-font-smoothing":"auto","-moz-osx-font-smoothing":"auto"}})},placeholderColor:({matchUtilities:r,theme:e,corePlugins:t})=>{r({placeholder:i=>t("placeholderOpacity")?{"&::placeholder":oe({color:i,property:"color",variable:"--tw-placeholder-opacity"})}:{"&::placeholder":{color:$(i)}}},{values:ie(e("placeholderColor")),type:["color","any"]})},placeholderOpacity:({matchUtilities:r,theme:e})=>{r({"placeholder-opacity":t=>({["&::placeholder"]:{"--tw-placeholder-opacity":t}})},{values:e("placeholderOpacity")})},caretColor:({matchUtilities:r,theme:e})=>{r({caret:t=>({"caret-color":$(t)})},{values:ie(e("caretColor")),type:["color","any"]})},accentColor:({matchUtilities:r,theme:e})=>{r({accent:t=>({"accent-color":$(t)})},{values:ie(e("accentColor")),type:["color","any"]})},opacity:P("opacity",[["opacity",["opacity"]]]),backgroundBlendMode:({addUtilities:r})=>{r({".bg-blend-normal":{"background-blend-mode":"normal"},".bg-blend-multiply":{"background-blend-mode":"multiply"},".bg-blend-screen":{"background-blend-mode":"screen"},".bg-blend-overlay":{"background-blend-mode":"overlay"},".bg-blend-darken":{"background-blend-mode":"darken"},".bg-blend-lighten":{"background-blend-mode":"lighten"},".bg-blend-color-dodge":{"background-blend-mode":"color-dodge"},".bg-blend-color-burn":{"background-blend-mode":"color-burn"},".bg-blend-hard-light":{"background-blend-mode":"hard-light"},".bg-blend-soft-light":{"background-blend-mode":"soft-light"},".bg-blend-difference":{"background-blend-mode":"difference"},".bg-blend-exclusion":{"background-blend-mode":"exclusion"},".bg-blend-hue":{"background-blend-mode":"hue"},".bg-blend-saturation":{"background-blend-mode":"saturation"},".bg-blend-color":{"background-blend-mode":"color"},".bg-blend-luminosity":{"background-blend-mode":"luminosity"}})},mixBlendMode:({addUtilities:r})=>{r({".mix-blend-normal":{"mix-blend-mode":"normal"},".mix-blend-multiply":{"mix-blend-mode":"multiply"},".mix-blend-screen":{"mix-blend-mode":"screen"},".mix-blend-overlay":{"mix-blend-mode":"overlay"},".mix-blend-darken":{"mix-blend-mode":"darken"},".mix-blend-lighten":{"mix-blend-mode":"lighten"},".mix-blend-color-dodge":{"mix-blend-mode":"color-dodge"},".mix-blend-color-burn":{"mix-blend-mode":"color-burn"},".mix-blend-hard-light":{"mix-blend-mode":"hard-light"},".mix-blend-soft-light":{"mix-blend-mode":"soft-light"},".mix-blend-difference":{"mix-blend-mode":"difference"},".mix-blend-exclusion":{"mix-blend-mode":"exclusion"},".mix-blend-hue":{"mix-blend-mode":"hue"},".mix-blend-saturation":{"mix-blend-mode":"saturation"},".mix-blend-color":{"mix-blend-mode":"color"},".mix-blend-luminosity":{"mix-blend-mode":"luminosity"},".mix-blend-plus-lighter":{"mix-blend-mode":"plus-lighter"}})},boxShadow:(()=>{let r=Qe("boxShadow"),e=["var(--tw-ring-offset-shadow, 0 0 #0000)","var(--tw-ring-shadow, 0 0 #0000)","var(--tw-shadow)"].join(", ");return function({matchUtilities:t,addDefaults:i,theme:n}){i(" box-shadow",{"--tw-ring-offset-shadow":"0 0 #0000","--tw-ring-shadow":"0 0 #0000","--tw-shadow":"0 0 #0000","--tw-shadow-colored":"0 0 #0000"}),t({shadow:s=>{s=r(s);let a=qi(s);for(let o of a)!o.valid||(o.color="var(--tw-shadow-color)");return{"@defaults box-shadow":{},"--tw-shadow":s==="none"?"0 0 #0000":s,"--tw-shadow-colored":s==="none"?"0 0 #0000":vf(a),"box-shadow":e}}},{values:n("boxShadow"),type:["shadow"]})}})(),boxShadowColor:({matchUtilities:r,theme:e})=>{r({shadow:t=>({"--tw-shadow-color":$(t),"--tw-shadow":"var(--tw-shadow-colored)"})},{values:ie(e("boxShadowColor")),type:["color","any"]})},outlineStyle:({addUtilities:r})=>{r({".outline-none":{outline:"2px solid transparent","outline-offset":"2px"},".outline":{"outline-style":"solid"},".outline-dashed":{"outline-style":"dashed"},".outline-dotted":{"outline-style":"dotted"},".outline-double":{"outline-style":"double"}})},outlineWidth:P("outlineWidth",[["outline",["outline-width"]]],{type:["length","number","percentage"]}),outlineOffset:P("outlineOffset",[["outline-offset",["outline-offset"]]],{type:["length","number","percentage","any"],supportsNegativeValues:!0}),outlineColor:({matchUtilities:r,theme:e})=>{r({outline:t=>({"outline-color":$(t)})},{values:ie(e("outlineColor")),type:["color","any"]})},ringWidth:({matchUtilities:r,addDefaults:e,addUtilities:t,theme:i,config:n})=>{let s=(()=>{if(Z(n(),"respectDefaultRingColorOpacity"))return i("ringColor.DEFAULT");let a=i("ringOpacity.DEFAULT","0.5");return i("ringColor")?.DEFAULT?Ie(i("ringColor")?.DEFAULT,a,`rgb(147 197 253 / ${a})`):`rgb(147 197 253 / ${a})`})();e("ring-width",{"--tw-ring-inset":" ","--tw-ring-offset-width":i("ringOffsetWidth.DEFAULT","0px"),"--tw-ring-offset-color":i("ringOffsetColor.DEFAULT","#fff"),"--tw-ring-color":s,"--tw-ring-offset-shadow":"0 0 #0000","--tw-ring-shadow":"0 0 #0000","--tw-shadow":"0 0 #0000","--tw-shadow-colored":"0 0 #0000"}),r({ring:a=>({"@defaults ring-width":{},"--tw-ring-offset-shadow":"var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)","--tw-ring-shadow":`var(--tw-ring-inset) 0 0 0 calc(${a} + var(--tw-ring-offset-width)) var(--tw-ring-color)`,"box-shadow":["var(--tw-ring-offset-shadow)","var(--tw-ring-shadow)","var(--tw-shadow, 0 0 #0000)"].join(", ")})},{values:i("ringWidth"),type:"length"}),t({".ring-inset":{"@defaults ring-width":{},"--tw-ring-inset":"inset"}})},ringColor:({matchUtilities:r,theme:e,corePlugins:t})=>{r({ring:i=>t("ringOpacity")?oe({color:i,property:"--tw-ring-color",variable:"--tw-ring-opacity"}):{"--tw-ring-color":$(i)}},{values:Object.fromEntries(Object.entries(ie(e("ringColor"))).filter(([i])=>i!=="DEFAULT")),type:["color","any"]})},ringOpacity:r=>{let{config:e}=r;return P("ringOpacity",[["ring-opacity",["--tw-ring-opacity"]]],{filterDefault:!Z(e(),"respectDefaultRingColorOpacity")})(r)},ringOffsetWidth:P("ringOffsetWidth",[["ring-offset",["--tw-ring-offset-width"]]],{type:"length"}),ringOffsetColor:({matchUtilities:r,theme:e})=>{r({"ring-offset":t=>({"--tw-ring-offset-color":$(t)})},{values:ie(e("ringOffsetColor")),type:["color","any"]})},blur:({matchUtilities:r,theme:e})=>{r({blur:t=>({"--tw-blur":`blur(${t})`,"@defaults filter":{},filter:Be})},{values:e("blur")})},brightness:({matchUtilities:r,theme:e})=>{r({brightness:t=>({"--tw-brightness":`brightness(${t})`,"@defaults filter":{},filter:Be})},{values:e("brightness")})},contrast:({matchUtilities:r,theme:e})=>{r({contrast:t=>({"--tw-contrast":`contrast(${t})`,"@defaults filter":{},filter:Be})},{values:e("contrast")})},dropShadow:({matchUtilities:r,theme:e})=>{r({"drop-shadow":t=>({"--tw-drop-shadow":Array.isArray(t)?t.map(i=>`drop-shadow(${i})`).join(" "):`drop-shadow(${t})`,"@defaults filter":{},filter:Be})},{values:e("dropShadow")})},grayscale:({matchUtilities:r,theme:e})=>{r({grayscale:t=>({"--tw-grayscale":`grayscale(${t})`,"@defaults filter":{},filter:Be})},{values:e("grayscale")})},hueRotate:({matchUtilities:r,theme:e})=>{r({"hue-rotate":t=>({"--tw-hue-rotate":`hue-rotate(${t})`,"@defaults filter":{},filter:Be})},{values:e("hueRotate"),supportsNegativeValues:!0})},invert:({matchUtilities:r,theme:e})=>{r({invert:t=>({"--tw-invert":`invert(${t})`,"@defaults filter":{},filter:Be})},{values:e("invert")})},saturate:({matchUtilities:r,theme:e})=>{r({saturate:t=>({"--tw-saturate":`saturate(${t})`,"@defaults filter":{},filter:Be})},{values:e("saturate")})},sepia:({matchUtilities:r,theme:e})=>{r({sepia:t=>({"--tw-sepia":`sepia(${t})`,"@defaults filter":{},filter:Be})},{values:e("sepia")})},filter:({addDefaults:r,addUtilities:e})=>{r("filter",{"--tw-blur":" ","--tw-brightness":" ","--tw-contrast":" ","--tw-grayscale":" ","--tw-hue-rotate":" ","--tw-invert":" ","--tw-saturate":" ","--tw-sepia":" ","--tw-drop-shadow":" "}),e({".filter":{"@defaults filter":{},filter:Be},".filter-none":{filter:"none"}})},backdropBlur:({matchUtilities:r,theme:e})=>{r({"backdrop-blur":t=>({"--tw-backdrop-blur":`blur(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropBlur")})},backdropBrightness:({matchUtilities:r,theme:e})=>{r({"backdrop-brightness":t=>({"--tw-backdrop-brightness":`brightness(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropBrightness")})},backdropContrast:({matchUtilities:r,theme:e})=>{r({"backdrop-contrast":t=>({"--tw-backdrop-contrast":`contrast(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropContrast")})},backdropGrayscale:({matchUtilities:r,theme:e})=>{r({"backdrop-grayscale":t=>({"--tw-backdrop-grayscale":`grayscale(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropGrayscale")})},backdropHueRotate:({matchUtilities:r,theme:e})=>{r({"backdrop-hue-rotate":t=>({"--tw-backdrop-hue-rotate":`hue-rotate(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropHueRotate"),supportsNegativeValues:!0})},backdropInvert:({matchUtilities:r,theme:e})=>{r({"backdrop-invert":t=>({"--tw-backdrop-invert":`invert(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropInvert")})},backdropOpacity:({matchUtilities:r,theme:e})=>{r({"backdrop-opacity":t=>({"--tw-backdrop-opacity":`opacity(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropOpacity")})},backdropSaturate:({matchUtilities:r,theme:e})=>{r({"backdrop-saturate":t=>({"--tw-backdrop-saturate":`saturate(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropSaturate")})},backdropSepia:({matchUtilities:r,theme:e})=>{r({"backdrop-sepia":t=>({"--tw-backdrop-sepia":`sepia(${t})`,"@defaults backdrop-filter":{},"backdrop-filter":Me})},{values:e("backdropSepia")})},backdropFilter:({addDefaults:r,addUtilities:e})=>{r("backdrop-filter",{"--tw-backdrop-blur":" ","--tw-backdrop-brightness":" ","--tw-backdrop-contrast":" ","--tw-backdrop-grayscale":" ","--tw-backdrop-hue-rotate":" ","--tw-backdrop-invert":" ","--tw-backdrop-opacity":" ","--tw-backdrop-saturate":" ","--tw-backdrop-sepia":" "}),e({".backdrop-filter":{"@defaults backdrop-filter":{},"backdrop-filter":Me},".backdrop-filter-none":{"backdrop-filter":"none"}})},transitionProperty:({matchUtilities:r,theme:e})=>{let t=e("transitionTimingFunction.DEFAULT"),i=e("transitionDuration.DEFAULT");r({transition:n=>({"transition-property":n,...n==="none"?{}:{"transition-timing-function":t,"transition-duration":i}})},{values:e("transitionProperty")})},transitionDelay:P("transitionDelay",[["delay",["transitionDelay"]]]),transitionDuration:P("transitionDuration",[["duration",["transitionDuration"]]],{filterDefault:!0}),transitionTimingFunction:P("transitionTimingFunction",[["ease",["transitionTimingFunction"]]],{filterDefault:!0}),willChange:P("willChange",[["will-change",["will-change"]]]),content:P("content",[["content",["--tw-content",["content","var(--tw-content)"]]]]),forcedColorAdjust:({addUtilities:r})=>{r({".forced-color-adjust-auto":{"forced-color-adjust":"auto"},".forced-color-adjust-none":{"forced-color-adjust":"none"}})}}});function QS(r){if(r===void 0)return!1;if(r==="true"||r==="1")return!0;if(r==="false"||r==="0")return!1;if(r==="*")return!0;let e=r.split(",").map(t=>t.split(":")[0]);return e.includes("-tailwindcss")?!1:!!e.includes("tailwindcss")}var De,ah,oh,In,bo,Je,ui,ut=S(()=>{l();mo();De=typeof h!="undefined"?{NODE_ENV:"production",DEBUG:QS(h.env.DEBUG),ENGINE:yo.tailwindcss.engine}:{NODE_ENV:"production",DEBUG:!1,ENGINE:yo.tailwindcss.engine},ah=new Map,oh=new Map,In=new Map,bo=new Map,Je=new String("*"),ui=Symbol("__NONE__")});function Gt(r){let e=[],t=!1;for(let i=0;i0)}var lh,uh,JS,wo=S(()=>{l();lh=new Map([["{","}"],["[","]"],["(",")"]]),uh=new Map(Array.from(lh.entries()).map(([r,e])=>[e,r])),JS=new Set(['"',"'","`"])});function Ht(r){let[e]=fh(r);return e.forEach(([t,i])=>t.removeChild(i)),r.nodes.push(...e.map(([,t])=>t)),r}function fh(r){let e=[],t=null;for(let i of r.nodes)if(i.type==="combinator")e=e.filter(([,n])=>vo(n).includes("jumpable")),t=null;else if(i.type==="pseudo"){XS(i)?(t=i,e.push([r,i,null])):t&&KS(i,t)?e.push([r,i,t]):t=null;for(let n of i.nodes??[]){let[s,a]=fh(n);t=a||t,e.push(...s)}}return[e,t]}function ch(r){return r.value.startsWith("::")||xo[r.value]!==void 0}function XS(r){return ch(r)&&vo(r).includes("terminal")}function KS(r,e){return r.type!=="pseudo"||ch(r)?!1:vo(e).includes("actionable")}function vo(r){return xo[r.value]??xo.__default__}var xo,Rn=S(()=>{l();xo={"::after":["terminal","jumpable"],"::backdrop":["terminal","jumpable"],"::before":["terminal","jumpable"],"::cue":["terminal"],"::cue-region":["terminal"],"::first-letter":["terminal","jumpable"],"::first-line":["terminal","jumpable"],"::grammar-error":["terminal"],"::marker":["terminal","jumpable"],"::part":["terminal","actionable"],"::placeholder":["terminal","jumpable"],"::selection":["terminal","jumpable"],"::slotted":["terminal"],"::spelling-error":["terminal"],"::target-text":["terminal"],"::file-selector-button":["terminal","actionable"],"::deep":["actionable"],"::v-deep":["actionable"],"::ng-deep":["actionable"],":after":["terminal","jumpable"],":before":["terminal","jumpable"],":first-letter":["terminal","jumpable"],":first-line":["terminal","jumpable"],__default__:["terminal","actionable"]}});function Yt(r,{context:e,candidate:t}){let i=e?.tailwindConfig.prefix??"",n=r.map(a=>{let o=(0,Le.default)().astSync(a.format);return{...a,ast:a.respectPrefix?Vt(i,o):o}}),s=Le.default.root({nodes:[Le.default.selector({nodes:[Le.default.className({value:he(t)})]})]});for(let{ast:a}of n)[s,a]=e3(s,a),a.walkNesting(o=>o.replaceWith(...s.nodes[0].nodes)),s=a;return s}function dh(r){let e=[];for(;r.prev()&&r.prev().type!=="combinator";)r=r.prev();for(;r&&r.type!=="combinator";)e.push(r),r=r.next();return e}function ZS(r){return r.sort((e,t)=>e.type==="tag"&&t.type==="class"?-1:e.type==="class"&&t.type==="tag"?1:e.type==="class"&&t.type==="pseudo"&&t.value.startsWith("::")?-1:e.type==="pseudo"&&e.value.startsWith("::")&&t.type==="class"?1:r.index(e)-r.index(t)),r}function So(r,e){let t=!1;r.walk(i=>{if(i.type==="class"&&i.value===e)return t=!0,!1}),t||r.remove()}function qn(r,e,{context:t,candidate:i,base:n}){let s=t?.tailwindConfig?.separator??":";n=n??le(i,s).pop();let a=(0,Le.default)().astSync(r);if(a.walkClasses(f=>{f.raws&&f.value.includes(n)&&(f.raws.value=he((0,ph.default)(f.raws.value)))}),a.each(f=>So(f,n)),a.length===0)return null;let o=Array.isArray(e)?Yt(e,{context:t,candidate:i}):e;if(o===null)return a.toString();let u=Le.default.comment({value:"/*__simple__*/"}),c=Le.default.comment({value:"/*__simple__*/"});return a.walkClasses(f=>{if(f.value!==n)return;let d=f.parent,p=o.nodes[0].nodes;if(d.nodes.length===1){f.replaceWith(...p);return}let g=dh(f);d.insertBefore(g[0],u),d.insertAfter(g[g.length-1],c);for(let v of p)d.insertBefore(g[0],v.clone());f.remove(),g=dh(u);let b=d.index(u);d.nodes.splice(b,g.length,...ZS(Le.default.selector({nodes:g})).nodes),u.remove(),c.remove()}),a.walkPseudos(f=>{f.value===ko&&f.replaceWith(f.nodes)}),a.each(f=>Ht(f)),a.toString()}function e3(r,e){let t=[];return r.walkPseudos(i=>{i.value===ko&&t.push({pseudo:i,value:i.nodes[0].toString()})}),e.walkPseudos(i=>{if(i.value!==ko)return;let n=i.nodes[0].toString(),s=t.find(c=>c.value===n);if(!s)return;let a=[],o=i.next();for(;o&&o.type!=="combinator";)a.push(o),o=o.next();let u=o;s.pseudo.parent.insertAfter(s.pseudo,Le.default.selector({nodes:a.map(c=>c.clone())})),i.remove(),a.forEach(c=>c.remove()),u&&u.type==="combinator"&&u.remove()}),[r,e]}var Le,ph,ko,Co=S(()=>{l();Le=J(Fe()),ph=J(cn());Wt();An();Rn();Dt();ko=":merge"});function Fn(r,e){let t=(0,Ao.default)().astSync(r);return t.each(i=>{i.nodes[0].type==="pseudo"&&i.nodes[0].value===":is"&&i.nodes.every(s=>s.type!=="combinator")||(i.nodes=[Ao.default.pseudo({value:":is",nodes:[i.clone()]})]),Ht(i)}),`${e} ${t.toString()}`}var Ao,Oo=S(()=>{l();Ao=J(Fe());Rn()});function _o(r){return t3.transformSync(r)}function*r3(r){let e=1/0;for(;e>=0;){let t,i=!1;if(e===1/0&&r.endsWith("]")){let a=r.indexOf("[");r[a-1]==="-"?t=a-1:r[a-1]==="/"?(t=a-1,i=!0):t=-1}else e===1/0&&r.includes("/")?(t=r.lastIndexOf("/"),i=!0):t=r.lastIndexOf("-",e);if(t<0)break;let n=r.slice(0,t),s=r.slice(i?t:t+1);e=t-1,!(n===""||s==="/")&&(yield[n,s])}}function i3(r,e){if(r.length===0||e.tailwindConfig.prefix==="")return r;for(let t of r){let[i]=t;if(i.options.respectPrefix){let n=U.root({nodes:[t[1].clone()]}),s=t[1].raws.tailwind.classCandidate;n.walkRules(a=>{let o=s.startsWith("-");a.selector=Vt(e.tailwindConfig.prefix,a.selector,o)}),t[1]=n.nodes[0]}}return r}function n3(r,e){if(r.length===0)return r;let t=[];for(let[i,n]of r){let s=U.root({nodes:[n.clone()]});s.walkRules(a=>{let o=(0,Bn.default)().astSync(a.selector);o.each(u=>So(u,e)),Rf(o,u=>u===e?`!${u}`:u),a.selector=o.toString(),a.walkDecls(u=>u.important=!0)}),t.push([{...i,important:!0},s.nodes[0]])}return t}function s3(r,e,t){if(e.length===0)return e;let i={modifier:null,value:ui};{let[n,...s]=le(r,"/");if(s.length>1&&(n=n+"/"+s.slice(0,-1).join("/"),s=s.slice(-1)),s.length&&!t.variantMap.has(r)&&(r=n,i.modifier=s[0],!Z(t.tailwindConfig,"generalizedModifiers")))return[]}if(r.endsWith("]")&&!r.startsWith("[")){let n=/(.)(-?)\[(.*)\]/g.exec(r);if(n){let[,s,a,o]=n;if(s==="@"&&a==="-")return[];if(s!=="@"&&a==="")return[];r=r.replace(`${a}[${o}]`,""),i.value=o}}if(Po(r)&&!t.variantMap.has(r)){let n=t.offsets.recordVariant(r),s=L(r.slice(1,-1)),a=le(s,",");if(a.length>1)return[];if(!a.every(Nn))return[];let o=a.map((u,c)=>[t.offsets.applyParallelOffset(n,c),fi(u.trim())]);t.variantMap.set(r,o)}if(t.variantMap.has(r)){let n=Po(r),s=t.variantOptions.get(r)?.[oi]??{},a=t.variantMap.get(r).slice(),o=[],u=(()=>!(n||s.respectPrefix===!1))();for(let[c,f]of e){if(c.layer==="user")continue;let d=U.root({nodes:[f.clone()]});for(let[p,g,b]of a){let w=function(){v.raws.neededBackup||(v.raws.neededBackup=!0,v.walkRules(_=>_.raws.originalSelector=_.selector))},k=function(_){return w(),v.each(I=>{I.type==="rule"&&(I.selectors=I.selectors.map(B=>_({get className(){return _o(B)},selector:B})))}),v},v=(b??d).clone(),y=[],C=g({get container(){return w(),v},separator:t.tailwindConfig.separator,modifySelectors:k,wrap(_){let I=v.nodes;v.removeAll(),_.append(I),v.append(_)},format(_){y.push({format:_,respectPrefix:u})},args:i});if(Array.isArray(C)){for(let[_,I]of C.entries())a.push([t.offsets.applyParallelOffset(p,_),I,v.clone()]);continue}if(typeof C=="string"&&y.push({format:C,respectPrefix:u}),C===null)continue;v.raws.neededBackup&&(delete v.raws.neededBackup,v.walkRules(_=>{let I=_.raws.originalSelector;if(!I||(delete _.raws.originalSelector,I===_.selector))return;let B=_.selector,R=(0,Bn.default)(X=>{X.walkClasses(ue=>{ue.value=`${r}${t.tailwindConfig.separator}${ue.value}`})}).processSync(I);y.push({format:B.replace(R,"&"),respectPrefix:u}),_.selector=I})),v.nodes[0].raws.tailwind={...v.nodes[0].raws.tailwind,parentLayer:c.layer};let O=[{...c,sort:t.offsets.applyVariantOffset(c.sort,p,Object.assign(i,t.variantOptions.get(r))),collectedFormats:(c.collectedFormats??[]).concat(y)},v.nodes[0]];o.push(O)}}return o}return[]}function Eo(r,e,t={}){return!ne(r)&&!Array.isArray(r)?[[r],t]:Array.isArray(r)?Eo(r[0],e,r[1]):(e.has(r)||e.set(r,Ut(r)),[e.get(r),t])}function o3(r){return a3.test(r)}function l3(r){if(!r.includes("://"))return!1;try{let e=new URL(r);return e.scheme!==""&&e.host!==""}catch(e){return!1}}function hh(r){let e=!0;return r.walkDecls(t=>{if(!mh(t.prop,t.value))return e=!1,!1}),e}function mh(r,e){if(l3(`${r}:${e}`))return!1;try{return U.parse(`a{${r}:${e}}`).toResult(),!0}catch(t){return!1}}function u3(r,e){let[,t,i]=r.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/)??[];if(i===void 0||!o3(t)||!Gt(i))return null;let n=L(i,{property:t});return mh(t,n)?[[{sort:e.offsets.arbitraryProperty(),layer:"utilities"},()=>({[co(r)]:{[t]:n}})]]:null}function*f3(r,e){e.candidateRuleMap.has(r)&&(yield[e.candidateRuleMap.get(r),"DEFAULT"]),yield*function*(o){o!==null&&(yield[o,"DEFAULT"])}(u3(r,e));let t=r,i=!1,n=e.tailwindConfig.prefix,s=n.length,a=t.startsWith(n)||t.startsWith(`-${n}`);t[s]==="-"&&a&&(i=!0,t=n+t.slice(s+1)),i&&e.candidateRuleMap.has(t)&&(yield[e.candidateRuleMap.get(t),"-DEFAULT"]);for(let[o,u]of r3(t))e.candidateRuleMap.has(o)&&(yield[e.candidateRuleMap.get(o),i?`-${u}`:u])}function c3(r,e){return r===Je?[Je]:le(r,e)}function*p3(r,e){for(let t of r)t[1].raws.tailwind={...t[1].raws.tailwind,classCandidate:e,preserveSource:t[0].options?.preserveSource??!1},yield t}function*To(r,e){let t=e.tailwindConfig.separator,[i,...n]=c3(r,t).reverse(),s=!1;i.startsWith("!")&&(s=!0,i=i.slice(1));for(let a of f3(i,e)){let o=[],u=new Map,[c,f]=a,d=c.length===1;for(let[p,g]of c){let b=[];if(typeof g=="function")for(let v of[].concat(g(f,{isOnlyPlugin:d}))){let[y,w]=Eo(v,e.postCssNodeCache);for(let k of y)b.push([{...p,options:{...p.options,...w}},k])}else if(f==="DEFAULT"||f==="-DEFAULT"){let v=g,[y,w]=Eo(v,e.postCssNodeCache);for(let k of y)b.push([{...p,options:{...p.options,...w}},k])}if(b.length>0){let v=Array.from(Rs(p.options?.types??[],f,p.options??{},e.tailwindConfig)).map(([y,w])=>w);v.length>0&&u.set(b,v),o.push(b)}}if(Po(f)){if(o.length>1){let b=function(y){return y.length===1?y[0]:y.find(w=>{let k=u.get(w);return w.some(([{options:C},O])=>hh(O)?C.types.some(({type:_,preferOnConflict:I})=>k.includes(_)&&I):!1)})},[p,g]=o.reduce((y,w)=>(w.some(([{options:C}])=>C.types.some(({type:O})=>O==="any"))?y[0].push(w):y[1].push(w),y),[[],[]]),v=b(g)??b(p);if(v)o=[v];else{let y=o.map(k=>new Set([...u.get(k)??[]]));for(let k of y)for(let C of k){let O=!1;for(let _ of y)k!==_&&_.has(C)&&(_.delete(C),O=!0);O&&k.delete(C)}let w=[];for(let[k,C]of y.entries())for(let O of C){let _=o[k].map(([,I])=>I).flat().map(I=>I.toString().split(`
+`).slice(1,-1).map(B=>B.trim()).map(B=>` ${B}`).join(`
+`)).join(`
+
+`);w.push(` Use \`${r.replace("[",`[${O}:`)}\` for \`${_.trim()}\``);break}M.warn([`The class \`${r}\` is ambiguous and matches multiple utilities.`,...w,`If this is content and not a class, replace it with \`${r.replace("[","[").replace("]","]")}\` to silence this warning.`]);continue}}o=o.map(p=>p.filter(g=>hh(g[1])))}o=o.flat(),o=Array.from(p3(o,i)),o=i3(o,e),s&&(o=n3(o,i));for(let p of n)o=s3(p,o,e);for(let p of o)p[1].raws.tailwind={...p[1].raws.tailwind,candidate:r},p=d3(p,{context:e,candidate:r}),p!==null&&(yield p)}}function d3(r,{context:e,candidate:t}){if(!r[0].collectedFormats)return r;let i=!0,n;try{n=Yt(r[0].collectedFormats,{context:e,candidate:t})}catch{return null}let s=U.root({nodes:[r[1].clone()]});return s.walkRules(a=>{if(!Mn(a))try{let o=qn(a.selector,n,{candidate:t,context:e});if(o===null){a.remove();return}a.selector=o}catch{return i=!1,!1}}),!i||s.nodes.length===0?null:(r[1]=s.nodes[0],r)}function Mn(r){return r.parent&&r.parent.type==="atrule"&&r.parent.name==="keyframes"}function h3(r){if(r===!0)return e=>{Mn(e)||e.walkDecls(t=>{t.parent.type==="rule"&&!Mn(t.parent)&&(t.important=!0)})};if(typeof r=="string")return e=>{Mn(e)||(e.selectors=e.selectors.map(t=>Fn(t,r)))}}function Ln(r,e,t=!1){let i=[],n=h3(e.tailwindConfig.important);for(let s of r){if(e.notClassCache.has(s))continue;if(e.candidateRuleCache.has(s)){i=i.concat(Array.from(e.candidateRuleCache.get(s)));continue}let a=Array.from(To(s,e));if(a.length===0){e.notClassCache.add(s);continue}e.classCache.set(s,a);let o=e.candidateRuleCache.get(s)??new Set;e.candidateRuleCache.set(s,o);for(let u of a){let[{sort:c,options:f},d]=u;if(f.respectImportant&&n){let g=U.root({nodes:[d.clone()]});g.walkRules(n),d=g.nodes[0]}let p=[c,t?d.clone():d];o.add(p),e.ruleCache.add(p),i.push(p)}}return i}function Po(r){return r.startsWith("[")&&r.endsWith("]")}var Bn,t3,a3,$n=S(()=>{l();at();Bn=J(Fe());fo();Pt();An();vr();Ee();ut();Co();po();xr();li();wo();Dt();We();Oo();t3=(0,Bn.default)(r=>r.first.filter(({type:e})=>e==="class").pop().value);a3=/^[a-z_-]/});var gh,yh=S(()=>{l();gh={}});function m3(r){try{return gh.createHash("md5").update(r,"utf-8").digest("binary")}catch(e){return""}}function bh(r,e){let t=e.toString();if(!t.includes("@tailwind"))return!1;let i=bo.get(r),n=m3(t),s=i!==n;return bo.set(r,n),s}var wh=S(()=>{l();yh();ut()});function zn(r){return(r>0n)-(r<0n)}var xh=S(()=>{l()});function vh(r,e){let t=0n,i=0n;for(let[n,s]of e)r&n&&(t=t|n,i=i|s);return r&~t|i}var kh=S(()=>{l()});function Sh(r){let e=null;for(let t of r)e=e??t,e=e>t?e:t;return e}function g3(r,e){let t=r.length,i=e.length,n=t{l();xh();kh();Do=class{constructor(){this.offsets={defaults:0n,base:0n,components:0n,utilities:0n,variants:0n,user:0n},this.layerPositions={defaults:0n,base:1n,components:2n,utilities:3n,user:4n,variants:5n},this.reservedVariantBits=0n,this.variantOffsets=new Map}create(e){return{layer:e,parentLayer:e,arbitrary:0n,variants:0n,parallelIndex:0n,index:this.offsets[e]++,options:[]}}arbitraryProperty(){return{...this.create("utilities"),arbitrary:1n}}forVariant(e,t=0){let i=this.variantOffsets.get(e);if(i===void 0)throw new Error(`Cannot find offset for unknown variant ${e}`);return{...this.create("variants"),variants:i<n.startsWith("[")).sort(([n],[s])=>g3(n,s)),t=e.map(([,n])=>n).sort((n,s)=>zn(n-s));return e.map(([,n],s)=>[n,t[s]]).filter(([n,s])=>n!==s)}remapArbitraryVariantOffsets(e){let t=this.recalculateVariantOffsets();return t.length===0?e:e.map(i=>{let[n,s]=i;return n={...n,variants:vh(n.variants,t)},[n,s]})}sort(e){return e=this.remapArbitraryVariantOffsets(e),e.sort(([t],[i])=>zn(this.compare(t,i)))}}});function Fo(r,e){let t=r.tailwindConfig.prefix;return typeof t=="function"?t(e):t+e}function Oh({type:r="any",...e}){let t=[].concat(r);return{...e,types:t.map(i=>Array.isArray(i)?{type:i[0],...i[1]}:{type:i,preferOnConflict:!1})}}function y3(r){let e=[],t="",i=0;for(let n=0;n0&&e.push(t.trim()),e=e.filter(n=>n!==""),e}function b3(r,e,{before:t=[]}={}){if(t=[].concat(t),t.length<=0){r.push(e);return}let i=r.length-1;for(let n of t){let s=r.indexOf(n);s!==-1&&(i=Math.min(i,s))}r.splice(i,0,e)}function _h(r){return Array.isArray(r)?r.flatMap(e=>!Array.isArray(e)&&!ne(e)?e:Ut(e)):_h([r])}function w3(r,e){return(0,Io.default)(i=>{let n=[];return e&&e(i),i.walkClasses(s=>{n.push(s.value)}),n}).transformSync(r)}function x3(r){r.walkPseudos(e=>{e.value===":not"&&e.remove()})}function v3(r,e={containsNonOnDemandable:!1},t=0){let i=[],n=[];r.type==="rule"?n.push(...r.selectors):r.type==="atrule"&&r.walkRules(s=>n.push(...s.selectors));for(let s of n){let a=w3(s,x3);a.length===0&&(e.containsNonOnDemandable=!0);for(let o of a)i.push(o)}return t===0?[e.containsNonOnDemandable||i.length===0,i]:i}function jn(r){return _h(r).flatMap(e=>{let t=new Map,[i,n]=v3(e);return i&&n.unshift(Je),n.map(s=>(t.has(e)||t.set(e,e),[s,t.get(e)]))})}function Nn(r){return r.startsWith("@")||r.includes("&")}function fi(r){r=r.replace(/\n+/g,"").replace(/\s{1,}/g," ").trim();let e=y3(r).map(t=>{if(!t.startsWith("@"))return({format:s})=>s(t);let[,i,n]=/@(\S*)( .+|[({].*)?/g.exec(t);return({wrap:s})=>s(U.atRule({name:i,params:n?.trim()??""}))}).reverse();return t=>{for(let i of e)i(t)}}function k3(r,e,{variantList:t,variantMap:i,offsets:n,classList:s}){function a(p,g){return p?(0,Ah.default)(r,p,g):r}function o(p){return Vt(r.prefix,p)}function u(p,g){return p===Je?Je:g.respectPrefix?e.tailwindConfig.prefix+p:p}function c(p,g,b={}){let v=tt(p),y=a(["theme",...v],g);return Qe(v[0])(y,b)}let f=0,d={postcss:U,prefix:o,e:he,config:a,theme:c,corePlugins:p=>Array.isArray(r.corePlugins)?r.corePlugins.includes(p):a(["corePlugins",p],!0),variants:()=>[],addBase(p){for(let[g,b]of jn(p)){let v=u(g,{}),y=n.create("base");e.candidateRuleMap.has(v)||e.candidateRuleMap.set(v,[]),e.candidateRuleMap.get(v).push([{sort:y,layer:"base"},b])}},addDefaults(p,g){let b={[`@defaults ${p}`]:g};for(let[v,y]of jn(b)){let w=u(v,{});e.candidateRuleMap.has(w)||e.candidateRuleMap.set(w,[]),e.candidateRuleMap.get(w).push([{sort:n.create("defaults"),layer:"defaults"},y])}},addComponents(p,g){g=Object.assign({},{preserveSource:!1,respectPrefix:!0,respectImportant:!1},Array.isArray(g)?{}:g);for(let[v,y]of jn(p)){let w=u(v,g);s.add(w),e.candidateRuleMap.has(w)||e.candidateRuleMap.set(w,[]),e.candidateRuleMap.get(w).push([{sort:n.create("components"),layer:"components",options:g},y])}},addUtilities(p,g){g=Object.assign({},{preserveSource:!1,respectPrefix:!0,respectImportant:!0},Array.isArray(g)?{}:g);for(let[v,y]of jn(p)){let w=u(v,g);s.add(w),e.candidateRuleMap.has(w)||e.candidateRuleMap.set(w,[]),e.candidateRuleMap.get(w).push([{sort:n.create("utilities"),layer:"utilities",options:g},y])}},matchUtilities:function(p,g){g=Oh({...{respectPrefix:!0,respectImportant:!0,modifiers:!1},...g});let v=n.create("utilities");for(let y in p){let C=function(_,{isOnlyPlugin:I}){let[B,R,X]=Is(g.types,_,g,r);if(B===void 0)return[];if(!g.types.some(({type:z})=>z===R))if(I)M.warn([`Unnecessary typehint \`${R}\` in \`${y}-${_}\`.`,`You can safely update it to \`${y}-${_.replace(R+":","")}\`.`]);else return[];if(!Gt(B))return[];let ue={get modifier(){return g.modifiers||M.warn(`modifier-used-without-options-for-${y}`,["Your plugin must set `modifiers: true` in its options to support modifiers."]),X}},pe=Z(r,"generalizedModifiers");return[].concat(pe?k(B,ue):k(B)).filter(Boolean).map(z=>({[On(y,_)]:z}))},w=u(y,g),k=p[y];s.add([w,g]);let O=[{sort:v,layer:"utilities",options:g},C];e.candidateRuleMap.has(w)||e.candidateRuleMap.set(w,[]),e.candidateRuleMap.get(w).push(O)}},matchComponents:function(p,g){g=Oh({...{respectPrefix:!0,respectImportant:!1,modifiers:!1},...g});let v=n.create("components");for(let y in p){let C=function(_,{isOnlyPlugin:I}){let[B,R,X]=Is(g.types,_,g,r);if(B===void 0)return[];if(!g.types.some(({type:z})=>z===R))if(I)M.warn([`Unnecessary typehint \`${R}\` in \`${y}-${_}\`.`,`You can safely update it to \`${y}-${_.replace(R+":","")}\`.`]);else return[];if(!Gt(B))return[];let ue={get modifier(){return g.modifiers||M.warn(`modifier-used-without-options-for-${y}`,["Your plugin must set `modifiers: true` in its options to support modifiers."]),X}},pe=Z(r,"generalizedModifiers");return[].concat(pe?k(B,ue):k(B)).filter(Boolean).map(z=>({[On(y,_)]:z}))},w=u(y,g),k=p[y];s.add([w,g]);let O=[{sort:v,layer:"components",options:g},C];e.candidateRuleMap.has(w)||e.candidateRuleMap.set(w,[]),e.candidateRuleMap.get(w).push(O)}},addVariant(p,g,b={}){g=[].concat(g).map(v=>{if(typeof v!="string")return(y={})=>{let{args:w,modifySelectors:k,container:C,separator:O,wrap:_,format:I}=y,B=v(Object.assign({modifySelectors:k,container:C,separator:O},b.type===Ro.MatchVariant&&{args:w,wrap:_,format:I}));if(typeof B=="string"&&!Nn(B))throw new Error(`Your custom variant \`${p}\` has an invalid format string. Make sure it's an at-rule or contains a \`&\` placeholder.`);return Array.isArray(B)?B.filter(R=>typeof R=="string").map(R=>fi(R)):B&&typeof B=="string"&&fi(B)(y)};if(!Nn(v))throw new Error(`Your custom variant \`${p}\` has an invalid format string. Make sure it's an at-rule or contains a \`&\` placeholder.`);return fi(v)}),b3(t,p,b),i.set(p,g),e.variantOptions.set(p,b)},matchVariant(p,g,b){let v=b?.id??++f,y=p==="@",w=Z(r,"generalizedModifiers");for(let[C,O]of Object.entries(b?.values??{}))C!=="DEFAULT"&&d.addVariant(y?`${p}${C}`:`${p}-${C}`,({args:_,container:I})=>g(O,w?{modifier:_?.modifier,container:I}:{container:I}),{...b,value:O,id:v,type:Ro.MatchVariant,variantInfo:qo.Base});let k="DEFAULT"in(b?.values??{});d.addVariant(p,({args:C,container:O})=>C?.value===ui&&!k?null:g(C?.value===ui?b.values.DEFAULT:C?.value??(typeof C=="string"?C:""),w?{modifier:C?.modifier,container:O}:{container:O}),{...b,id:v,type:Ro.MatchVariant,variantInfo:qo.Dynamic})}};return d}function Un(r){return Bo.has(r)||Bo.set(r,new Map),Bo.get(r)}function Eh(r,e){let t=!1,i=new Map;for(let n of r){if(!n)continue;let s=Ls.parse(n),a=s.hash?s.href.replace(s.hash,""):s.href;a=s.search?a.replace(s.search,""):a;let o=re.statSync(decodeURIComponent(a),{throwIfNoEntry:!1})?.mtimeMs;!o||((!e.has(n)||o>e.get(n))&&(t=!0),i.set(n,o))}return[t,i]}function Th(r){r.walkAtRules(e=>{["responsive","variants"].includes(e.name)&&(Th(e),e.before(e.nodes),e.remove())})}function S3(r){let e=[];return r.each(t=>{t.type==="atrule"&&["responsive","variants"].includes(t.name)&&(t.name="layer",t.params="utilities")}),r.walkAtRules("layer",t=>{if(Th(t),t.params==="base"){for(let i of t.nodes)e.push(function({addBase:n}){n(i,{respectPrefix:!1})});t.remove()}else if(t.params==="components"){for(let i of t.nodes)e.push(function({addComponents:n}){n(i,{respectPrefix:!1,preserveSource:!0})});t.remove()}else if(t.params==="utilities"){for(let i of t.nodes)e.push(function({addUtilities:n}){n(i,{respectPrefix:!1,preserveSource:!0})});t.remove()}}),e}function C3(r,e){let t=Object.entries({...ae,...nh}).map(([o,u])=>r.tailwindConfig.corePlugins.includes(o)?u:null).filter(Boolean),i=r.tailwindConfig.plugins.map(o=>(o.__isOptionsFunction&&(o=o()),typeof o=="function"?o:o.handler)),n=S3(e),s=[ae.childVariant,ae.pseudoElementVariants,ae.pseudoClassVariants,ae.hasVariants,ae.ariaVariants,ae.dataVariants],a=[ae.supportsVariants,ae.reducedMotionVariants,ae.prefersContrastVariants,ae.printVariant,ae.screenVariants,ae.orientationVariants,ae.directionVariants,ae.darkVariants,ae.forcedColorsVariants];return[...t,...s,...i,...a,...n]}function A3(r,e){let t=[],i=new Map;e.variantMap=i;let n=new Do;e.offsets=n;let s=new Set,a=k3(e.tailwindConfig,e,{variantList:t,variantMap:i,offsets:n,classList:s});for(let f of r)if(Array.isArray(f))for(let d of f)d(a);else f?.(a);n.recordVariants(t,f=>i.get(f).length);for(let[f,d]of i.entries())e.variantMap.set(f,d.map((p,g)=>[n.forVariant(f,g),p]));let o=(e.tailwindConfig.safelist??[]).filter(Boolean);if(o.length>0){let f=[];for(let d of o){if(typeof d=="string"){e.changedContent.push({content:d,extension:"html"});continue}if(d instanceof RegExp){M.warn("root-regex",["Regular expressions in `safelist` work differently in Tailwind CSS v3.0.","Update your `safelist` configuration to eliminate this warning.","https://tailwindcss.com/docs/content-configuration#safelisting-classes"]);continue}f.push(d)}if(f.length>0){let d=new Map,p=e.tailwindConfig.prefix.length,g=f.some(b=>b.pattern.source.includes("!"));for(let b of s){let v=Array.isArray(b)?(()=>{let[y,w]=b,C=Object.keys(w?.values??{}).map(O=>ai(y,O));return w?.supportsNegativeValues&&(C=[...C,...C.map(O=>"-"+O)],C=[...C,...C.map(O=>O.slice(0,p)+"-"+O.slice(p))]),w.types.some(({type:O})=>O==="color")&&(C=[...C,...C.flatMap(O=>Object.keys(e.tailwindConfig.theme.opacity).map(_=>`${O}/${_}`))]),g&&w?.respectImportant&&(C=[...C,...C.map(O=>"!"+O)]),C})():[b];for(let y of v)for(let{pattern:w,variants:k=[]}of f)if(w.lastIndex=0,d.has(w)||d.set(w,0),!!w.test(y)){d.set(w,d.get(w)+1),e.changedContent.push({content:y,extension:"html"});for(let C of k)e.changedContent.push({content:C+e.tailwindConfig.separator+y,extension:"html"})}}for(let[b,v]of d.entries())v===0&&M.warn([`The safelist pattern \`${b}\` doesn't match any Tailwind CSS classes.`,"Fix this pattern or remove it from your `safelist` configuration.","https://tailwindcss.com/docs/content-configuration#safelisting-classes"])}}let u=[].concat(e.tailwindConfig.darkMode??"media")[1]??"dark",c=[Fo(e,u),Fo(e,"group"),Fo(e,"peer")];e.getClassOrder=function(d){let p=[...d].sort((y,w)=>y===w?0:y[y,null])),b=Ln(new Set(p),e,!0);b=e.offsets.sort(b);let v=BigInt(c.length);for(let[,y]of b){let w=y.raws.tailwind.candidate;g.set(w,g.get(w)??v++)}return d.map(y=>{let w=g.get(y)??null,k=c.indexOf(y);return w===null&&k!==-1&&(w=BigInt(k)),[y,w]})},e.getClassList=function(d={}){let p=[];for(let g of s)if(Array.isArray(g)){let[b,v]=g,y=[],w=Object.keys(v?.modifiers??{});v?.types?.some(({type:O})=>O==="color")&&w.push(...Object.keys(e.tailwindConfig.theme.opacity??{}));let k={modifiers:w},C=d.includeMetadata&&w.length>0;for(let[O,_]of Object.entries(v?.values??{})){if(_==null)continue;let I=ai(b,O);if(p.push(C?[I,k]:I),v?.supportsNegativeValues&&et(_)){let B=ai(b,`-${O}`);y.push(C?[B,k]:B)}}p.push(...y)}else p.push(g);return p},e.getVariants=function(){let d=[];for(let[p,g]of e.variantOptions.entries())g.variantInfo!==qo.Base&&d.push({name:p,isArbitrary:g.type===Symbol.for("MATCH_VARIANT"),values:Object.keys(g.values??{}),hasDash:p!=="@",selectors({modifier:b,value:v}={}){let y="__TAILWIND_PLACEHOLDER__",w=U.rule({selector:`.${y}`}),k=U.root({nodes:[w.clone()]}),C=k.toString(),O=(e.variantMap.get(p)??[]).flatMap(([z,fe])=>fe),_=[];for(let z of O){let fe=[],Ci={args:{modifier:b,value:g.values?.[v]??v},separator:e.tailwindConfig.separator,modifySelectors(Oe){return k.each(ws=>{ws.type==="rule"&&(ws.selectors=ws.selectors.map(Yu=>Oe({get className(){return _o(Yu)},selector:Yu})))}),k},format(Oe){fe.push(Oe)},wrap(Oe){fe.push(`@${Oe.name} ${Oe.params} { & }`)},container:k},Ai=z(Ci);if(fe.length>0&&_.push(fe),Array.isArray(Ai))for(let Oe of Ai)fe=[],Oe(Ci),_.push(fe)}let I=[],B=k.toString();C!==B&&(k.walkRules(z=>{let fe=z.selector,Ci=(0,Io.default)(Ai=>{Ai.walkClasses(Oe=>{Oe.value=`${p}${e.tailwindConfig.separator}${Oe.value}`})}).processSync(fe);I.push(fe.replace(Ci,"&").replace(y,"&"))}),k.walkAtRules(z=>{I.push(`@${z.name} (${z.params}) { & }`)}));let R=!(v in(g.values??{})),X=g[oi]??{},ue=(()=>!(R||X.respectPrefix===!1))();_=_.map(z=>z.map(fe=>({format:fe,respectPrefix:ue}))),I=I.map(z=>({format:z,respectPrefix:ue}));let pe={candidate:y,context:e},Ue=_.map(z=>qn(`.${y}`,Yt(z,pe),pe).replace(`.${y}`,"&").replace("{ & }","").trim());return I.length>0&&Ue.push(Yt(I,pe).toString().replace(`.${y}`,"&")),Ue}});return d}}function Ph(r,e){!r.classCache.has(e)||(r.notClassCache.add(e),r.classCache.delete(e),r.applyClassCache.delete(e),r.candidateRuleMap.delete(e),r.candidateRuleCache.delete(e),r.stylesheetCache=null)}function O3(r,e){let t=e.raws.tailwind.candidate;if(!!t){for(let i of r.ruleCache)i[1].raws.tailwind.candidate===t&&r.ruleCache.delete(i);Ph(r,t)}}function Mo(r,e=[],t=U.root()){let i={disposables:[],ruleCache:new Set,candidateRuleCache:new Map,classCache:new Map,applyClassCache:new Map,notClassCache:new Set(r.blocklist??[]),postCssNodeCache:new Map,candidateRuleMap:new Map,tailwindConfig:r,changedContent:e,variantMap:new Map,stylesheetCache:null,variantOptions:new Map,markInvalidUtilityCandidate:s=>Ph(i,s),markInvalidUtilityNode:s=>O3(i,s)},n=C3(i,t);return A3(n,i),i}function Dh(r,e,t,i,n,s){let a=e.opts.from,o=i!==null;De.DEBUG&&console.log("Source path:",a);let u;if(o&&Qt.has(a))u=Qt.get(a);else if(ci.has(n)){let p=ci.get(n);ft.get(p).add(a),Qt.set(a,p),u=p}let c=bh(a,r);if(u){let[p,g]=Eh([...s],Un(u));if(!p&&!c)return[u,!1,g]}if(Qt.has(a)){let p=Qt.get(a);if(ft.has(p)&&(ft.get(p).delete(a),ft.get(p).size===0)){ft.delete(p);for(let[g,b]of ci)b===p&&ci.delete(g);for(let g of p.disposables.splice(0))g(p)}}De.DEBUG&&console.log("Setting up new context...");let f=Mo(t,[],r);Object.assign(f,{userConfigPath:i});let[,d]=Eh([...s],Un(f));return ci.set(n,f),Qt.set(a,f),ft.has(f)||ft.set(f,new Set),ft.get(f).add(a),[f,!0,d]}var Ah,Io,oi,Ro,qo,Bo,Qt,ci,ft,li=S(()=>{l();Ve();$s();at();Ah=J(oa()),Io=J(Fe());ni();fo();An();Pt();Wt();po();vr();sh();ut();ut();Ti();Ee();Ei();wo();$n();wh();Ch();We();Co();oi=Symbol(),Ro={AddVariant:Symbol.for("ADD_VARIANT"),MatchVariant:Symbol.for("MATCH_VARIANT")},qo={Base:1<<0,Dynamic:1<<1};Bo=new WeakMap;Qt=ah,ci=oh,ft=In});function Lo(r){return r.ignore?[]:r.glob?h.env.ROLLUP_WATCH==="true"?[{type:"dependency",file:r.base}]:[{type:"dir-dependency",dir:r.base,glob:r.glob}]:[{type:"dependency",file:r.base}]}var Ih=S(()=>{l()});function Rh(r,e){return{handler:r,config:e}}var qh,Fh=S(()=>{l();Rh.withOptions=function(r,e=()=>({})){let t=function(i){return{__options:i,handler:r(i),config:e(i)}};return t.__isOptionsFunction=!0,t.__pluginFunction=r,t.__configFunction=e,t};qh=Rh});var $o={};_e($o,{default:()=>_3});var _3,No=S(()=>{l();Fh();_3=qh});var Mh=x((WD,Bh)=>{l();var E3=(No(),$o).default,T3={overflow:"hidden",display:"-webkit-box","-webkit-box-orient":"vertical"},P3=E3(function({matchUtilities:r,addUtilities:e,theme:t,variants:i}){let n=t("lineClamp");r({"line-clamp":s=>({...T3,"-webkit-line-clamp":`${s}`})},{values:n}),e([{".line-clamp-none":{"-webkit-line-clamp":"unset"}}],i("lineClamp"))},{theme:{lineClamp:{1:"1",2:"2",3:"3",4:"4",5:"5",6:"6"}},variants:{lineClamp:["responsive"]}});Bh.exports=P3});function zo(r){r.content.files.length===0&&M.warn("content-problems",["The `content` option in your Tailwind CSS configuration is missing or empty.","Configure your content sources or your generated CSS will be missing styles.","https://tailwindcss.com/docs/content-configuration"]);try{let e=Mh();r.plugins.includes(e)&&(M.warn("line-clamp-in-core",["As of Tailwind CSS v3.3, the `@tailwindcss/line-clamp` plugin is now included by default.","Remove it from the `plugins` array in your configuration to eliminate this warning."]),r.plugins=r.plugins.filter(t=>t!==e))}catch{}return r}var Lh=S(()=>{l();Ee()});var $h,Nh=S(()=>{l();$h=()=>!1});var Vn,zh=S(()=>{l();Vn={sync:r=>[].concat(r),generateTasks:r=>[{dynamic:!1,base:".",negative:[],positive:[].concat(r),patterns:[].concat(r)}],escapePath:r=>r}});var jo,jh=S(()=>{l();jo=r=>r});var Uh,Vh=S(()=>{l();Uh=()=>""});function Wh(r){let e=r,t=Uh(r);return t!=="."&&(e=r.substr(t.length),e.charAt(0)==="/"&&(e=e.substr(1))),e.substr(0,2)==="./"&&(e=e.substr(2)),e.charAt(0)==="/"&&(e=e.substr(1)),{base:t,glob:e}}var Gh=S(()=>{l();Vh()});function Hh(r,e){let t=e.content.files;t=t.filter(o=>typeof o=="string"),t=t.map(jo);let i=Vn.generateTasks(t),n=[],s=[];for(let o of i)n.push(...o.positive.map(u=>Yh(u,!1))),s.push(...o.negative.map(u=>Yh(u,!0)));let a=[...n,...s];return a=I3(r,a),a=a.flatMap(R3),a=a.map(D3),a}function Yh(r,e){let t={original:r,base:r,ignore:e,pattern:r,glob:null};return $h(r)&&Object.assign(t,Wh(r)),t}function D3(r){let e=jo(r.base);return e=Vn.escapePath(e),r.pattern=r.glob?`${e}/${r.glob}`:e,r.pattern=r.ignore?`!${r.pattern}`:r.pattern,r}function I3(r,e){let t=[];return r.userConfigPath&&r.tailwindConfig.content.relative&&(t=[ee.dirname(r.userConfigPath)]),e.map(i=>(i.base=ee.resolve(...t,i.base),i))}function R3(r){let e=[r];try{let t=re.realpathSync(r.base);t!==r.base&&e.push({...r,base:t})}catch{}return e}function Qh(r,e,t){let i=r.tailwindConfig.content.files.filter(a=>typeof a.raw=="string").map(({raw:a,extension:o="html"})=>({content:a,extension:o})),[n,s]=q3(e,t);for(let a of n){let o=ee.extname(a).slice(1);i.push({file:a,extension:o})}return[i,s]}function q3(r,e){let t=r.map(a=>a.pattern),i=new Map,n=new Set;De.DEBUG&&console.time("Finding changed files");let s=Vn.sync(t,{absolute:!0});for(let a of s){let o=e.get(a)||-1/0,u=re.statSync(a).mtimeMs;u>o&&(n.add(a),i.set(a,u))}return De.DEBUG&&console.timeEnd("Finding changed files"),[n,i]}var Jh=S(()=>{l();Ve();St();Nh();zh();jh();Gh();ut()});function Xh(){}var Kh=S(()=>{l()});function L3(r,e){for(let t of e){let i=`${r}${t}`;if(re.existsSync(i)&&re.statSync(i).isFile())return i}for(let t of e){let i=`${r}/index${t}`;if(re.existsSync(i))return i}return null}function*Zh(r,e,t,i=ee.extname(r)){let n=L3(ee.resolve(e,r),F3.includes(i)?B3:M3);if(n===null||t.has(n))return;t.add(n),yield n,e=ee.dirname(n),i=ee.extname(n);let s=re.readFileSync(n,"utf-8");for(let a of[...s.matchAll(/import[\s\S]*?['"](.{3,}?)['"]/gi),...s.matchAll(/import[\s\S]*from[\s\S]*?['"](.{3,}?)['"]/gi),...s.matchAll(/require\(['"`](.+)['"`]\)/gi)])!a[1].startsWith(".")||(yield*Zh(a[1],e,t,i))}function Uo(r){return r===null?new Set:new Set(Zh(r,ee.dirname(r),new Set))}var F3,B3,M3,em=S(()=>{l();Ve();St();F3=[".js",".cjs",".mjs"],B3=["",".js",".cjs",".mjs",".ts",".cts",".mts",".jsx",".tsx"],M3=["",".ts",".cts",".mts",".tsx",".js",".cjs",".mjs",".jsx"]});function $3(r,e){if(Vo.has(r))return Vo.get(r);let t=Hh(r,e);return Vo.set(r,t).get(r)}function N3(r){let e=Ms(r);if(e!==null){let[i,n,s,a]=rm.get(e)||[],o=Uo(e),u=!1,c=new Map;for(let p of o){let g=re.statSync(p).mtimeMs;c.set(p,g),(!a||!a.has(p)||g>a.get(p))&&(u=!0)}if(!u)return[i,e,n,s];for(let p of o)delete Ju.cache[p];let f=zo(Mi(Xh(e))),d=_i(f);return rm.set(e,[f,d,o,c]),[f,e,d,o]}let t=Mi(r?.config??r??{});return t=zo(t),[t,null,_i(t),[]]}function Wo(r){return({tailwindDirectives:e,registerDependency:t})=>(i,n)=>{let[s,a,o,u]=N3(r),c=new Set(u);if(e.size>0){c.add(n.opts.from);for(let b of n.messages)b.type==="dependency"&&c.add(b.file)}let[f,,d]=Dh(i,n,s,a,o,c),p=Un(f),g=$3(f,s);if(e.size>0){for(let y of g)for(let w of Lo(y))t(w);let[b,v]=Qh(f,g,p);for(let y of b)f.changedContent.push(y);for(let[y,w]of v.entries())d.set(y,w)}for(let b of u)t({type:"dependency",file:b});for(let[b,v]of d.entries())p.set(b,v);return f}}var tm,rm,Vo,im=S(()=>{l();Ve();tm=J(xs());tf();Wf();Yf();li();Ih();Lh();Jh();Kh();em();rm=new tm.default({maxSize:100}),Vo=new WeakMap});function Go(r){let e=new Set,t=new Set,i=new Set;if(r.walkAtRules(n=>{n.name==="apply"&&i.add(n),n.name==="import"&&(n.params==='"tailwindcss/base"'||n.params==="'tailwindcss/base'"?(n.name="tailwind",n.params="base"):n.params==='"tailwindcss/components"'||n.params==="'tailwindcss/components'"?(n.name="tailwind",n.params="components"):n.params==='"tailwindcss/utilities"'||n.params==="'tailwindcss/utilities'"?(n.name="tailwind",n.params="utilities"):(n.params==='"tailwindcss/screens"'||n.params==="'tailwindcss/screens'"||n.params==='"tailwindcss/variants"'||n.params==="'tailwindcss/variants'")&&(n.name="tailwind",n.params="variants")),n.name==="tailwind"&&(n.params==="screens"&&(n.params="variants"),e.add(n.params)),["layer","responsive","variants"].includes(n.name)&&(["responsive","variants"].includes(n.name)&&M.warn(`${n.name}-at-rule-deprecated`,[`The \`@${n.name}\` directive has been deprecated in Tailwind CSS v3.0.`,"Use `@layer utilities` or `@layer components` instead.","https://tailwindcss.com/docs/upgrade-guide#replace-variants-with-layer"]),t.add(n))}),!e.has("base")||!e.has("components")||!e.has("utilities")){for(let n of t)if(n.name==="layer"&&["base","components","utilities"].includes(n.params)){if(!e.has(n.params))throw n.error(`\`@layer ${n.params}\` is used but no matching \`@tailwind ${n.params}\` directive is present.`)}else if(n.name==="responsive"){if(!e.has("utilities"))throw n.error("`@responsive` is used but `@tailwind utilities` is missing.")}else if(n.name==="variants"&&!e.has("utilities"))throw n.error("`@variants` is used but `@tailwind utilities` is missing.")}return{tailwindDirectives:e,applyDirectives:i}}var nm=S(()=>{l();Ee()});function _t(r,e=void 0,t=void 0){return r.map(i=>{let n=i.clone();return t!==void 0&&(n.raws.tailwind={...n.raws.tailwind,...t}),e!==void 0&&sm(n,s=>{if(s.raws.tailwind?.preserveSource===!0&&s.source)return!1;s.source=e}),n})}function sm(r,e){e(r)!==!1&&r.each?.(t=>sm(t,e))}var am=S(()=>{l()});function Ho(r){return r=Array.isArray(r)?r:[r],r=r.map(e=>e instanceof RegExp?e.source:e),r.join("")}function be(r){return new RegExp(Ho(r),"g")}function ct(r){return`(?:${r.map(Ho).join("|")})`}function Yo(r){return`(?:${Ho(r)})?`}function lm(r){return r&&z3.test(r)?r.replace(om,"\\$&"):r||""}var om,z3,um=S(()=>{l();om=/[\\^$.*+?()[\]{}|]/g,z3=RegExp(om.source)});function fm(r){let e=Array.from(j3(r));return t=>{let i=[];for(let n of e)for(let s of t.match(n)??[])i.push(W3(s));return i}}function*j3(r){let e=r.tailwindConfig.separator,t=r.tailwindConfig.prefix!==""?Yo(be([/-?/,lm(r.tailwindConfig.prefix)])):"",i=ct([/\[[^\s:'"`]+:[^\s\[\]]+\]/,/\[[^\s:'"`\]]+:[^\s]+?\[[^\s]+\][^\s]+?\]/,be([ct([/-?(?:\w+)/,/@(?:\w+)/]),Yo(ct([be([ct([/-(?:\w+-)*\['[^\s]+'\]/,/-(?:\w+-)*\["[^\s]+"\]/,/-(?:\w+-)*\[`[^\s]+`\]/,/-(?:\w+-)*\[(?:[^\s\[\]]+\[[^\s\[\]]+\])*[^\s:\[\]]+\]/]),/(?![{([]])/,/(?:\/[^\s'"`\\><$]*)?/]),be([ct([/-(?:\w+-)*\['[^\s]+'\]/,/-(?:\w+-)*\["[^\s]+"\]/,/-(?:\w+-)*\[`[^\s]+`\]/,/-(?:\w+-)*\[(?:[^\s\[\]]+\[[^\s\[\]]+\])*[^\s\[\]]+\]/]),/(?![{([]])/,/(?:\/[^\s'"`\\$]*)?/]),/[-\/][^\s'"`\\$={><]*/]))])]),n=[ct([be([/@\[[^\s"'`]+\](\/[^\s"'`]+)?/,e]),be([/([^\s"'`\[\\]+-)?\[[^\s"'`]+\]\/\w+/,e]),be([/([^\s"'`\[\\]+-)?\[[^\s"'`]+\]/,e]),be([/[^\s"'`\[\\]+/,e])]),ct([be([/([^\s"'`\[\\]+-)?\[[^\s`]+\]\/\w+/,e]),be([/([^\s"'`\[\\]+-)?\[[^\s`]+\]/,e]),be([/[^\s`\[\\]+/,e])])];for(let s of n)yield be(["((?=((",s,")+))\\2)?",/!?/,t,i]);yield/[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g}function W3(r){if(!r.includes("-["))return r;let e=0,t=[],i=r.matchAll(U3);i=Array.from(i).flatMap(n=>{let[,...s]=n;return s.map((a,o)=>Object.assign([],n,{index:n.index+o,0:a}))});for(let n of i){let s=n[0],a=t[t.length-1];if(s===a?t.pop():(s==="'"||s==='"'||s==="`")&&t.push(s),!a){if(s==="["){e++;continue}else if(s==="]"){e--;continue}if(e<0)return r.substring(0,n.index-1);if(e===0&&!V3.test(s))return r.substring(0,n.index)}}return r}var U3,V3,cm=S(()=>{l();um();U3=/([\[\]'"`])([^\[\]'"`])?/g,V3=/[^"'`\s<>\]]+/});function G3(r,e){let t=r.tailwindConfig.content.extract;return t[e]||t.DEFAULT||dm[e]||dm.DEFAULT(r)}function H3(r,e){let t=r.content.transform;return t[e]||t.DEFAULT||hm[e]||hm.DEFAULT}function Y3(r,e,t,i){pi.has(e)||pi.set(e,new pm.default({maxSize:25e3}));for(let n of r.split(`
+`))if(n=n.trim(),!i.has(n))if(i.add(n),pi.get(e).has(n))for(let s of pi.get(e).get(n))t.add(s);else{let s=e(n).filter(o=>o!=="!*"),a=new Set(s);for(let o of a)t.add(o);pi.get(e).set(n,a)}}function Q3(r,e){let t=e.offsets.sort(r),i={base:new Set,defaults:new Set,components:new Set,utilities:new Set,variants:new Set};for(let[n,s]of t)i[n.layer].add(s);return i}function Qo(r){return async e=>{let t={base:null,components:null,utilities:null,variants:null};if(e.walkAtRules(b=>{b.name==="tailwind"&&Object.keys(t).includes(b.params)&&(t[b.params]=b)}),Object.values(t).every(b=>b===null))return e;let i=new Set([...r.candidates??[],Je]),n=new Set;Xe.DEBUG&&console.time("Reading changed files");{let b=[];for(let y of r.changedContent){let w=H3(r.tailwindConfig,y.extension),k=G3(r,y.extension);b.push([y,{transformer:w,extractor:k}])}let v=500;for(let y=0;y{C=k?await re.promises.readFile(k,"utf8"):C,Y3(O(C),_,i,n)}))}}Xe.DEBUG&&console.timeEnd("Reading changed files");let s=r.classCache.size;Xe.DEBUG&&console.time("Generate rules"),Xe.DEBUG&&console.time("Sorting candidates");let a=new Set([...i].sort((b,v)=>b===v?0:b{let v=b.raws.tailwind?.parentLayer;return v==="components"?t.components!==null:v==="utilities"?t.utilities!==null:!0});t.variants?(t.variants.before(_t(p,t.variants.source,{layer:"variants"})),t.variants.remove()):p.length>0&&e.append(_t(p,e.source,{layer:"variants"})),e.source.end=e.source.end??e.source.start;let g=p.some(b=>b.raws.tailwind?.parentLayer==="utilities");t.utilities&&f.size===0&&!g&&M.warn("content-problems",["No utility classes were detected in your source files. If this is unexpected, double-check the `content` option in your Tailwind CSS configuration.","https://tailwindcss.com/docs/content-configuration"]),Xe.DEBUG&&(console.log("Potential classes: ",i.size),console.log("Active contexts: ",In.size)),r.changedContent=[],e.walkAtRules("layer",b=>{Object.keys(t).includes(b.params)&&b.remove()})}}var pm,Xe,dm,hm,pi,mm=S(()=>{l();Ve();pm=J(xs());ut();$n();Ee();am();cm();Xe=De,dm={DEFAULT:fm},hm={DEFAULT:r=>r,svelte:r=>r.replace(/(?:^|\s)class:/g," ")};pi=new WeakMap});function Gn(r){let e=new Map;U.root({nodes:[r.clone()]}).walkRules(s=>{(0,Wn.default)(a=>{a.walkClasses(o=>{let u=o.parent.toString(),c=e.get(u);c||e.set(u,c=new Set),c.add(o.value)})}).processSync(s.selector)});let i=Array.from(e.values(),s=>Array.from(s)),n=i.flat();return Object.assign(n,{groups:i})}function Jo(r){return J3.astSync(r)}function gm(r,e){let t=new Set;for(let i of r)t.add(i.split(e).pop());return Array.from(t)}function ym(r,e){let t=r.tailwindConfig.prefix;return typeof t=="function"?t(e):t+e}function*bm(r){for(yield r;r.parent;)yield r.parent,r=r.parent}function X3(r,e={}){let t=r.nodes;r.nodes=[];let i=r.clone(e);return r.nodes=t,i}function K3(r){for(let e of bm(r))if(r!==e){if(e.type==="root")break;r=X3(e,{nodes:[r]})}return r}function Z3(r,e){let t=new Map;return r.walkRules(i=>{for(let a of bm(i))if(a.raws.tailwind?.layer!==void 0)return;let n=K3(i),s=e.offsets.create("user");for(let a of Gn(i)){let o=t.get(a)||[];t.set(a,o),o.push([{layer:"user",sort:s,important:!1},n])}}),t}function eC(r,e){for(let t of r){if(e.notClassCache.has(t)||e.applyClassCache.has(t))continue;if(e.classCache.has(t)){e.applyClassCache.set(t,e.classCache.get(t).map(([n,s])=>[n,s.clone()]));continue}let i=Array.from(To(t,e));if(i.length===0){e.notClassCache.add(t);continue}e.applyClassCache.set(t,i)}return e.applyClassCache}function tC(r){let e=null;return{get:t=>(e=e||r(),e.get(t)),has:t=>(e=e||r(),e.has(t))}}function rC(r){return{get:e=>r.flatMap(t=>t.get(e)||[]),has:e=>r.some(t=>t.has(e))}}function wm(r){let e=r.split(/[\s\t\n]+/g);return e[e.length-1]==="!important"?[e.slice(0,-1),!0]:[e,!1]}function xm(r,e,t){let i=new Set,n=[];if(r.walkAtRules("apply",u=>{let[c]=wm(u.params);for(let f of c)i.add(f);n.push(u)}),n.length===0)return;let s=rC([t,eC(i,e)]);function a(u,c,f){let d=Jo(u),p=Jo(c),b=Jo(`.${he(f)}`).nodes[0].nodes[0];return d.each(v=>{let y=new Set;p.each(w=>{let k=!1;w=w.clone(),w.walkClasses(C=>{C.value===b.value&&(k||(C.replaceWith(...v.nodes.map(O=>O.clone())),y.add(w),k=!0))})});for(let w of y){let k=[[]];for(let C of w.nodes)C.type==="combinator"?(k.push(C),k.push([])):k[k.length-1].push(C);w.nodes=[];for(let C of k)Array.isArray(C)&&C.sort((O,_)=>O.type==="tag"&&_.type==="class"?-1:O.type==="class"&&_.type==="tag"?1:O.type==="class"&&_.type==="pseudo"&&_.value.startsWith("::")?-1:O.type==="pseudo"&&O.value.startsWith("::")&&_.type==="class"?1:0),w.nodes=w.nodes.concat(C)}v.replaceWith(...y)}),d.toString()}let o=new Map;for(let u of n){let[c]=o.get(u.parent)||[[],u.source];o.set(u.parent,[c,u.source]);let[f,d]=wm(u.params);if(u.parent.type==="atrule"){if(u.parent.name==="screen"){let p=u.parent.params;throw u.error(`@apply is not supported within nested at-rules like @screen. We suggest you write this as @apply ${f.map(g=>`${p}:${g}`).join(" ")} instead.`)}throw u.error(`@apply is not supported within nested at-rules like @${u.parent.name}. You can fix this by un-nesting @${u.parent.name}.`)}for(let p of f){if([ym(e,"group"),ym(e,"peer")].includes(p))throw u.error(`@apply should not be used with the '${p}' utility`);if(!s.has(p))throw u.error(`The \`${p}\` class does not exist. If \`${p}\` is a custom class, make sure it is defined within a \`@layer\` directive.`);let g=s.get(p);c.push([p,d,g])}}for(let[u,[c,f]]of o){let d=[];for(let[g,b,v]of c){let y=[g,...gm([g],e.tailwindConfig.separator)];for(let[w,k]of v){let C=Gn(u),O=Gn(k);if(O=O.groups.filter(R=>R.some(X=>y.includes(X))).flat(),O=O.concat(gm(O,e.tailwindConfig.separator)),C.some(R=>O.includes(R)))throw k.error(`You cannot \`@apply\` the \`${g}\` utility here because it creates a circular dependency.`);let I=U.root({nodes:[k.clone()]});I.walk(R=>{R.source=f}),(k.type!=="atrule"||k.type==="atrule"&&k.name!=="keyframes")&&I.walkRules(R=>{if(!Gn(R).some(z=>z===g)){R.remove();return}let X=typeof e.tailwindConfig.important=="string"?e.tailwindConfig.important:null,pe=u.raws.tailwind!==void 0&&X&&u.selector.indexOf(X)===0?u.selector.slice(X.length):u.selector;pe===""&&(pe=u.selector),R.selector=a(pe,R.selector,g),X&&pe!==u.selector&&(R.selector=Fn(R.selector,X)),R.walkDecls(z=>{z.important=w.important||b});let Ue=(0,Wn.default)().astSync(R.selector);Ue.each(z=>Ht(z)),R.selector=Ue.toString()}),!!I.nodes[0]&&d.push([w.sort,I.nodes[0]])}}let p=e.offsets.sort(d).map(g=>g[1]);u.after(p)}for(let u of n)u.parent.nodes.length>1?u.remove():u.parent.remove();xm(r,e,t)}function Xo(r){return e=>{let t=tC(()=>Z3(e,r));xm(e,r,t)}}var Wn,J3,vm=S(()=>{l();at();Wn=J(Fe());$n();Wt();Oo();Rn();J3=(0,Wn.default)()});var km=x((j9,Hn)=>{l();(function(){"use strict";function r(i,n,s){if(!i)return null;r.caseSensitive||(i=i.toLowerCase());var a=r.threshold===null?null:r.threshold*i.length,o=r.thresholdAbsolute,u;a!==null&&o!==null?u=Math.min(a,o):a!==null?u=a:o!==null?u=o:u=null;var c,f,d,p,g,b=n.length;for(g=0;gs)return s+1;var u=[],c,f,d,p,g;for(c=0;c<=o;c++)u[c]=[c];for(f=0;f<=a;f++)u[0][f]=f;for(c=1;c<=o;c++){for(d=e,p=1,c>s&&(p=c-s),g=o+1,g>s+c&&(g=s+c),f=1;f<=a;f++)f g?u[c][f]=s+1:n.charAt(c-1)===i.charAt(f-1)?u[c][f]=u[c-1][f-1]:u[c][f]=Math.min(u[c-1][f-1]+1,Math.min(u[c][f-1]+1,u[c-1][f]+1)),u[c][f]s)return s+1}return u[o][a]}})()});var Cm=x((U9,Sm)=>{l();var Ko="(".charCodeAt(0),Zo=")".charCodeAt(0),Yn="'".charCodeAt(0),el='"'.charCodeAt(0),tl="\\".charCodeAt(0),Jt="/".charCodeAt(0),rl=",".charCodeAt(0),il=":".charCodeAt(0),Qn="*".charCodeAt(0),iC="u".charCodeAt(0),nC="U".charCodeAt(0),sC="+".charCodeAt(0),aC=/^[a-f0-9?-]+$/i;Sm.exports=function(r){for(var e=[],t=r,i,n,s,a,o,u,c,f,d=0,p=t.charCodeAt(d),g=t.length,b=[{nodes:e}],v=0,y,w="",k="",C="";d{l();Am.exports=function r(e,t,i){var n,s,a,o;for(n=0,s=e.length;n{l();function _m(r,e){var t=r.type,i=r.value,n,s;return e&&(s=e(r))!==void 0?s:t==="word"||t==="space"?i:t==="string"?(n=r.quote||"",n+i+(r.unclosed?"":n)):t==="comment"?"/*"+i+(r.unclosed?"":"*/"):t==="div"?(r.before||"")+i+(r.after||""):Array.isArray(r.nodes)?(n=Em(r.nodes,e),t!=="function"?n:i+"("+(r.before||"")+n+(r.after||"")+(r.unclosed?"":")")):i}function Em(r,e){var t,i;if(Array.isArray(r)){for(t="",i=r.length-1;~i;i-=1)t=_m(r[i],e)+t;return t}return _m(r,e)}Tm.exports=Em});var Im=x((G9,Dm)=>{l();var Jn="-".charCodeAt(0),Xn="+".charCodeAt(0),nl=".".charCodeAt(0),oC="e".charCodeAt(0),lC="E".charCodeAt(0);function uC(r){var e=r.charCodeAt(0),t;if(e===Xn||e===Jn){if(t=r.charCodeAt(1),t>=48&&t<=57)return!0;var i=r.charCodeAt(2);return t===nl&&i>=48&&i<=57}return e===nl?(t=r.charCodeAt(1),t>=48&&t<=57):e>=48&&e<=57}Dm.exports=function(r){var e=0,t=r.length,i,n,s;if(t===0||!uC(r))return!1;for(i=r.charCodeAt(e),(i===Xn||i===Jn)&&e++;e57));)e+=1;if(i=r.charCodeAt(e),n=r.charCodeAt(e+1),i===nl&&n>=48&&n<=57)for(e+=2;e57));)e+=1;if(i=r.charCodeAt(e),n=r.charCodeAt(e+1),s=r.charCodeAt(e+2),(i===oC||i===lC)&&(n>=48&&n<=57||(n===Xn||n===Jn)&&s>=48&&s<=57))for(e+=n===Xn||n===Jn?3:2;e57));)e+=1;return{number:r.slice(0,e),unit:r.slice(e)}}});var Bm=x((H9,Fm)=>{l();var fC=Cm(),Rm=Om(),qm=Pm();function pt(r){return this instanceof pt?(this.nodes=fC(r),this):new pt(r)}pt.prototype.toString=function(){return Array.isArray(this.nodes)?qm(this.nodes):""};pt.prototype.walk=function(r,e){return Rm(this.nodes,r,e),this};pt.unit=Im();pt.walk=Rm;pt.stringify=qm;Fm.exports=pt});function al(r){return typeof r=="object"&&r!==null}function cC(r,e){let t=tt(e);do if(t.pop(),(0,di.default)(r,t)!==void 0)break;while(t.length);return t.length?t:void 0}function Xt(r){return typeof r=="string"?r:r.reduce((e,t,i)=>t.includes(".")?`${e}[${t}]`:i===0?t:`${e}.${t}`,"")}function Lm(r){return r.map(e=>`'${e}'`).join(", ")}function $m(r){return Lm(Object.keys(r))}function ol(r,e,t,i={}){let n=Array.isArray(e)?Xt(e):e.replace(/^['"]+|['"]+$/g,""),s=Array.isArray(e)?e:tt(n),a=(0,di.default)(r.theme,s,t);if(a===void 0){let u=`'${n}' does not exist in your theme config.`,c=s.slice(0,-1),f=(0,di.default)(r.theme,c);if(al(f)){let d=Object.keys(f).filter(g=>ol(r,[...c,g]).isValid),p=(0,Mm.default)(s[s.length-1],d);p?u+=` Did you mean '${Xt([...c,p])}'?`:d.length>0&&(u+=` '${Xt(c)}' has the following valid keys: ${Lm(d)}`)}else{let d=cC(r.theme,n);if(d){let p=(0,di.default)(r.theme,d);al(p)?u+=` '${Xt(d)}' has the following keys: ${$m(p)}`:u+=` '${Xt(d)}' is not an object.`}else u+=` Your theme has the following top-level keys: ${$m(r.theme)}`}return{isValid:!1,error:u}}if(!(typeof a=="string"||typeof a=="number"||typeof a=="function"||a instanceof String||a instanceof Number||Array.isArray(a))){let u=`'${n}' was found but does not resolve to a string.`;if(al(a)){let c=Object.keys(a).filter(f=>ol(r,[...s,f]).isValid);c.length&&(u+=` Did you mean something like '${Xt([...s,c[0]])}'?`)}return{isValid:!1,error:u}}let[o]=s;return{isValid:!0,value:Qe(o)(a,i)}}function pC(r,e,t){e=e.map(n=>Nm(r,n,t));let i=[""];for(let n of e)n.type==="div"&&n.value===","?i.push(""):i[i.length-1]+=sl.default.stringify(n);return i}function Nm(r,e,t){if(e.type==="function"&&t[e.value]!==void 0){let i=pC(r,e.nodes,t);e.type="word",e.value=t[e.value](r,...i)}return e}function dC(r,e,t){return Object.keys(t).some(n=>e.includes(`${n}(`))?(0,sl.default)(e).walk(n=>{Nm(r,n,t)}).toString():e}function*mC(r){r=r.replace(/^['"]+|['"]+$/g,"");let e=r.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]+))$/),t;yield[r,void 0],e&&(r=e[1],t=e[2],yield[r,t])}function gC(r,e,t){let i=Array.from(mC(e)).map(([n,s])=>Object.assign(ol(r,n,t,{opacityValue:s}),{resolvedPath:n,alpha:s}));return i.find(n=>n.isValid)??i[0]}function zm(r){let e=r.tailwindConfig,t={theme:(i,n,...s)=>{let{isValid:a,value:o,error:u,alpha:c}=gC(e,n,s.length?s:void 0);if(!a){let p=i.parent,g=p?.raws.tailwind?.candidate;if(p&&g!==void 0){r.markInvalidUtilityNode(p),p.remove(),M.warn("invalid-theme-key-in-class",[`The utility \`${g}\` contains an invalid theme value and was not generated.`]);return}throw i.error(u)}let f=It(o),d=f!==void 0&&typeof f=="function";return(c!==void 0||d)&&(c===void 0&&(c=1),o=Ie(f,c,f)),o},screen:(i,n)=>{n=n.replace(/^['"]+/g,"").replace(/['"]+$/g,"");let a=lt(e.theme.screens).find(({name:o})=>o===n);if(!a)throw i.error(`The '${n}' screen does not exist in your theme.`);return ot(a)}};return i=>{i.walk(n=>{let s=hC[n.type];s!==void 0&&(n[s]=dC(n,n[s],t))})}}var di,Mm,sl,hC,jm=S(()=>{l();di=J(oa()),Mm=J(km());ni();sl=J(Bm());Pn();_n();Ti();yr();vr();Ee();hC={atrule:"params",decl:"value"}});function Um({tailwindConfig:{theme:r}}){return function(e){e.walkAtRules("screen",t=>{let i=t.params,s=lt(r.screens).find(({name:a})=>a===i);if(!s)throw t.error(`No \`${i}\` screen found.`);t.name="media",t.params=ot(s)})}}var Vm=S(()=>{l();Pn();_n()});function yC(r){let e=r.filter(o=>o.type!=="pseudo"||o.nodes.length>0?!0:o.value.startsWith("::")||[":before",":after",":first-line",":first-letter"].includes(o.value)).reverse(),t=new Set(["tag","class","id","attribute"]),i=e.findIndex(o=>t.has(o.type));if(i===-1)return e.reverse().join("").trim();let n=e[i],s=Wm[n.type]?Wm[n.type](n):n;e=e.slice(0,i);let a=e.findIndex(o=>o.type==="combinator"&&o.value===">");return a!==-1&&(e.splice(0,a),e.unshift(Kn.default.universal())),[s,...e.reverse()].join("").trim()}function wC(r){return ll.has(r)||ll.set(r,bC.transformSync(r)),ll.get(r)}function ul({tailwindConfig:r}){return e=>{let t=new Map,i=new Set;if(e.walkAtRules("defaults",n=>{if(n.nodes&&n.nodes.length>0){i.add(n);return}let s=n.params;t.has(s)||t.set(s,new Set),t.get(s).add(n.parent),n.remove()}),Z(r,"optimizeUniversalDefaults"))for(let n of i){let s=new Map,a=t.get(n.params)??[];for(let o of a)for(let u of wC(o.selector)){let c=u.includes(":-")||u.includes("::-")?u:"__DEFAULT__",f=s.get(c)??new Set;s.set(c,f),f.add(u)}if(Z(r,"optimizeUniversalDefaults")){if(s.size===0){n.remove();continue}for(let[,o]of s){let u=U.rule({source:n.source});u.selectors=[...o],u.append(n.nodes.map(c=>c.clone())),n.before(u)}}n.remove()}else if(i.size){let n=U.rule({selectors:["*","::before","::after"]});for(let a of i)n.append(a.nodes),n.parent||a.before(n),n.source||(n.source=a.source),a.remove();let s=n.clone({selectors:["::backdrop"]});n.after(s)}}}var Kn,Wm,bC,ll,Gm=S(()=>{l();at();Kn=J(Fe());We();Wm={id(r){return Kn.default.attribute({attribute:"id",operator:"=",value:r.value,quoteMark:'"'})}};bC=(0,Kn.default)(r=>r.map(e=>{let t=e.split(i=>i.type==="combinator"&&i.value===" ").pop();return yC(t)})),ll=new Map});function fl(){function r(e){let t=null;e.each(i=>{if(!xC.has(i.type)){t=null;return}if(t===null){t=i;return}let n=Hm[i.type];i.type==="atrule"&&i.name==="font-face"?t=i:n.every(s=>(i[s]??"").replace(/\s+/g," ")===(t[s]??"").replace(/\s+/g," "))?(i.nodes&&t.append(i.nodes),i.remove()):t=i}),e.each(i=>{i.type==="atrule"&&r(i)})}return e=>{r(e)}}var Hm,xC,Ym=S(()=>{l();Hm={atrule:["name","params"],rule:["selector"]},xC=new Set(Object.keys(Hm))});function cl(){return r=>{r.walkRules(e=>{let t=new Map,i=new Set([]),n=new Map;e.walkDecls(s=>{if(s.parent===e){if(t.has(s.prop)){if(t.get(s.prop).value===s.value){i.add(t.get(s.prop)),t.set(s.prop,s);return}n.has(s.prop)||n.set(s.prop,new Set),n.get(s.prop).add(t.get(s.prop)),n.get(s.prop).add(s)}t.set(s.prop,s)}});for(let s of i)s.remove();for(let s of n.values()){let a=new Map;for(let o of s){let u=kC(o.value);u!==null&&(a.has(u)||a.set(u,new Set),a.get(u).add(o))}for(let o of a.values()){let u=Array.from(o).slice(0,-1);for(let c of u)c.remove()}}})}}function kC(r){let e=/^-?\d*.?\d+([\w%]+)?$/g.exec(r);return e?e[1]??vC:null}var vC,Qm=S(()=>{l();vC=Symbol("unitless-number")});function SC(r){if(!r.walkAtRules)return;let e=new Set;if(r.walkAtRules("apply",t=>{e.add(t.parent)}),e.size!==0)for(let t of e){let i=[],n=[];for(let s of t.nodes)s.type==="atrule"&&s.name==="apply"?(n.length>0&&(i.push(n),n=[]),i.push([s])):n.push(s);if(n.length>0&&i.push(n),i.length!==1){for(let s of[...i].reverse()){let a=t.clone({nodes:[]});a.append(s),t.after(a)}t.remove()}}}function Zn(){return r=>{SC(r)}}var Jm=S(()=>{l()});function CC(r){return r.type==="root"}function AC(r){return r.type==="atrule"&&r.name==="layer"}function Xm(r){return(e,t)=>{let i=!1;e.walkAtRules("tailwind",n=>{if(i)return!1;if(n.parent&&!(CC(n.parent)||AC(n.parent)))return i=!0,n.warn(t,["Nested @tailwind rules were detected, but are not supported.","Consider using a prefix to scope Tailwind's classes: https://tailwindcss.com/docs/configuration#prefix","Alternatively, use the important selector strategy: https://tailwindcss.com/docs/configuration#selector-strategy"].join(`
+`)),!1}),e.walkRules(n=>{if(i)return!1;n.walkRules(s=>(i=!0,s.warn(t,["Nested CSS was detected, but CSS nesting has not been configured correctly.","Please enable a CSS nesting plugin *before* Tailwind in your configuration.","See how here: https://tailwindcss.com/docs/using-with-preprocessors#nesting"].join(`
+`)),!1))})}}var Km=S(()=>{l()});function es(r){return async function(e,t){let{tailwindDirectives:i,applyDirectives:n}=Go(e);Xm()(e,t),Zn()(e,t);let s=r({tailwindDirectives:i,applyDirectives:n,registerDependency(a){t.messages.push({plugin:"tailwindcss",parent:t.opts.from,...a})},createContext(a,o){return Mo(a,o,e)}})(e,t);if(s.tailwindConfig.separator==="-")throw new Error("The '-' character cannot be used as a custom separator in JIT mode due to parsing ambiguity. Please use another character like '_' instead.");hf(s.tailwindConfig),await Qo(s)(e,t),Zn()(e,t),Xo(s)(e,t),zm(s)(e,t),Um(s)(e,t),ul(s)(e,t),fl(s)(e,t),cl(s)(e,t)}}var Zm=S(()=>{l();nm();mm();vm();jm();Vm();Gm();Ym();Qm();Jm();Km();li();We()});function eg(r,e){let t=null,i=null;return r.walkAtRules("config",n=>{if(i=n.source?.input.file??e.opts.from??null,i===null)throw n.error("The `@config` directive cannot be used without setting `from` in your PostCSS config.");if(t)throw n.error("Only one `@config` directive is allowed per file.");let s=n.params.match(/(['"])(.*?)\1/);if(!s)throw n.error("A path is required when using the `@config` directive.");let a=s[2];if(ee.isAbsolute(a))throw n.error("The `@config` directive cannot be used with an absolute path.");if(t=ee.resolve(ee.dirname(i),a),!re.existsSync(t))throw n.error(`The config file at "${a}" does not exist. Make sure the path is correct and the file exists.`);n.remove()}),t||null}var tg=S(()=>{l();Ve();St()});var rg=x((I8,pl)=>{l();im();Zm();ut();tg();pl.exports=function(e){return{postcssPlugin:"tailwindcss",plugins:[De.DEBUG&&function(t){return console.log(`
+`),console.time("JIT TOTAL"),t},async function(t,i){e=eg(t,i)??e;let n=Wo(e);if(t.type==="document"){let s=t.nodes.filter(a=>a.type==="root");for(let a of s)a.type==="root"&&await es(n)(a,i);return}await es(n)(t,i)},!1,De.DEBUG&&function(t){return console.timeEnd("JIT TOTAL"),console.log(`
+`),t}].filter(Boolean)}};pl.exports.postcss=!0});var ng=x((R8,ig)=>{l();ig.exports=rg()});var dl=x((q8,sg)=>{l();sg.exports=()=>["and_chr 114","and_uc 15.5","chrome 114","chrome 113","chrome 109","edge 114","firefox 114","ios_saf 16.5","ios_saf 16.4","ios_saf 16.3","ios_saf 16.1","opera 99","safari 16.5","samsung 21"]});var ts={};_e(ts,{agents:()=>OC,feature:()=>_C});function _C(){return{status:"cr",title:"CSS Feature Queries",stats:{ie:{"6":"n","7":"n","8":"n","9":"n","10":"n","11":"n","5.5":"n"},edge:{"12":"y","13":"y","14":"y","15":"y","16":"y","17":"y","18":"y","79":"y","80":"y","81":"y","83":"y","84":"y","85":"y","86":"y","87":"y","88":"y","89":"y","90":"y","91":"y","92":"y","93":"y","94":"y","95":"y","96":"y","97":"y","98":"y","99":"y","100":"y","101":"y","102":"y","103":"y","104":"y","105":"y","106":"y","107":"y","108":"y","109":"y","110":"y","111":"y","112":"y","113":"y","114":"y"},firefox:{"2":"n","3":"n","4":"n","5":"n","6":"n","7":"n","8":"n","9":"n","10":"n","11":"n","12":"n","13":"n","14":"n","15":"n","16":"n","17":"n","18":"n","19":"n","20":"n","21":"n","22":"y","23":"y","24":"y","25":"y","26":"y","27":"y","28":"y","29":"y","30":"y","31":"y","32":"y","33":"y","34":"y","35":"y","36":"y","37":"y","38":"y","39":"y","40":"y","41":"y","42":"y","43":"y","44":"y","45":"y","46":"y","47":"y","48":"y","49":"y","50":"y","51":"y","52":"y","53":"y","54":"y","55":"y","56":"y","57":"y","58":"y","59":"y","60":"y","61":"y","62":"y","63":"y","64":"y","65":"y","66":"y","67":"y","68":"y","69":"y","70":"y","71":"y","72":"y","73":"y","74":"y","75":"y","76":"y","77":"y","78":"y","79":"y","80":"y","81":"y","82":"y","83":"y","84":"y","85":"y","86":"y","87":"y","88":"y","89":"y","90":"y","91":"y","92":"y","93":"y","94":"y","95":"y","96":"y","97":"y","98":"y","99":"y","100":"y","101":"y","102":"y","103":"y","104":"y","105":"y","106":"y","107":"y","108":"y","109":"y","110":"y","111":"y","112":"y","113":"y","114":"y","115":"y","116":"y","117":"y","3.5":"n","3.6":"n"},chrome:{"4":"n","5":"n","6":"n","7":"n","8":"n","9":"n","10":"n","11":"n","12":"n","13":"n","14":"n","15":"n","16":"n","17":"n","18":"n","19":"n","20":"n","21":"n","22":"n","23":"n","24":"n","25":"n","26":"n","27":"n","28":"y","29":"y","30":"y","31":"y","32":"y","33":"y","34":"y","35":"y","36":"y","37":"y","38":"y","39":"y","40":"y","41":"y","42":"y","43":"y","44":"y","45":"y","46":"y","47":"y","48":"y","49":"y","50":"y","51":"y","52":"y","53":"y","54":"y","55":"y","56":"y","57":"y","58":"y","59":"y","60":"y","61":"y","62":"y","63":"y","64":"y","65":"y","66":"y","67":"y","68":"y","69":"y","70":"y","71":"y","72":"y","73":"y","74":"y","75":"y","76":"y","77":"y","78":"y","79":"y","80":"y","81":"y","83":"y","84":"y","85":"y","86":"y","87":"y","88":"y","89":"y","90":"y","91":"y","92":"y","93":"y","94":"y","95":"y","96":"y","97":"y","98":"y","99":"y","100":"y","101":"y","102":"y","103":"y","104":"y","105":"y","106":"y","107":"y","108":"y","109":"y","110":"y","111":"y","112":"y","113":"y","114":"y","115":"y","116":"y","117":"y"},safari:{"4":"n","5":"n","6":"n","7":"n","8":"n","9":"y","10":"y","11":"y","12":"y","13":"y","14":"y","15":"y","17":"y","9.1":"y","10.1":"y","11.1":"y","12.1":"y","13.1":"y","14.1":"y","15.1":"y","15.2-15.3":"y","15.4":"y","15.5":"y","15.6":"y","16.0":"y","16.1":"y","16.2":"y","16.3":"y","16.4":"y","16.5":"y","16.6":"y",TP:"y","3.1":"n","3.2":"n","5.1":"n","6.1":"n","7.1":"n"},opera:{"9":"n","11":"n","12":"n","15":"y","16":"y","17":"y","18":"y","19":"y","20":"y","21":"y","22":"y","23":"y","24":"y","25":"y","26":"y","27":"y","28":"y","29":"y","30":"y","31":"y","32":"y","33":"y","34":"y","35":"y","36":"y","37":"y","38":"y","39":"y","40":"y","41":"y","42":"y","43":"y","44":"y","45":"y","46":"y","47":"y","48":"y","49":"y","50":"y","51":"y","52":"y","53":"y","54":"y","55":"y","56":"y","57":"y","58":"y","60":"y","62":"y","63":"y","64":"y","65":"y","66":"y","67":"y","68":"y","69":"y","70":"y","71":"y","72":"y","73":"y","74":"y","75":"y","76":"y","77":"y","78":"y","79":"y","80":"y","81":"y","82":"y","83":"y","84":"y","85":"y","86":"y","87":"y","88":"y","89":"y","90":"y","91":"y","92":"y","93":"y","94":"y","95":"y","96":"y","97":"y","98":"y","99":"y","100":"y","12.1":"y","9.5-9.6":"n","10.0-10.1":"n","10.5":"n","10.6":"n","11.1":"n","11.5":"n","11.6":"n"},ios_saf:{"8":"n","17":"y","9.0-9.2":"y","9.3":"y","10.0-10.2":"y","10.3":"y","11.0-11.2":"y","11.3-11.4":"y","12.0-12.1":"y","12.2-12.5":"y","13.0-13.1":"y","13.2":"y","13.3":"y","13.4-13.7":"y","14.0-14.4":"y","14.5-14.8":"y","15.0-15.1":"y","15.2-15.3":"y","15.4":"y","15.5":"y","15.6":"y","16.0":"y","16.1":"y","16.2":"y","16.3":"y","16.4":"y","16.5":"y","16.6":"y","3.2":"n","4.0-4.1":"n","4.2-4.3":"n","5.0-5.1":"n","6.0-6.1":"n","7.0-7.1":"n","8.1-8.4":"n"},op_mini:{all:"y"},android:{"3":"n","4":"n","114":"y","4.4":"y","4.4.3-4.4.4":"y","2.1":"n","2.2":"n","2.3":"n","4.1":"n","4.2-4.3":"n"},bb:{"7":"n","10":"n"},op_mob:{"10":"n","11":"n","12":"n","73":"y","11.1":"n","11.5":"n","12.1":"n"},and_chr:{"114":"y"},and_ff:{"115":"y"},ie_mob:{"10":"n","11":"n"},and_uc:{"15.5":"y"},samsung:{"4":"y","20":"y","21":"y","5.0-5.4":"y","6.2-6.4":"y","7.2-7.4":"y","8.2":"y","9.2":"y","10.1":"y","11.1-11.2":"y","12.0":"y","13.0":"y","14.0":"y","15.0":"y","16.0":"y","17.0":"y","18.0":"y","19.0":"y"},and_qq:{"13.1":"y"},baidu:{"13.18":"y"},kaios:{"2.5":"y","3.0-3.1":"y"}}}}var OC,rs=S(()=>{l();OC={ie:{prefix:"ms"},edge:{prefix:"webkit",prefix_exceptions:{"12":"ms","13":"ms","14":"ms","15":"ms","16":"ms","17":"ms","18":"ms"}},firefox:{prefix:"moz"},chrome:{prefix:"webkit"},safari:{prefix:"webkit"},opera:{prefix:"webkit",prefix_exceptions:{"9":"o","11":"o","12":"o","9.5-9.6":"o","10.0-10.1":"o","10.5":"o","10.6":"o","11.1":"o","11.5":"o","11.6":"o","12.1":"o"}},ios_saf:{prefix:"webkit"},op_mini:{prefix:"o"},android:{prefix:"webkit"},bb:{prefix:"webkit"},op_mob:{prefix:"o",prefix_exceptions:{"73":"webkit"}},and_chr:{prefix:"webkit"},and_ff:{prefix:"moz"},ie_mob:{prefix:"ms"},and_uc:{prefix:"webkit",prefix_exceptions:{"15.5":"webkit"}},samsung:{prefix:"webkit"},and_qq:{prefix:"webkit"},baidu:{prefix:"webkit"},kaios:{prefix:"moz"}}});var ag=x(()=>{l()});var ce=x((M8,dt)=>{l();var{list:hl}=ye();dt.exports.error=function(r){let e=new Error(r);throw e.autoprefixer=!0,e};dt.exports.uniq=function(r){return[...new Set(r)]};dt.exports.removeNote=function(r){return r.includes(" ")?r.split(" ")[0]:r};dt.exports.escapeRegexp=function(r){return r.replace(/[$()*+-.?[\\\]^{|}]/g,"\\$&")};dt.exports.regexp=function(r,e=!0){return e&&(r=this.escapeRegexp(r)),new RegExp(`(^|[\\s,(])(${r}($|[\\s(,]))`,"gi")};dt.exports.editList=function(r,e){let t=hl.comma(r),i=e(t,[]);if(t===i)return r;let n=r.match(/,\s*/);return n=n?n[0]:", ",i.join(n)};dt.exports.splitSelector=function(r){return hl.comma(r).map(e=>hl.space(e).map(t=>t.split(/(?=\.|#)/g)))}});var ht=x((L8,ug)=>{l();var EC=dl(),og=(rs(),ts).agents,TC=ce(),lg=class{static prefixes(){if(this.prefixesCache)return this.prefixesCache;this.prefixesCache=[];for(let e in og)this.prefixesCache.push(`-${og[e].prefix}-`);return this.prefixesCache=TC.uniq(this.prefixesCache).sort((e,t)=>t.length-e.length),this.prefixesCache}static withPrefix(e){return this.prefixesRegexp||(this.prefixesRegexp=new RegExp(this.prefixes().join("|"))),this.prefixesRegexp.test(e)}constructor(e,t,i,n){this.data=e,this.options=i||{},this.browserslistOpts=n||{},this.selected=this.parse(t)}parse(e){let t={};for(let i in this.browserslistOpts)t[i]=this.browserslistOpts[i];return t.path=this.options.from,EC(e,t)}prefix(e){let[t,i]=e.split(" "),n=this.data[t],s=n.prefix_exceptions&&n.prefix_exceptions[i];return s||(s=n.prefix),`-${s}-`}isSelected(e){return this.selected.includes(e)}};ug.exports=lg});var hi=x(($8,fg)=>{l();fg.exports={prefix(r){let e=r.match(/^(-\w+-)/);return e?e[0]:""},unprefixed(r){return r.replace(/^-\w+-/,"")}}});var Kt=x((N8,pg)=>{l();var PC=ht(),cg=hi(),DC=ce();function ml(r,e){let t=new r.constructor;for(let i of Object.keys(r||{})){let n=r[i];i==="parent"&&typeof n=="object"?e&&(t[i]=e):i==="source"||i===null?t[i]=n:Array.isArray(n)?t[i]=n.map(s=>ml(s,t)):i!=="_autoprefixerPrefix"&&i!=="_autoprefixerValues"&&i!=="proxyCache"&&(typeof n=="object"&&n!==null&&(n=ml(n,t)),t[i]=n)}return t}var is=class{static hack(e){return this.hacks||(this.hacks={}),e.names.map(t=>(this.hacks[t]=e,this.hacks[t]))}static load(e,t,i){let n=this.hacks&&this.hacks[e];return n?new n(e,t,i):new this(e,t,i)}static clone(e,t){let i=ml(e);for(let n in t)i[n]=t[n];return i}constructor(e,t,i){this.prefixes=t,this.name=e,this.all=i}parentPrefix(e){let t;return typeof e._autoprefixerPrefix!="undefined"?t=e._autoprefixerPrefix:e.type==="decl"&&e.prop[0]==="-"?t=cg.prefix(e.prop):e.type==="root"?t=!1:e.type==="rule"&&e.selector.includes(":-")&&/:(-\w+-)/.test(e.selector)?t=e.selector.match(/:(-\w+-)/)[1]:e.type==="atrule"&&e.name[0]==="-"?t=cg.prefix(e.name):t=this.parentPrefix(e.parent),PC.prefixes().includes(t)||(t=!1),e._autoprefixerPrefix=t,e._autoprefixerPrefix}process(e,t){if(!this.check(e))return;let i=this.parentPrefix(e),n=this.prefixes.filter(a=>!i||i===DC.removeNote(a)),s=[];for(let a of n)this.add(e,a,s.concat([a]),t)&&s.push(a);return s}clone(e,t){return is.clone(e,t)}};pg.exports=is});var q=x((z8,mg)=>{l();var IC=Kt(),RC=ht(),dg=ce(),hg=class extends IC{check(){return!0}prefixed(e,t){return t+e}normalize(e){return e}otherPrefixes(e,t){for(let i of RC.prefixes())if(i!==t&&e.includes(i))return!0;return!1}set(e,t){return e.prop=this.prefixed(e.prop,t),e}needCascade(e){return e._autoprefixerCascade||(e._autoprefixerCascade=this.all.options.cascade!==!1&&e.raw("before").includes(`
+`)),e._autoprefixerCascade}maxPrefixed(e,t){if(t._autoprefixerMax)return t._autoprefixerMax;let i=0;for(let n of e)n=dg.removeNote(n),n.length>i&&(i=n.length);return t._autoprefixerMax=i,t._autoprefixerMax}calcBefore(e,t,i=""){let s=this.maxPrefixed(e,t)-dg.removeNote(i).length,a=t.raw("before");return s>0&&(a+=Array(s).fill(" ").join("")),a}restoreBefore(e){let t=e.raw("before").split(`
+`),i=t[t.length-1];this.all.group(e).up(n=>{let s=n.raw("before").split(`
+`),a=s[s.length-1];a.lengtha.prop===n.prop&&a.value===n.value)))return this.needCascade(e)&&(n.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,n)}isAlready(e,t){let i=this.all.group(e).up(n=>n.prop===t);return i||(i=this.all.group(e).down(n=>n.prop===t)),i}add(e,t,i,n){let s=this.prefixed(e.prop,t);if(!(this.isAlready(e,s)||this.otherPrefixes(e.value,t)))return this.insert(e,t,i,n)}process(e,t){if(!this.needCascade(e)){super.process(e,t);return}let i=super.process(e,t);!i||!i.length||(this.restoreBefore(e),e.raws.before=this.calcBefore(i,e))}old(e,t){return[this.prefixed(e,t)]}};mg.exports=hg});var yg=x((j8,gg)=>{l();gg.exports=function r(e){return{mul:t=>new r(e*t),div:t=>new r(e/t),simplify:()=>new r(e),toString:()=>e.toString()}}});var xg=x((U8,wg)=>{l();var qC=yg(),FC=Kt(),gl=ce(),BC=/(min|max)-resolution\s*:\s*\d*\.?\d+(dppx|dpcm|dpi|x)/gi,MC=/(min|max)-resolution(\s*:\s*)(\d*\.?\d+)(dppx|dpcm|dpi|x)/i,bg=class extends FC{prefixName(e,t){return e==="-moz-"?t+"--moz-device-pixel-ratio":e+t+"-device-pixel-ratio"}prefixQuery(e,t,i,n,s){return n=new qC(n),s==="dpi"?n=n.div(96):s==="dpcm"&&(n=n.mul(2.54).div(96)),n=n.simplify(),e==="-o-"&&(n=n.n+"/"+n.d),this.prefixName(e,t)+i+n}clean(e){if(!this.bad){this.bad=[];for(let t of this.prefixes)this.bad.push(this.prefixName(t,"min")),this.bad.push(this.prefixName(t,"max"))}e.params=gl.editList(e.params,t=>t.filter(i=>this.bad.every(n=>!i.includes(n))))}process(e){let t=this.parentPrefix(e),i=t?[t]:this.prefixes;e.params=gl.editList(e.params,(n,s)=>{for(let a of n){if(!a.includes("min-resolution")&&!a.includes("max-resolution")){s.push(a);continue}for(let o of i){let u=a.replace(BC,c=>{let f=c.match(MC);return this.prefixQuery(o,f[1],f[2],f[3],f[4])});s.push(u)}s.push(a)}return gl.uniq(s)})}};wg.exports=bg});var kg=x((V8,vg)=>{l();var yl="(".charCodeAt(0),bl=")".charCodeAt(0),ns="'".charCodeAt(0),wl='"'.charCodeAt(0),xl="\\".charCodeAt(0),Zt="/".charCodeAt(0),vl=",".charCodeAt(0),kl=":".charCodeAt(0),ss="*".charCodeAt(0),LC="u".charCodeAt(0),$C="U".charCodeAt(0),NC="+".charCodeAt(0),zC=/^[a-f0-9?-]+$/i;vg.exports=function(r){for(var e=[],t=r,i,n,s,a,o,u,c,f,d=0,p=t.charCodeAt(d),g=t.length,b=[{nodes:e}],v=0,y,w="",k="",C="";d{l();Sg.exports=function r(e,t,i){var n,s,a,o;for(n=0,s=e.length;n{l();function Ag(r,e){var t=r.type,i=r.value,n,s;return e&&(s=e(r))!==void 0?s:t==="word"||t==="space"?i:t==="string"?(n=r.quote||"",n+i+(r.unclosed?"":n)):t==="comment"?"/*"+i+(r.unclosed?"":"*/"):t==="div"?(r.before||"")+i+(r.after||""):Array.isArray(r.nodes)?(n=Og(r.nodes,e),t!=="function"?n:i+"("+(r.before||"")+n+(r.after||"")+(r.unclosed?"":")")):i}function Og(r,e){var t,i;if(Array.isArray(r)){for(t="",i=r.length-1;~i;i-=1)t=Ag(r[i],e)+t;return t}return Ag(r,e)}_g.exports=Og});var Pg=x((H8,Tg)=>{l();var as="-".charCodeAt(0),os="+".charCodeAt(0),Sl=".".charCodeAt(0),jC="e".charCodeAt(0),UC="E".charCodeAt(0);function VC(r){var e=r.charCodeAt(0),t;if(e===os||e===as){if(t=r.charCodeAt(1),t>=48&&t<=57)return!0;var i=r.charCodeAt(2);return t===Sl&&i>=48&&i<=57}return e===Sl?(t=r.charCodeAt(1),t>=48&&t<=57):e>=48&&e<=57}Tg.exports=function(r){var e=0,t=r.length,i,n,s;if(t===0||!VC(r))return!1;for(i=r.charCodeAt(e),(i===os||i===as)&&e++;e57));)e+=1;if(i=r.charCodeAt(e),n=r.charCodeAt(e+1),i===Sl&&n>=48&&n<=57)for(e+=2;e57));)e+=1;if(i=r.charCodeAt(e),n=r.charCodeAt(e+1),s=r.charCodeAt(e+2),(i===jC||i===UC)&&(n>=48&&n<=57||(n===os||n===as)&&s>=48&&s<=57))for(e+=n===os||n===as?3:2;e57));)e+=1;return{number:r.slice(0,e),unit:r.slice(e)}}});var ls=x((Y8,Rg)=>{l();var WC=kg(),Dg=Cg(),Ig=Eg();function mt(r){return this instanceof mt?(this.nodes=WC(r),this):new mt(r)}mt.prototype.toString=function(){return Array.isArray(this.nodes)?Ig(this.nodes):""};mt.prototype.walk=function(r,e){return Dg(this.nodes,r,e),this};mt.unit=Pg();mt.walk=Dg;mt.stringify=Ig;Rg.exports=mt});var Lg=x((Q8,Mg)=>{l();var{list:GC}=ye(),qg=ls(),HC=ht(),Fg=hi(),Bg=class{constructor(e){this.props=["transition","transition-property"],this.prefixes=e}add(e,t){let i,n,s=this.prefixes.add[e.prop],a=this.ruleVendorPrefixes(e),o=a||s&&s.prefixes||[],u=this.parse(e.value),c=u.map(g=>this.findProp(g)),f=[];if(c.some(g=>g[0]==="-"))return;for(let g of u){if(n=this.findProp(g),n[0]==="-")continue;let b=this.prefixes.add[n];if(!(!b||!b.prefixes))for(i of b.prefixes){if(a&&!a.some(y=>i.includes(y)))continue;let v=this.prefixes.prefixed(n,i);v!=="-ms-transform"&&!c.includes(v)&&(this.disabled(n,i)||f.push(this.clone(n,v,g)))}}u=u.concat(f);let d=this.stringify(u),p=this.stringify(this.cleanFromUnprefixed(u,"-webkit-"));if(o.includes("-webkit-")&&this.cloneBefore(e,`-webkit-${e.prop}`,p),this.cloneBefore(e,e.prop,p),o.includes("-o-")){let g=this.stringify(this.cleanFromUnprefixed(u,"-o-"));this.cloneBefore(e,`-o-${e.prop}`,g)}for(i of o)if(i!=="-webkit-"&&i!=="-o-"){let g=this.stringify(this.cleanOtherPrefixes(u,i));this.cloneBefore(e,i+e.prop,g)}d!==e.value&&!this.already(e,e.prop,d)&&(this.checkForWarning(t,e),e.cloneBefore(),e.value=d)}findProp(e){let t=e[0].value;if(/^\d/.test(t)){for(let[i,n]of e.entries())if(i!==0&&n.type==="word")return n.value}return t}already(e,t,i){return e.parent.some(n=>n.prop===t&&n.value===i)}cloneBefore(e,t,i){this.already(e,t,i)||e.cloneBefore({prop:t,value:i})}checkForWarning(e,t){if(t.prop!=="transition-property")return;let i=!1,n=!1;t.parent.each(s=>{if(s.type!=="decl"||s.prop.indexOf("transition-")!==0)return;let a=GC.comma(s.value);if(s.prop==="transition-property"){a.forEach(o=>{let u=this.prefixes.add[o];u&&u.prefixes&&u.prefixes.length>0&&(i=!0)});return}return n=n||a.length>1,!1}),i&&n&&t.warn(e,"Replace transition-property to transition, because Autoprefixer could not support any cases of transition-property and other transition-*")}remove(e){let t=this.parse(e.value);t=t.filter(a=>{let o=this.prefixes.remove[this.findProp(a)];return!o||!o.remove});let i=this.stringify(t);if(e.value===i)return;if(t.length===0){e.remove();return}let n=e.parent.some(a=>a.prop===e.prop&&a.value===i),s=e.parent.some(a=>a!==e&&a.prop===e.prop&&a.value.length>i.length);if(n||s){e.remove();return}e.value=i}parse(e){let t=qg(e),i=[],n=[];for(let s of t.nodes)n.push(s),s.type==="div"&&s.value===","&&(i.push(n),n=[]);return i.push(n),i.filter(s=>s.length>0)}stringify(e){if(e.length===0)return"";let t=[];for(let i of e)i[i.length-1].type!=="div"&&i.push(this.div(e)),t=t.concat(i);return t[0].type==="div"&&(t=t.slice(1)),t[t.length-1].type==="div"&&(t=t.slice(0,-2+1||void 0)),qg.stringify({nodes:t})}clone(e,t,i){let n=[],s=!1;for(let a of i)!s&&a.type==="word"&&a.value===e?(n.push({type:"word",value:t}),s=!0):n.push(a);return n}div(e){for(let t of e)for(let i of t)if(i.type==="div"&&i.value===",")return i;return{type:"div",value:",",after:" "}}cleanOtherPrefixes(e,t){return e.filter(i=>{let n=Fg.prefix(this.findProp(i));return n===""||n===t})}cleanFromUnprefixed(e,t){let i=e.map(s=>this.findProp(s)).filter(s=>s.slice(0,t.length)===t).map(s=>this.prefixes.unprefixed(s)),n=[];for(let s of e){let a=this.findProp(s),o=Fg.prefix(a);!i.includes(a)&&(o===t||o==="")&&n.push(s)}return n}disabled(e,t){let i=["order","justify-content","align-self","align-content"];if(e.includes("flex")||i.includes(e)){if(this.prefixes.options.flexbox===!1)return!0;if(this.prefixes.options.flexbox==="no-2009")return t.includes("2009")}}ruleVendorPrefixes(e){let{parent:t}=e;if(t.type!=="rule")return!1;if(!t.selector.includes(":-"))return!1;let i=HC.prefixes().filter(n=>t.selector.includes(":"+n));return i.length>0?i:!1}};Mg.exports=Bg});var er=x((J8,Ng)=>{l();var YC=ce(),$g=class{constructor(e,t,i,n){this.unprefixed=e,this.prefixed=t,this.string=i||t,this.regexp=n||YC.regexp(t)}check(e){return e.includes(this.string)?!!e.match(this.regexp):!1}};Ng.exports=$g});var Ce=x((X8,jg)=>{l();var QC=Kt(),JC=er(),XC=hi(),KC=ce(),zg=class extends QC{static save(e,t){let i=t.prop,n=[];for(let s in t._autoprefixerValues){let a=t._autoprefixerValues[s];if(a===t.value)continue;let o,u=XC.prefix(i);if(u==="-pie-")continue;if(u===s){o=t.value=a,n.push(o);continue}let c=e.prefixed(i,s),f=t.parent;if(!f.every(b=>b.prop!==c)){n.push(o);continue}let d=a.replace(/\s+/," ");if(f.some(b=>b.prop===t.prop&&b.value.replace(/\s+/," ")===d)){n.push(o);continue}let g=this.clone(t,{value:a});o=t.parent.insertBefore(t,g),n.push(o)}return n}check(e){let t=e.value;return t.includes(this.name)?!!t.match(this.regexp()):!1}regexp(){return this.regexpCache||(this.regexpCache=KC.regexp(this.name))}replace(e,t){return e.replace(this.regexp(),`$1${t}$2`)}value(e){return e.raws.value&&e.raws.value.value===e.value?e.raws.value.raw:e.value}add(e,t){e._autoprefixerValues||(e._autoprefixerValues={});let i=e._autoprefixerValues[t]||this.value(e),n;do if(n=i,i=this.replace(i,t),i===!1)return;while(i!==n);e._autoprefixerValues[t]=i}old(e){return new JC(this.name,e+this.name)}};jg.exports=zg});var gt=x((K8,Ug)=>{l();Ug.exports={}});var Al=x((Z8,Gg)=>{l();var Vg=ls(),ZC=Ce(),eA=gt().insertAreas,tA=/(^|[^-])linear-gradient\(\s*(top|left|right|bottom)/i,rA=/(^|[^-])radial-gradient\(\s*\d+(\w*|%)\s+\d+(\w*|%)\s*,/i,iA=/(!\s*)?autoprefixer:\s*ignore\s+next/i,nA=/(!\s*)?autoprefixer\s*grid:\s*(on|off|(no-)?autoplace)/i,sA=["width","height","min-width","max-width","min-height","max-height","inline-size","min-inline-size","max-inline-size","block-size","min-block-size","max-block-size"];function Cl(r){return r.parent.some(e=>e.prop==="grid-template"||e.prop==="grid-template-areas")}function aA(r){let e=r.parent.some(i=>i.prop==="grid-template-rows"),t=r.parent.some(i=>i.prop==="grid-template-columns");return e&&t}var Wg=class{constructor(e){this.prefixes=e}add(e,t){let i=this.prefixes.add["@resolution"],n=this.prefixes.add["@keyframes"],s=this.prefixes.add["@viewport"],a=this.prefixes.add["@supports"];e.walkAtRules(f=>{if(f.name==="keyframes"){if(!this.disabled(f,t))return n&&n.process(f)}else if(f.name==="viewport"){if(!this.disabled(f,t))return s&&s.process(f)}else if(f.name==="supports"){if(this.prefixes.options.supports!==!1&&!this.disabled(f,t))return a.process(f)}else if(f.name==="media"&&f.params.includes("-resolution")&&!this.disabled(f,t))return i&&i.process(f)}),e.walkRules(f=>{if(!this.disabled(f,t))return this.prefixes.add.selectors.map(d=>d.process(f,t))});function o(f){return f.parent.nodes.some(d=>{if(d.type!=="decl")return!1;let p=d.prop==="display"&&/(inline-)?grid/.test(d.value),g=d.prop.startsWith("grid-template"),b=/^grid-([A-z]+-)?gap/.test(d.prop);return p||g||b})}function u(f){return f.parent.some(d=>d.prop==="display"&&/(inline-)?flex/.test(d.value))}let c=this.gridStatus(e,t)&&this.prefixes.add["grid-area"]&&this.prefixes.add["grid-area"].prefixes;return e.walkDecls(f=>{if(this.disabledDecl(f,t))return;let d=f.parent,p=f.prop,g=f.value;if(p==="grid-row-span"){t.warn("grid-row-span is not part of final Grid Layout. Use grid-row.",{node:f});return}else if(p==="grid-column-span"){t.warn("grid-column-span is not part of final Grid Layout. Use grid-column.",{node:f});return}else if(p==="display"&&g==="box"){t.warn("You should write display: flex by final spec instead of display: box",{node:f});return}else if(p==="text-emphasis-position")(g==="under"||g==="over")&&t.warn("You should use 2 values for text-emphasis-position For example, `under left` instead of just `under`.",{node:f});else if(/^(align|justify|place)-(items|content)$/.test(p)&&u(f))(g==="start"||g==="end")&&t.warn(`${g} value has mixed support, consider using flex-${g} instead`,{node:f});else if(p==="text-decoration-skip"&&g==="ink")t.warn("Replace text-decoration-skip: ink to text-decoration-skip-ink: auto, because spec had been changed",{node:f});else{if(c&&this.gridStatus(f,t))if(f.value==="subgrid"&&t.warn("IE does not support subgrid",{node:f}),/^(align|justify|place)-items$/.test(p)&&o(f)){let v=p.replace("-items","-self");t.warn(`IE does not support ${p} on grid containers. Try using ${v} on child elements instead: ${f.parent.selector} > * { ${v}: ${f.value} }`,{node:f})}else if(/^(align|justify|place)-content$/.test(p)&&o(f))t.warn(`IE does not support ${f.prop} on grid containers`,{node:f});else if(p==="display"&&f.value==="contents"){t.warn("Please do not use display: contents; if you have grid setting enabled",{node:f});return}else if(f.prop==="grid-gap"){let v=this.gridStatus(f,t);v==="autoplace"&&!aA(f)&&!Cl(f)?t.warn("grid-gap only works if grid-template(-areas) is being used or both rows and columns have been declared and cells have not been manually placed inside the explicit grid",{node:f}):(v===!0||v==="no-autoplace")&&!Cl(f)&&t.warn("grid-gap only works if grid-template(-areas) is being used",{node:f})}else if(p==="grid-auto-columns"){t.warn("grid-auto-columns is not supported by IE",{node:f});return}else if(p==="grid-auto-rows"){t.warn("grid-auto-rows is not supported by IE",{node:f});return}else if(p==="grid-auto-flow"){let v=d.some(w=>w.prop==="grid-template-rows"),y=d.some(w=>w.prop==="grid-template-columns");Cl(f)?t.warn("grid-auto-flow is not supported by IE",{node:f}):g.includes("dense")?t.warn("grid-auto-flow: dense is not supported by IE",{node:f}):!v&&!y&&t.warn("grid-auto-flow works only if grid-template-rows and grid-template-columns are present in the same rule",{node:f});return}else if(g.includes("auto-fit")){t.warn("auto-fit value is not supported by IE",{node:f,word:"auto-fit"});return}else if(g.includes("auto-fill")){t.warn("auto-fill value is not supported by IE",{node:f,word:"auto-fill"});return}else p.startsWith("grid-template")&&g.includes("[")&&t.warn("Autoprefixer currently does not support line names. Try using grid-template-areas instead.",{node:f,word:"["});if(g.includes("radial-gradient"))if(rA.test(f.value))t.warn("Gradient has outdated direction syntax. New syntax is like `closest-side at 0 0` instead of `0 0, closest-side`.",{node:f});else{let v=Vg(g);for(let y of v.nodes)if(y.type==="function"&&y.value==="radial-gradient")for(let w of y.nodes)w.type==="word"&&(w.value==="cover"?t.warn("Gradient has outdated direction syntax. Replace `cover` to `farthest-corner`.",{node:f}):w.value==="contain"&&t.warn("Gradient has outdated direction syntax. Replace `contain` to `closest-side`.",{node:f}))}g.includes("linear-gradient")&&tA.test(g)&&t.warn("Gradient has outdated direction syntax. New syntax is like `to left` instead of `right`.",{node:f})}sA.includes(f.prop)&&(f.value.includes("-fill-available")||(f.value.includes("fill-available")?t.warn("Replace fill-available to stretch, because spec had been changed",{node:f}):f.value.includes("fill")&&Vg(g).nodes.some(y=>y.type==="word"&&y.value==="fill")&&t.warn("Replace fill to stretch, because spec had been changed",{node:f})));let b;if(f.prop==="transition"||f.prop==="transition-property")return this.prefixes.transition.add(f,t);if(f.prop==="align-self"){if(this.displayType(f)!=="grid"&&this.prefixes.options.flexbox!==!1&&(b=this.prefixes.add["align-self"],b&&b.prefixes&&b.process(f)),this.gridStatus(f,t)!==!1&&(b=this.prefixes.add["grid-row-align"],b&&b.prefixes))return b.process(f,t)}else if(f.prop==="justify-self"){if(this.gridStatus(f,t)!==!1&&(b=this.prefixes.add["grid-column-align"],b&&b.prefixes))return b.process(f,t)}else if(f.prop==="place-self"){if(b=this.prefixes.add["place-self"],b&&b.prefixes&&this.gridStatus(f,t)!==!1)return b.process(f,t)}else if(b=this.prefixes.add[f.prop],b&&b.prefixes)return b.process(f,t)}),this.gridStatus(e,t)&&eA(e,this.disabled),e.walkDecls(f=>{if(this.disabledValue(f,t))return;let d=this.prefixes.unprefixed(f.prop),p=this.prefixes.values("add",d);if(Array.isArray(p))for(let g of p)g.process&&g.process(f,t);ZC.save(this.prefixes,f)})}remove(e,t){let i=this.prefixes.remove["@resolution"];e.walkAtRules((n,s)=>{this.prefixes.remove[`@${n.name}`]?this.disabled(n,t)||n.parent.removeChild(s):n.name==="media"&&n.params.includes("-resolution")&&i&&i.clean(n)});for(let n of this.prefixes.remove.selectors)e.walkRules((s,a)=>{n.check(s)&&(this.disabled(s,t)||s.parent.removeChild(a))});return e.walkDecls((n,s)=>{if(this.disabled(n,t))return;let a=n.parent,o=this.prefixes.unprefixed(n.prop);if((n.prop==="transition"||n.prop==="transition-property")&&this.prefixes.transition.remove(n),this.prefixes.remove[n.prop]&&this.prefixes.remove[n.prop].remove){let u=this.prefixes.group(n).down(c=>this.prefixes.normalize(c.prop)===o);if(o==="flex-flow"&&(u=!0),n.prop==="-webkit-box-orient"){let c={"flex-direction":!0,"flex-flow":!0};if(!n.parent.some(f=>c[f.prop]))return}if(u&&!this.withHackValue(n)){n.raw("before").includes(`
+`)&&this.reduceSpaces(n),a.removeChild(s);return}}for(let u of this.prefixes.values("remove",o)){if(!u.check||!u.check(n.value))continue;if(o=u.unprefixed,this.prefixes.group(n).down(f=>f.value.includes(o))){a.removeChild(s);return}}})}withHackValue(e){return e.prop==="-webkit-background-clip"&&e.value==="text"}disabledValue(e,t){return this.gridStatus(e,t)===!1&&e.type==="decl"&&e.prop==="display"&&e.value.includes("grid")||this.prefixes.options.flexbox===!1&&e.type==="decl"&&e.prop==="display"&&e.value.includes("flex")||e.type==="decl"&&e.prop==="content"?!0:this.disabled(e,t)}disabledDecl(e,t){if(this.gridStatus(e,t)===!1&&e.type==="decl"&&(e.prop.includes("grid")||e.prop==="justify-items"))return!0;if(this.prefixes.options.flexbox===!1&&e.type==="decl"){let i=["order","justify-content","align-items","align-content"];if(e.prop.includes("flex")||i.includes(e.prop))return!0}return this.disabled(e,t)}disabled(e,t){if(!e)return!1;if(e._autoprefixerDisabled!==void 0)return e._autoprefixerDisabled;if(e.parent){let n=e.prev();if(n&&n.type==="comment"&&iA.test(n.text))return e._autoprefixerDisabled=!0,e._autoprefixerSelfDisabled=!0,!0}let i=null;if(e.nodes){let n;e.each(s=>{s.type==="comment"&&/(!\s*)?autoprefixer:\s*(off|on)/i.test(s.text)&&(typeof n!="undefined"?t.warn("Second Autoprefixer control comment was ignored. Autoprefixer applies control comment to whole block, not to next rules.",{node:s}):n=/on/i.test(s.text))}),n!==void 0&&(i=!n)}if(!e.nodes||i===null)if(e.parent){let n=this.disabled(e.parent,t);e.parent._autoprefixerSelfDisabled===!0?i=!1:i=n}else i=!1;return e._autoprefixerDisabled=i,i}reduceSpaces(e){let t=!1;if(this.prefixes.group(e).up(()=>(t=!0,!0)),t)return;let i=e.raw("before").split(`
+`),n=i[i.length-1].length,s=!1;this.prefixes.group(e).down(a=>{i=a.raw("before").split(`
+`);let o=i.length-1;i[o].length>n&&(s===!1&&(s=i[o].length-n),i[o]=i[o].slice(0,-s),a.raws.before=i.join(`
+`))})}displayType(e){for(let t of e.parent.nodes)if(t.prop==="display"){if(t.value.includes("flex"))return"flex";if(t.value.includes("grid"))return"grid"}return!1}gridStatus(e,t){if(!e)return!1;if(e._autoprefixerGridStatus!==void 0)return e._autoprefixerGridStatus;let i=null;if(e.nodes){let n;e.each(s=>{if(s.type==="comment"&&nA.test(s.text)){let a=/:\s*autoplace/i.test(s.text),o=/no-autoplace/i.test(s.text);typeof n!="undefined"?t.warn("Second Autoprefixer grid control comment was ignored. Autoprefixer applies control comments to the whole block, not to the next rules.",{node:s}):a?n="autoplace":o?n=!0:n=/on/i.test(s.text)}}),n!==void 0&&(i=n)}if(e.type==="atrule"&&e.name==="supports"){let n=e.params;n.includes("grid")&&n.includes("auto")&&(i=!1)}if(!e.nodes||i===null)if(e.parent){let n=this.gridStatus(e.parent,t);e.parent._autoprefixerSelfDisabled===!0?i=!1:i=n}else typeof this.prefixes.options.grid!="undefined"?i=this.prefixes.options.grid:typeof h.env.AUTOPREFIXER_GRID!="undefined"?h.env.AUTOPREFIXER_GRID==="autoplace"?i="autoplace":i=!0:i=!1;return e._autoprefixerGridStatus=i,i}};Gg.exports=Wg});var Yg=x((eI,Hg)=>{l();Hg.exports={A:{A:{"2":"K E F G A B JC"},B:{"1":"C L M H N D O P Q R S T U V W X Y Z a b c d e f g h i j n o p q r s t u v w x y z I"},C:{"1":"2 3 4 5 6 7 8 9 AB BB CB DB EB FB GB HB IB JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB cB 0B dB 1B eB fB gB hB iB jB kB lB mB nB oB m pB qB rB sB tB P Q R 2B S T U V W X Y Z a b c d e f g h i j n o p q r s t u v w x y z I uB 3B 4B","2":"0 1 KC zB J K E F G A B C L M H N D O k l LC MC"},D:{"1":"8 9 AB BB CB DB EB FB GB HB IB JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB cB 0B dB 1B eB fB gB hB iB jB kB lB mB nB oB m pB qB rB sB tB P Q R S T U V W X Y Z a b c d e f g h i j n o p q r s t u v w x y z I uB 3B 4B","2":"0 1 2 3 4 5 6 7 J K E F G A B C L M H N D O k l"},E:{"1":"G A B C L M H D RC 6B vB wB 7B SC TC 8B 9B xB AC yB BC CC DC EC FC GC UC","2":"0 J K E F NC 5B OC PC QC"},F:{"1":"1 2 3 4 5 6 7 8 9 H N D O k l AB BB CB DB EB FB GB HB IB JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB cB dB eB fB gB hB iB jB kB lB mB nB oB m pB qB rB sB tB P Q R 2B S T U V W X Y Z a b c d e f g h i j wB","2":"G B C VC WC XC YC vB HC ZC"},G:{"1":"D fC gC hC iC jC kC lC mC nC oC pC qC rC sC tC 8B 9B xB AC yB BC CC DC EC FC GC","2":"F 5B aC IC bC cC dC eC"},H:{"1":"uC"},I:{"1":"I zC 0C","2":"zB J vC wC xC yC IC"},J:{"2":"E A"},K:{"1":"m","2":"A B C vB HC wB"},L:{"1":"I"},M:{"1":"uB"},N:{"2":"A B"},O:{"1":"xB"},P:{"1":"J k l 1C 2C 3C 4C 5C 6B 6C 7C 8C 9C AD yB BD CD DD"},Q:{"1":"7B"},R:{"1":"ED"},S:{"1":"FD GD"}},B:4,C:"CSS Feature Queries"}});var Kg=x((tI,Xg)=>{l();function Qg(r){return r[r.length-1]}var Jg={parse(r){let e=[""],t=[e];for(let i of r){if(i==="("){e=[""],Qg(t).push(e),t.push(e);continue}if(i===")"){t.pop(),e=Qg(t),e.push("");continue}e[e.length-1]+=i}return t[0]},stringify(r){let e="";for(let t of r){if(typeof t=="object"){e+=`(${Jg.stringify(t)})`;continue}e+=t}return e}};Xg.exports=Jg});var i0=x((rI,r0)=>{l();var oA=Yg(),{feature:lA}=(rs(),ts),{parse:uA}=ye(),fA=ht(),Ol=Kg(),cA=Ce(),pA=ce(),Zg=lA(oA),e0=[];for(let r in Zg.stats){let e=Zg.stats[r];for(let t in e){let i=e[t];/y/.test(i)&&e0.push(r+" "+t)}}var t0=class{constructor(e,t){this.Prefixes=e,this.all=t}prefixer(){if(this.prefixerCache)return this.prefixerCache;let e=this.all.browsers.selected.filter(i=>e0.includes(i)),t=new fA(this.all.browsers.data,e,this.all.options);return this.prefixerCache=new this.Prefixes(this.all.data,t,this.all.options),this.prefixerCache}parse(e){let t=e.split(":"),i=t[0],n=t[1];return n||(n=""),[i.trim(),n.trim()]}virtual(e){let[t,i]=this.parse(e),n=uA("a{}").first;return n.append({prop:t,value:i,raws:{before:""}}),n}prefixed(e){let t=this.virtual(e);if(this.disabled(t.first))return t.nodes;let i={warn:()=>null},n=this.prefixer().add[t.first.prop];n&&n.process&&n.process(t.first,i);for(let s of t.nodes){for(let a of this.prefixer().values("add",t.first.prop))a.process(s);cA.save(this.all,s)}return t.nodes}isNot(e){return typeof e=="string"&&/not\s*/i.test(e)}isOr(e){return typeof e=="string"&&/\s*or\s*/i.test(e)}isProp(e){return typeof e=="object"&&e.length===1&&typeof e[0]=="string"}isHack(e,t){return!new RegExp(`(\\(|\\s)${pA.escapeRegexp(t)}:`).test(e)}toRemove(e,t){let[i,n]=this.parse(e),s=this.all.unprefixed(i),a=this.all.cleaner();if(a.remove[i]&&a.remove[i].remove&&!this.isHack(t,s))return!0;for(let o of a.values("remove",s))if(o.check(n))return!0;return!1}remove(e,t){let i=0;for(;itypeof t!="object"?t:t.length===1&&typeof t[0]=="object"?this.cleanBrackets(t[0]):this.cleanBrackets(t))}convert(e){let t=[""];for(let i of e)t.push([`${i.prop}: ${i.value}`]),t.push(" or ");return t[t.length-1]="",t}normalize(e){if(typeof e!="object")return e;if(e=e.filter(t=>t!==""),typeof e[0]=="string"){let t=e[0].trim();if(t.includes(":")||t==="selector"||t==="not selector")return[Ol.stringify(e)]}return e.map(t=>this.normalize(t))}add(e,t){return e.map(i=>{if(this.isProp(i)){let n=this.prefixed(i[0]);return n.length>1?this.convert(n):i}return typeof i=="object"?this.add(i,t):i})}process(e){let t=Ol.parse(e.params);t=this.normalize(t),t=this.remove(t,e.params),t=this.add(t,e.params),t=this.cleanBrackets(t),e.params=Ol.stringify(t)}disabled(e){if(!this.all.options.grid&&(e.prop==="display"&&e.value.includes("grid")||e.prop.includes("grid")||e.prop==="justify-items"))return!0;if(this.all.options.flexbox===!1){if(e.prop==="display"&&e.value.includes("flex"))return!0;let t=["order","justify-content","align-items","align-content"];if(e.prop.includes("flex")||t.includes(e.prop))return!0}return!1}};r0.exports=t0});var a0=x((iI,s0)=>{l();var n0=class{constructor(e,t){this.prefix=t,this.prefixed=e.prefixed(this.prefix),this.regexp=e.regexp(this.prefix),this.prefixeds=e.possible().map(i=>[e.prefixed(i),e.regexp(i)]),this.unprefixed=e.name,this.nameRegexp=e.regexp()}isHack(e){let t=e.parent.index(e)+1,i=e.parent.nodes;for(;t{l();var{list:dA}=ye(),hA=a0(),mA=Kt(),gA=ht(),yA=ce(),o0=class extends mA{constructor(e,t,i){super(e,t,i);this.regexpCache=new Map}check(e){return e.selector.includes(this.name)?!!e.selector.match(this.regexp()):!1}prefixed(e){return this.name.replace(/^(\W*)/,`$1${e}`)}regexp(e){if(!this.regexpCache.has(e)){let t=e?this.prefixed(e):this.name;this.regexpCache.set(e,new RegExp(`(^|[^:"'=])${yA.escapeRegexp(t)}`,"gi"))}return this.regexpCache.get(e)}possible(){return gA.prefixes()}prefixeds(e){if(e._autoprefixerPrefixeds){if(e._autoprefixerPrefixeds[this.name])return e._autoprefixerPrefixeds}else e._autoprefixerPrefixeds={};let t={};if(e.selector.includes(",")){let n=dA.comma(e.selector).filter(s=>s.includes(this.name));for(let s of this.possible())t[s]=n.map(a=>this.replace(a,s)).join(", ")}else for(let i of this.possible())t[i]=this.replace(e.selector,i);return e._autoprefixerPrefixeds[this.name]=t,e._autoprefixerPrefixeds}already(e,t,i){let n=e.parent.index(e)-1;for(;n>=0;){let s=e.parent.nodes[n];if(s.type!=="rule")return!1;let a=!1;for(let o in t[this.name]){let u=t[this.name][o];if(s.selector===u){if(i===o)return!0;a=!0;break}}if(!a)return!1;n-=1}return!1}replace(e,t){return e.replace(this.regexp(),`$1${this.prefixed(t)}`)}add(e,t){let i=this.prefixeds(e);if(this.already(e,i,t))return;let n=this.clone(e,{selector:i[this.name][t]});e.parent.insertBefore(e,n)}old(e){return new hA(this,e)}};l0.exports=o0});var c0=x((sI,f0)=>{l();var bA=Kt(),u0=class extends bA{add(e,t){let i=t+e.name;if(e.parent.some(a=>a.name===i&&a.params===e.params))return;let s=this.clone(e,{name:i});return e.parent.insertBefore(e,s)}process(e){let t=this.parentPrefix(e);for(let i of this.prefixes)(!t||t===i)&&this.add(e,i)}};f0.exports=u0});var d0=x((aI,p0)=>{l();var wA=tr(),_l=class extends wA{prefixed(e){return e==="-webkit-"?":-webkit-full-screen":e==="-moz-"?":-moz-full-screen":`:${e}fullscreen`}};_l.names=[":fullscreen"];p0.exports=_l});var m0=x((oI,h0)=>{l();var xA=tr(),El=class extends xA{possible(){return super.possible().concat(["-moz- old","-ms- old"])}prefixed(e){return e==="-webkit-"?"::-webkit-input-placeholder":e==="-ms-"?"::-ms-input-placeholder":e==="-ms- old"?":-ms-input-placeholder":e==="-moz- old"?":-moz-placeholder":`::${e}placeholder`}};El.names=["::placeholder"];h0.exports=El});var y0=x((lI,g0)=>{l();var vA=tr(),Tl=class extends vA{prefixed(e){return e==="-ms-"?":-ms-input-placeholder":`:${e}placeholder-shown`}};Tl.names=[":placeholder-shown"];g0.exports=Tl});var w0=x((uI,b0)=>{l();var kA=tr(),SA=ce(),Pl=class extends kA{constructor(e,t,i){super(e,t,i);this.prefixes&&(this.prefixes=SA.uniq(this.prefixes.map(n=>"-webkit-")))}prefixed(e){return e==="-webkit-"?"::-webkit-file-upload-button":`::${e}file-selector-button`}};Pl.names=["::file-selector-button"];b0.exports=Pl});var me=x((fI,x0)=>{l();x0.exports=function(r){let e;return r==="-webkit- 2009"||r==="-moz-"?e=2009:r==="-ms-"?e=2012:r==="-webkit-"&&(e="final"),r==="-webkit- 2009"&&(r="-webkit-"),[e,r]}});var C0=x((cI,S0)=>{l();var v0=ye().list,k0=me(),CA=q(),rr=class extends CA{prefixed(e,t){let i;return[i,t]=k0(t),i===2009?t+"box-flex":super.prefixed(e,t)}normalize(){return"flex"}set(e,t){let i=k0(t)[0];if(i===2009)return e.value=v0.space(e.value)[0],e.value=rr.oldValues[e.value]||e.value,super.set(e,t);if(i===2012){let n=v0.space(e.value);n.length===3&&n[2]==="0"&&(e.value=n.slice(0,2).concat("0px").join(" "))}return super.set(e,t)}};rr.names=["flex","box-flex"];rr.oldValues={auto:"1",none:"0"};S0.exports=rr});var _0=x((pI,O0)=>{l();var A0=me(),AA=q(),Dl=class extends AA{prefixed(e,t){let i;return[i,t]=A0(t),i===2009?t+"box-ordinal-group":i===2012?t+"flex-order":super.prefixed(e,t)}normalize(){return"order"}set(e,t){return A0(t)[0]===2009&&/\d/.test(e.value)?(e.value=(parseInt(e.value)+1).toString(),super.set(e,t)):super.set(e,t)}};Dl.names=["order","flex-order","box-ordinal-group"];O0.exports=Dl});var T0=x((dI,E0)=>{l();var OA=q(),Il=class extends OA{check(e){let t=e.value;return!t.toLowerCase().includes("alpha(")&&!t.includes("DXImageTransform.Microsoft")&&!t.includes("data:image/svg+xml")}};Il.names=["filter"];E0.exports=Il});var D0=x((hI,P0)=>{l();var _A=q(),Rl=class extends _A{insert(e,t,i,n){if(t!=="-ms-")return super.insert(e,t,i);let s=this.clone(e),a=e.prop.replace(/end$/,"start"),o=t+e.prop.replace(/end$/,"span");if(!e.parent.some(u=>u.prop===o)){if(s.prop=o,e.value.includes("span"))s.value=e.value.replace(/span\s/i,"");else{let u;if(e.parent.walkDecls(a,c=>{u=c}),u){let c=Number(e.value)-Number(u.value)+"";s.value=c}else e.warn(n,`Can not prefix ${e.prop} (${a} is not found)`)}e.cloneBefore(s)}}};Rl.names=["grid-row-end","grid-column-end"];P0.exports=Rl});var R0=x((mI,I0)=>{l();var EA=q(),ql=class extends EA{check(e){return!e.value.split(/\s+/).some(t=>{let i=t.toLowerCase();return i==="reverse"||i==="alternate-reverse"})}};ql.names=["animation","animation-direction"];I0.exports=ql});var F0=x((gI,q0)=>{l();var TA=me(),PA=q(),Fl=class extends PA{insert(e,t,i){let n;if([n,t]=TA(t),n!==2009)return super.insert(e,t,i);let s=e.value.split(/\s+/).filter(d=>d!=="wrap"&&d!=="nowrap"&&"wrap-reverse");if(s.length===0||e.parent.some(d=>d.prop===t+"box-orient"||d.prop===t+"box-direction"))return;let o=s[0],u=o.includes("row")?"horizontal":"vertical",c=o.includes("reverse")?"reverse":"normal",f=this.clone(e);return f.prop=t+"box-orient",f.value=u,this.needCascade(e)&&(f.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,f),f=this.clone(e),f.prop=t+"box-direction",f.value=c,this.needCascade(e)&&(f.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,f)}};Fl.names=["flex-flow","box-direction","box-orient"];q0.exports=Fl});var M0=x((yI,B0)=>{l();var DA=me(),IA=q(),Bl=class extends IA{normalize(){return"flex"}prefixed(e,t){let i;return[i,t]=DA(t),i===2009?t+"box-flex":i===2012?t+"flex-positive":super.prefixed(e,t)}};Bl.names=["flex-grow","flex-positive"];B0.exports=Bl});var $0=x((bI,L0)=>{l();var RA=me(),qA=q(),Ml=class extends qA{set(e,t){if(RA(t)[0]!==2009)return super.set(e,t)}};Ml.names=["flex-wrap"];L0.exports=Ml});var z0=x((wI,N0)=>{l();var FA=q(),ir=gt(),Ll=class extends FA{insert(e,t,i,n){if(t!=="-ms-")return super.insert(e,t,i);let s=ir.parse(e),[a,o]=ir.translate(s,0,2),[u,c]=ir.translate(s,1,3);[["grid-row",a],["grid-row-span",o],["grid-column",u],["grid-column-span",c]].forEach(([f,d])=>{ir.insertDecl(e,f,d)}),ir.warnTemplateSelectorNotFound(e,n),ir.warnIfGridRowColumnExists(e,n)}};Ll.names=["grid-area"];N0.exports=Ll});var U0=x((xI,j0)=>{l();var BA=q(),mi=gt(),$l=class extends BA{insert(e,t,i){if(t!=="-ms-")return super.insert(e,t,i);if(e.parent.some(a=>a.prop==="-ms-grid-row-align"))return;let[[n,s]]=mi.parse(e);s?(mi.insertDecl(e,"grid-row-align",n),mi.insertDecl(e,"grid-column-align",s)):(mi.insertDecl(e,"grid-row-align",n),mi.insertDecl(e,"grid-column-align",n))}};$l.names=["place-self"];j0.exports=$l});var W0=x((vI,V0)=>{l();var MA=q(),Nl=class extends MA{check(e){let t=e.value;return!t.includes("/")||t.includes("span")}normalize(e){return e.replace("-start","")}prefixed(e,t){let i=super.prefixed(e,t);return t==="-ms-"&&(i=i.replace("-start","")),i}};Nl.names=["grid-row-start","grid-column-start"];V0.exports=Nl});var Y0=x((kI,H0)=>{l();var G0=me(),LA=q(),nr=class extends LA{check(e){return e.parent&&!e.parent.some(t=>t.prop&&t.prop.startsWith("grid-"))}prefixed(e,t){let i;return[i,t]=G0(t),i===2012?t+"flex-item-align":super.prefixed(e,t)}normalize(){return"align-self"}set(e,t){let i=G0(t)[0];if(i===2012)return e.value=nr.oldValues[e.value]||e.value,super.set(e,t);if(i==="final")return super.set(e,t)}};nr.names=["align-self","flex-item-align"];nr.oldValues={"flex-end":"end","flex-start":"start"};H0.exports=nr});var J0=x((SI,Q0)=>{l();var $A=q(),NA=ce(),zl=class extends $A{constructor(e,t,i){super(e,t,i);this.prefixes&&(this.prefixes=NA.uniq(this.prefixes.map(n=>n==="-ms-"?"-webkit-":n)))}};zl.names=["appearance"];Q0.exports=zl});var Z0=x((CI,K0)=>{l();var X0=me(),zA=q(),jl=class extends zA{normalize(){return"flex-basis"}prefixed(e,t){let i;return[i,t]=X0(t),i===2012?t+"flex-preferred-size":super.prefixed(e,t)}set(e,t){let i;if([i,t]=X0(t),i===2012||i==="final")return super.set(e,t)}};jl.names=["flex-basis","flex-preferred-size"];K0.exports=jl});var ty=x((AI,ey)=>{l();var jA=q(),Ul=class extends jA{normalize(){return this.name.replace("box-image","border")}prefixed(e,t){let i=super.prefixed(e,t);return t==="-webkit-"&&(i=i.replace("border","box-image")),i}};Ul.names=["mask-border","mask-border-source","mask-border-slice","mask-border-width","mask-border-outset","mask-border-repeat","mask-box-image","mask-box-image-source","mask-box-image-slice","mask-box-image-width","mask-box-image-outset","mask-box-image-repeat"];ey.exports=Ul});var iy=x((OI,ry)=>{l();var UA=q(),$e=class extends UA{insert(e,t,i){let n=e.prop==="mask-composite",s;n?s=e.value.split(","):s=e.value.match($e.regexp)||[],s=s.map(c=>c.trim()).filter(c=>c);let a=s.length,o;if(a&&(o=this.clone(e),o.value=s.map(c=>$e.oldValues[c]||c).join(", "),s.includes("intersect")&&(o.value+=", xor"),o.prop=t+"mask-composite"),n)return a?(this.needCascade(e)&&(o.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,o)):void 0;let u=this.clone(e);return u.prop=t+u.prop,a&&(u.value=u.value.replace($e.regexp,"")),this.needCascade(e)&&(u.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,u),a?(this.needCascade(e)&&(o.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,o)):e}};$e.names=["mask","mask-composite"];$e.oldValues={add:"source-over",subtract:"source-out",intersect:"source-in",exclude:"xor"};$e.regexp=new RegExp(`\\s+(${Object.keys($e.oldValues).join("|")})\\b(?!\\))\\s*(?=[,])`,"ig");ry.exports=$e});var ay=x((_I,sy)=>{l();var ny=me(),VA=q(),sr=class extends VA{prefixed(e,t){let i;return[i,t]=ny(t),i===2009?t+"box-align":i===2012?t+"flex-align":super.prefixed(e,t)}normalize(){return"align-items"}set(e,t){let i=ny(t)[0];return(i===2009||i===2012)&&(e.value=sr.oldValues[e.value]||e.value),super.set(e,t)}};sr.names=["align-items","flex-align","box-align"];sr.oldValues={"flex-end":"end","flex-start":"start"};sy.exports=sr});var ly=x((EI,oy)=>{l();var WA=q(),Vl=class extends WA{set(e,t){return t==="-ms-"&&e.value==="contain"&&(e.value="element"),super.set(e,t)}insert(e,t,i){if(!(e.value==="all"&&t==="-ms-"))return super.insert(e,t,i)}};Vl.names=["user-select"];oy.exports=Vl});var cy=x((TI,fy)=>{l();var uy=me(),GA=q(),Wl=class extends GA{normalize(){return"flex-shrink"}prefixed(e,t){let i;return[i,t]=uy(t),i===2012?t+"flex-negative":super.prefixed(e,t)}set(e,t){let i;if([i,t]=uy(t),i===2012||i==="final")return super.set(e,t)}};Wl.names=["flex-shrink","flex-negative"];fy.exports=Wl});var dy=x((PI,py)=>{l();var HA=q(),Gl=class extends HA{prefixed(e,t){return`${t}column-${e}`}normalize(e){return e.includes("inside")?"break-inside":e.includes("before")?"break-before":"break-after"}set(e,t){return(e.prop==="break-inside"&&e.value==="avoid-column"||e.value==="avoid-page")&&(e.value="avoid"),super.set(e,t)}insert(e,t,i){if(e.prop!=="break-inside")return super.insert(e,t,i);if(!(/region/i.test(e.value)||/page/i.test(e.value)))return super.insert(e,t,i)}};Gl.names=["break-inside","page-break-inside","column-break-inside","break-before","page-break-before","column-break-before","break-after","page-break-after","column-break-after"];py.exports=Gl});var my=x((DI,hy)=>{l();var YA=q(),Hl=class extends YA{prefixed(e,t){return t+"print-color-adjust"}normalize(){return"color-adjust"}};Hl.names=["color-adjust","print-color-adjust"];hy.exports=Hl});var yy=x((II,gy)=>{l();var QA=q(),ar=class extends QA{insert(e,t,i){if(t==="-ms-"){let n=this.set(this.clone(e),t);this.needCascade(e)&&(n.raws.before=this.calcBefore(i,e,t));let s="ltr";return e.parent.nodes.forEach(a=>{a.prop==="direction"&&(a.value==="rtl"||a.value==="ltr")&&(s=a.value)}),n.value=ar.msValues[s][e.value]||e.value,e.parent.insertBefore(e,n)}return super.insert(e,t,i)}};ar.names=["writing-mode"];ar.msValues={ltr:{"horizontal-tb":"lr-tb","vertical-rl":"tb-rl","vertical-lr":"tb-lr"},rtl:{"horizontal-tb":"rl-tb","vertical-rl":"bt-rl","vertical-lr":"bt-lr"}};gy.exports=ar});var wy=x((RI,by)=>{l();var JA=q(),Yl=class extends JA{set(e,t){return e.value=e.value.replace(/\s+fill(\s)/,"$1"),super.set(e,t)}};Yl.names=["border-image"];by.exports=Yl});var ky=x((qI,vy)=>{l();var xy=me(),XA=q(),or=class extends XA{prefixed(e,t){let i;return[i,t]=xy(t),i===2012?t+"flex-line-pack":super.prefixed(e,t)}normalize(){return"align-content"}set(e,t){let i=xy(t)[0];if(i===2012)return e.value=or.oldValues[e.value]||e.value,super.set(e,t);if(i==="final")return super.set(e,t)}};or.names=["align-content","flex-line-pack"];or.oldValues={"flex-end":"end","flex-start":"start","space-between":"justify","space-around":"distribute"};vy.exports=or});var Cy=x((FI,Sy)=>{l();var KA=q(),Ae=class extends KA{prefixed(e,t){return t==="-moz-"?t+(Ae.toMozilla[e]||e):super.prefixed(e,t)}normalize(e){return Ae.toNormal[e]||e}};Ae.names=["border-radius"];Ae.toMozilla={};Ae.toNormal={};for(let r of["top","bottom"])for(let e of["left","right"]){let t=`border-${r}-${e}-radius`,i=`border-radius-${r}${e}`;Ae.names.push(t),Ae.names.push(i),Ae.toMozilla[t]=i,Ae.toNormal[i]=t}Sy.exports=Ae});var Oy=x((BI,Ay)=>{l();var ZA=q(),Ql=class extends ZA{prefixed(e,t){return e.includes("-start")?t+e.replace("-block-start","-before"):t+e.replace("-block-end","-after")}normalize(e){return e.includes("-before")?e.replace("-before","-block-start"):e.replace("-after","-block-end")}};Ql.names=["border-block-start","border-block-end","margin-block-start","margin-block-end","padding-block-start","padding-block-end","border-before","border-after","margin-before","margin-after","padding-before","padding-after"];Ay.exports=Ql});var Ey=x((MI,_y)=>{l();var e6=q(),{parseTemplate:t6,warnMissedAreas:r6,getGridGap:i6,warnGridGap:n6,inheritGridGap:s6}=gt(),Jl=class extends e6{insert(e,t,i,n){if(t!=="-ms-")return super.insert(e,t,i);if(e.parent.some(g=>g.prop==="-ms-grid-rows"))return;let s=i6(e),a=s6(e,s),{rows:o,columns:u,areas:c}=t6({decl:e,gap:a||s}),f=Object.keys(c).length>0,d=Boolean(o),p=Boolean(u);return n6({gap:s,hasColumns:p,decl:e,result:n}),r6(c,e,n),(d&&p||f)&&e.cloneBefore({prop:"-ms-grid-rows",value:o,raws:{}}),p&&e.cloneBefore({prop:"-ms-grid-columns",value:u,raws:{}}),e}};Jl.names=["grid-template"];_y.exports=Jl});var Py=x((LI,Ty)=>{l();var a6=q(),Xl=class extends a6{prefixed(e,t){return t+e.replace("-inline","")}normalize(e){return e.replace(/(margin|padding|border)-(start|end)/,"$1-inline-$2")}};Xl.names=["border-inline-start","border-inline-end","margin-inline-start","margin-inline-end","padding-inline-start","padding-inline-end","border-start","border-end","margin-start","margin-end","padding-start","padding-end"];Ty.exports=Xl});var Iy=x(($I,Dy)=>{l();var o6=q(),Kl=class extends o6{check(e){return!e.value.includes("flex-")&&e.value!=="baseline"}prefixed(e,t){return t+"grid-row-align"}normalize(){return"align-self"}};Kl.names=["grid-row-align"];Dy.exports=Kl});var qy=x((NI,Ry)=>{l();var l6=q(),lr=class extends l6{keyframeParents(e){let{parent:t}=e;for(;t;){if(t.type==="atrule"&&t.name==="keyframes")return!0;({parent:t}=t)}return!1}contain3d(e){if(e.prop==="transform-origin")return!1;for(let t of lr.functions3d)if(e.value.includes(`${t}(`))return!0;return!1}set(e,t){return e=super.set(e,t),t==="-ms-"&&(e.value=e.value.replace(/rotatez/gi,"rotate")),e}insert(e,t,i){if(t==="-ms-"){if(!this.contain3d(e)&&!this.keyframeParents(e))return super.insert(e,t,i)}else if(t==="-o-"){if(!this.contain3d(e))return super.insert(e,t,i)}else return super.insert(e,t,i)}};lr.names=["transform","transform-origin"];lr.functions3d=["matrix3d","translate3d","translateZ","scale3d","scaleZ","rotate3d","rotateX","rotateY","perspective"];Ry.exports=lr});var My=x((zI,By)=>{l();var Fy=me(),u6=q(),Zl=class extends u6{normalize(){return"flex-direction"}insert(e,t,i){let n;if([n,t]=Fy(t),n!==2009)return super.insert(e,t,i);if(e.parent.some(f=>f.prop===t+"box-orient"||f.prop===t+"box-direction"))return;let a=e.value,o,u;a==="inherit"||a==="initial"||a==="unset"?(o=a,u=a):(o=a.includes("row")?"horizontal":"vertical",u=a.includes("reverse")?"reverse":"normal");let c=this.clone(e);return c.prop=t+"box-orient",c.value=o,this.needCascade(e)&&(c.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,c),c=this.clone(e),c.prop=t+"box-direction",c.value=u,this.needCascade(e)&&(c.raws.before=this.calcBefore(i,e,t)),e.parent.insertBefore(e,c)}old(e,t){let i;return[i,t]=Fy(t),i===2009?[t+"box-orient",t+"box-direction"]:super.old(e,t)}};Zl.names=["flex-direction","box-direction","box-orient"];By.exports=Zl});var $y=x((jI,Ly)=>{l();var f6=q(),eu=class extends f6{check(e){return e.value==="pixelated"}prefixed(e,t){return t==="-ms-"?"-ms-interpolation-mode":super.prefixed(e,t)}set(e,t){return t!=="-ms-"?super.set(e,t):(e.prop="-ms-interpolation-mode",e.value="nearest-neighbor",e)}normalize(){return"image-rendering"}process(e,t){return super.process(e,t)}};eu.names=["image-rendering","interpolation-mode"];Ly.exports=eu});var zy=x((UI,Ny)=>{l();var c6=q(),p6=ce(),tu=class extends c6{constructor(e,t,i){super(e,t,i);this.prefixes&&(this.prefixes=p6.uniq(this.prefixes.map(n=>n==="-ms-"?"-webkit-":n)))}};tu.names=["backdrop-filter"];Ny.exports=tu});var Uy=x((VI,jy)=>{l();var d6=q(),h6=ce(),ru=class extends d6{constructor(e,t,i){super(e,t,i);this.prefixes&&(this.prefixes=h6.uniq(this.prefixes.map(n=>n==="-ms-"?"-webkit-":n)))}check(e){return e.value.toLowerCase()==="text"}};ru.names=["background-clip"];jy.exports=ru});var Wy=x((WI,Vy)=>{l();var m6=q(),g6=["none","underline","overline","line-through","blink","inherit","initial","unset"],iu=class extends m6{check(e){return e.value.split(/\s+/).some(t=>!g6.includes(t))}};iu.names=["text-decoration"];Vy.exports=iu});var Yy=x((GI,Hy)=>{l();var Gy=me(),y6=q(),ur=class extends y6{prefixed(e,t){let i;return[i,t]=Gy(t),i===2009?t+"box-pack":i===2012?t+"flex-pack":super.prefixed(e,t)}normalize(){return"justify-content"}set(e,t){let i=Gy(t)[0];if(i===2009||i===2012){let n=ur.oldValues[e.value]||e.value;if(e.value=n,i!==2009||n!=="distribute")return super.set(e,t)}else if(i==="final")return super.set(e,t)}};ur.names=["justify-content","flex-pack","box-pack"];ur.oldValues={"flex-end":"end","flex-start":"start","space-between":"justify","space-around":"distribute"};Hy.exports=ur});var Jy=x((HI,Qy)=>{l();var b6=q(),nu=class extends b6{set(e,t){let i=e.value.toLowerCase();return t==="-webkit-"&&!i.includes(" ")&&i!=="contain"&&i!=="cover"&&(e.value=e.value+" "+e.value),super.set(e,t)}};nu.names=["background-size"];Qy.exports=nu});var Ky=x((YI,Xy)=>{l();var w6=q(),su=gt(),au=class extends w6{insert(e,t,i){if(t!=="-ms-")return super.insert(e,t,i);let n=su.parse(e),[s,a]=su.translate(n,0,1);n[0]&&n[0].includes("span")&&(a=n[0].join("").replace(/\D/g,"")),[[e.prop,s],[`${e.prop}-span`,a]].forEach(([u,c])=>{su.insertDecl(e,u,c)})}};au.names=["grid-row","grid-column"];Xy.exports=au});var t1=x((QI,e1)=>{l();var x6=q(),{prefixTrackProp:Zy,prefixTrackValue:v6,autoplaceGridItems:k6,getGridGap:S6,inheritGridGap:C6}=gt(),A6=Al(),ou=class extends x6{prefixed(e,t){return t==="-ms-"?Zy({prop:e,prefix:t}):super.prefixed(e,t)}normalize(e){return e.replace(/^grid-(rows|columns)/,"grid-template-$1")}insert(e,t,i,n){if(t!=="-ms-")return super.insert(e,t,i);let{parent:s,prop:a,value:o}=e,u=a.includes("rows"),c=a.includes("columns"),f=s.some(k=>k.prop==="grid-template"||k.prop==="grid-template-areas");if(f&&u)return!1;let d=new A6({options:{}}),p=d.gridStatus(s,n),g=S6(e);g=C6(e,g)||g;let b=u?g.row:g.column;(p==="no-autoplace"||p===!0)&&!f&&(b=null);let v=v6({value:o,gap:b});e.cloneBefore({prop:Zy({prop:a,prefix:t}),value:v});let y=s.nodes.find(k=>k.prop==="grid-auto-flow"),w="row";if(y&&!d.disabled(y,n)&&(w=y.value.trim()),p==="autoplace"){let k=s.nodes.find(O=>O.prop==="grid-template-rows");if(!k&&f)return;if(!k&&!f){e.warn(n,"Autoplacement does not work without grid-template-rows property");return}!s.nodes.find(O=>O.prop==="grid-template-columns")&&!f&&e.warn(n,"Autoplacement does not work without grid-template-columns property"),c&&!f&&k6(e,n,g,w)}}};ou.names=["grid-template-rows","grid-template-columns","grid-rows","grid-columns"];e1.exports=ou});var i1=x((JI,r1)=>{l();var O6=q(),lu=class extends O6{check(e){return!e.value.includes("flex-")&&e.value!=="baseline"}prefixed(e,t){return t+"grid-column-align"}normalize(){return"justify-self"}};lu.names=["grid-column-align"];r1.exports=lu});var s1=x((XI,n1)=>{l();var _6=q(),uu=class extends _6{prefixed(e,t){return t+"scroll-chaining"}normalize(){return"overscroll-behavior"}set(e,t){return e.value==="auto"?e.value="chained":(e.value==="none"||e.value==="contain")&&(e.value="none"),super.set(e,t)}};uu.names=["overscroll-behavior","scroll-chaining"];n1.exports=uu});var l1=x((KI,o1)=>{l();var E6=q(),{parseGridAreas:T6,warnMissedAreas:P6,prefixTrackProp:D6,prefixTrackValue:a1,getGridGap:I6,warnGridGap:R6,inheritGridGap:q6}=gt();function F6(r){return r.trim().slice(1,-1).split(/["']\s*["']?/g)}var fu=class extends E6{insert(e,t,i,n){if(t!=="-ms-")return super.insert(e,t,i);let s=!1,a=!1,o=e.parent,u=I6(e);u=q6(e,u)||u,o.walkDecls(/-ms-grid-rows/,d=>d.remove()),o.walkDecls(/grid-template-(rows|columns)/,d=>{if(d.prop==="grid-template-rows"){a=!0;let{prop:p,value:g}=d;d.cloneBefore({prop:D6({prop:p,prefix:t}),value:a1({value:g,gap:u.row})})}else s=!0});let c=F6(e.value);s&&!a&&u.row&&c.length>1&&e.cloneBefore({prop:"-ms-grid-rows",value:a1({value:`repeat(${c.length}, auto)`,gap:u.row}),raws:{}}),R6({gap:u,hasColumns:s,decl:e,result:n});let f=T6({rows:c,gap:u});return P6(f,e,n),e}};fu.names=["grid-template-areas"];o1.exports=fu});var f1=x((ZI,u1)=>{l();var B6=q(),cu=class extends B6{set(e,t){return t==="-webkit-"&&(e.value=e.value.replace(/\s*(right|left)\s*/i,"")),super.set(e,t)}};cu.names=["text-emphasis-position"];u1.exports=cu});var p1=x((e7,c1)=>{l();var M6=q(),pu=class extends M6{set(e,t){return e.prop==="text-decoration-skip-ink"&&e.value==="auto"?(e.prop=t+"text-decoration-skip",e.value="ink",e):super.set(e,t)}};pu.names=["text-decoration-skip-ink","text-decoration-skip"];c1.exports=pu});var b1=x((t7,y1)=>{l();"use strict";y1.exports={wrap:d1,limit:h1,validate:m1,test:du,curry:L6,name:g1};function d1(r,e,t){var i=e-r;return((t-r)%i+i)%i+r}function h1(r,e,t){return Math.max(r,Math.min(e,t))}function m1(r,e,t,i,n){if(!du(r,e,t,i,n))throw new Error(t+" is outside of range ["+r+","+e+")");return t}function du(r,e,t,i,n){return!(te||n&&t===e||i&&t===r)}function g1(r,e,t,i){return(t?"(":"[")+r+","+e+(i?")":"]")}function L6(r,e,t,i){var n=g1.bind(null,r,e,t,i);return{wrap:d1.bind(null,r,e),limit:h1.bind(null,r,e),validate:function(s){return m1(r,e,s,t,i)},test:function(s){return du(r,e,s,t,i)},toString:n,name:n}}});var v1=x((r7,x1)=>{l();var hu=ls(),$6=b1(),N6=er(),z6=Ce(),j6=ce(),w1=/top|left|right|bottom/gi,Ke=class extends z6{replace(e,t){let i=hu(e);for(let n of i.nodes)if(n.type==="function"&&n.value===this.name)if(n.nodes=this.newDirection(n.nodes),n.nodes=this.normalize(n.nodes),t==="-webkit- old"){if(!this.oldWebkit(n))return!1}else n.nodes=this.convertDirection(n.nodes),n.value=t+n.value;return i.toString()}replaceFirst(e,...t){return t.map(n=>n===" "?{type:"space",value:n}:{type:"word",value:n}).concat(e.slice(1))}normalizeUnit(e,t){return`${parseFloat(e)/t*360}deg`}normalize(e){if(!e[0])return e;if(/-?\d+(.\d+)?grad/.test(e[0].value))e[0].value=this.normalizeUnit(e[0].value,400);else if(/-?\d+(.\d+)?rad/.test(e[0].value))e[0].value=this.normalizeUnit(e[0].value,2*Math.PI);else if(/-?\d+(.\d+)?turn/.test(e[0].value))e[0].value=this.normalizeUnit(e[0].value,1);else if(e[0].value.includes("deg")){let t=parseFloat(e[0].value);t=$6.wrap(0,360,t),e[0].value=`${t}deg`}return e[0].value==="0deg"?e=this.replaceFirst(e,"to"," ","top"):e[0].value==="90deg"?e=this.replaceFirst(e,"to"," ","right"):e[0].value==="180deg"?e=this.replaceFirst(e,"to"," ","bottom"):e[0].value==="270deg"&&(e=this.replaceFirst(e,"to"," ","left")),e}newDirection(e){if(e[0].value==="to"||(w1.lastIndex=0,!w1.test(e[0].value)))return e;e.unshift({type:"word",value:"to"},{type:"space",value:" "});for(let t=2;t0&&(e[0].value==="to"?this.fixDirection(e):e[0].value.includes("deg")?this.fixAngle(e):this.isRadial(e)&&this.fixRadial(e)),e}fixDirection(e){e.splice(0,2);for(let t of e){if(t.type==="div")break;t.type==="word"&&(t.value=this.revertDirection(t.value))}}fixAngle(e){let t=e[0].value;t=parseFloat(t),t=Math.abs(450-t)%360,t=this.roundFloat(t,3),e[0].value=`${t}deg`}fixRadial(e){let t=[],i=[],n,s,a,o,u;for(o=0;o{l();var U6=er(),V6=Ce();function k1(r){return new RegExp(`(^|[\\s,(])(${r}($|[\\s),]))`,"gi")}var mu=class extends V6{regexp(){return this.regexpCache||(this.regexpCache=k1(this.name)),this.regexpCache}isStretch(){return this.name==="stretch"||this.name==="fill"||this.name==="fill-available"}replace(e,t){return t==="-moz-"&&this.isStretch()?e.replace(this.regexp(),"$1-moz-available$3"):t==="-webkit-"&&this.isStretch()?e.replace(this.regexp(),"$1-webkit-fill-available$3"):super.replace(e,t)}old(e){let t=e+this.name;return this.isStretch()&&(e==="-moz-"?t="-moz-available":e==="-webkit-"&&(t="-webkit-fill-available")),new U6(this.name,t,t,k1(t))}add(e,t){if(!(e.prop.includes("grid")&&t!=="-webkit-"))return super.add(e,t)}};mu.names=["max-content","min-content","fit-content","fill","fill-available","stretch"];S1.exports=mu});var _1=x((n7,O1)=>{l();var A1=er(),W6=Ce(),gu=class extends W6{replace(e,t){return t==="-webkit-"?e.replace(this.regexp(),"$1-webkit-optimize-contrast"):t==="-moz-"?e.replace(this.regexp(),"$1-moz-crisp-edges"):super.replace(e,t)}old(e){return e==="-webkit-"?new A1(this.name,"-webkit-optimize-contrast"):e==="-moz-"?new A1(this.name,"-moz-crisp-edges"):super.old(e)}};gu.names=["pixelated"];O1.exports=gu});var T1=x((s7,E1)=>{l();var G6=Ce(),yu=class extends G6{replace(e,t){let i=super.replace(e,t);return t==="-webkit-"&&(i=i.replace(/("[^"]+"|'[^']+')(\s+\d+\w)/gi,"url($1)$2")),i}};yu.names=["image-set"];E1.exports=yu});var D1=x((a7,P1)=>{l();var H6=ye().list,Y6=Ce(),bu=class extends Y6{replace(e,t){return H6.space(e).map(i=>{if(i.slice(0,+this.name.length+1)!==this.name+"(")return i;let n=i.lastIndexOf(")"),s=i.slice(n+1),a=i.slice(this.name.length+1,n);if(t==="-webkit-"){let o=a.match(/\d*.?\d+%?/);o?(a=a.slice(o[0].length).trim(),a+=`, ${o[0]}`):a+=", 0.5"}return t+this.name+"("+a+")"+s}).join(" ")}};bu.names=["cross-fade"];P1.exports=bu});var R1=x((o7,I1)=>{l();var Q6=me(),J6=er(),X6=Ce(),wu=class extends X6{constructor(e,t){super(e,t);e==="display-flex"&&(this.name="flex")}check(e){return e.prop==="display"&&e.value===this.name}prefixed(e){let t,i;return[t,e]=Q6(e),t===2009?this.name==="flex"?i="box":i="inline-box":t===2012?this.name==="flex"?i="flexbox":i="inline-flexbox":t==="final"&&(i=this.name),e+i}replace(e,t){return this.prefixed(t)}old(e){let t=this.prefixed(e);if(!!t)return new J6(this.name,t)}};wu.names=["display-flex","inline-flex"];I1.exports=wu});var F1=x((l7,q1)=>{l();var K6=Ce(),xu=class extends K6{constructor(e,t){super(e,t);e==="display-grid"&&(this.name="grid")}check(e){return e.prop==="display"&&e.value===this.name}};xu.names=["display-grid","inline-grid"];q1.exports=xu});var M1=x((u7,B1)=>{l();var Z6=Ce(),vu=class extends Z6{constructor(e,t){super(e,t);e==="filter-function"&&(this.name="filter")}};vu.names=["filter","filter-function"];B1.exports=vu});var z1=x((f7,N1)=>{l();var L1=hi(),F=q(),$1=xg(),eO=Lg(),tO=Al(),rO=i0(),ku=ht(),fr=tr(),iO=c0(),Ne=Ce(),cr=ce(),nO=d0(),sO=m0(),aO=y0(),oO=w0(),lO=C0(),uO=_0(),fO=T0(),cO=D0(),pO=R0(),dO=F0(),hO=M0(),mO=$0(),gO=z0(),yO=U0(),bO=W0(),wO=Y0(),xO=J0(),vO=Z0(),kO=ty(),SO=iy(),CO=ay(),AO=ly(),OO=cy(),_O=dy(),EO=my(),TO=yy(),PO=wy(),DO=ky(),IO=Cy(),RO=Oy(),qO=Ey(),FO=Py(),BO=Iy(),MO=qy(),LO=My(),$O=$y(),NO=zy(),zO=Uy(),jO=Wy(),UO=Yy(),VO=Jy(),WO=Ky(),GO=t1(),HO=i1(),YO=s1(),QO=l1(),JO=f1(),XO=p1(),KO=v1(),ZO=C1(),e_=_1(),t_=T1(),r_=D1(),i_=R1(),n_=F1(),s_=M1();fr.hack(nO);fr.hack(sO);fr.hack(aO);fr.hack(oO);F.hack(lO);F.hack(uO);F.hack(fO);F.hack(cO);F.hack(pO);F.hack(dO);F.hack(hO);F.hack(mO);F.hack(gO);F.hack(yO);F.hack(bO);F.hack(wO);F.hack(xO);F.hack(vO);F.hack(kO);F.hack(SO);F.hack(CO);F.hack(AO);F.hack(OO);F.hack(_O);F.hack(EO);F.hack(TO);F.hack(PO);F.hack(DO);F.hack(IO);F.hack(RO);F.hack(qO);F.hack(FO);F.hack(BO);F.hack(MO);F.hack(LO);F.hack($O);F.hack(NO);F.hack(zO);F.hack(jO);F.hack(UO);F.hack(VO);F.hack(WO);F.hack(GO);F.hack(HO);F.hack(YO);F.hack(QO);F.hack(JO);F.hack(XO);Ne.hack(KO);Ne.hack(ZO);Ne.hack(e_);Ne.hack(t_);Ne.hack(r_);Ne.hack(i_);Ne.hack(n_);Ne.hack(s_);var Su=new Map,gi=class{constructor(e,t,i={}){this.data=e,this.browsers=t,this.options=i,[this.add,this.remove]=this.preprocess(this.select(this.data)),this.transition=new eO(this),this.processor=new tO(this)}cleaner(){if(this.cleanerCache)return this.cleanerCache;if(this.browsers.selected.length){let e=new ku(this.browsers.data,[]);this.cleanerCache=new gi(this.data,e,this.options)}else return this;return this.cleanerCache}select(e){let t={add:{},remove:{}};for(let i in e){let n=e[i],s=n.browsers.map(u=>{let c=u.split(" ");return{browser:`${c[0]} ${c[1]}`,note:c[2]}}),a=s.filter(u=>u.note).map(u=>`${this.browsers.prefix(u.browser)} ${u.note}`);a=cr.uniq(a),s=s.filter(u=>this.browsers.isSelected(u.browser)).map(u=>{let c=this.browsers.prefix(u.browser);return u.note?`${c} ${u.note}`:c}),s=this.sort(cr.uniq(s)),this.options.flexbox==="no-2009"&&(s=s.filter(u=>!u.includes("2009")));let o=n.browsers.map(u=>this.browsers.prefix(u));n.mistakes&&(o=o.concat(n.mistakes)),o=o.concat(a),o=cr.uniq(o),s.length?(t.add[i]=s,s.length!s.includes(u)))):t.remove[i]=o}return t}sort(e){return e.sort((t,i)=>{let n=cr.removeNote(t).length,s=cr.removeNote(i).length;return n===s?i.length-t.length:s-n})}preprocess(e){let t={selectors:[],"@supports":new rO(gi,this)};for(let n in e.add){let s=e.add[n];if(n==="@keyframes"||n==="@viewport")t[n]=new iO(n,s,this);else if(n==="@resolution")t[n]=new $1(n,s,this);else if(this.data[n].selector)t.selectors.push(fr.load(n,s,this));else{let a=this.data[n].props;if(a){let o=Ne.load(n,s,this);for(let u of a)t[u]||(t[u]={values:[]}),t[u].values.push(o)}else{let o=t[n]&&t[n].values||[];t[n]=F.load(n,s,this),t[n].values=o}}}let i={selectors:[]};for(let n in e.remove){let s=e.remove[n];if(this.data[n].selector){let a=fr.load(n,s);for(let o of s)i.selectors.push(a.old(o))}else if(n==="@keyframes"||n==="@viewport")for(let a of s){let o=`@${a}${n.slice(1)}`;i[o]={remove:!0}}else if(n==="@resolution")i[n]=new $1(n,s,this);else{let a=this.data[n].props;if(a){let o=Ne.load(n,[],this);for(let u of s){let c=o.old(u);if(c)for(let f of a)i[f]||(i[f]={}),i[f].values||(i[f].values=[]),i[f].values.push(c)}}else for(let o of s){let u=this.decl(n).old(n,o);if(n==="align-self"){let c=t[n]&&t[n].prefixes;if(c){if(o==="-webkit- 2009"&&c.includes("-webkit-"))continue;if(o==="-webkit-"&&c.includes("-webkit- 2009"))continue}}for(let c of u)i[c]||(i[c]={}),i[c].remove=!0}}}return[t,i]}decl(e){return Su.has(e)||Su.set(e,F.load(e)),Su.get(e)}unprefixed(e){let t=this.normalize(L1.unprefixed(e));return t==="flex-direction"&&(t="flex-flow"),t}normalize(e){return this.decl(e).normalize(e)}prefixed(e,t){return e=L1.unprefixed(e),this.decl(e).prefixed(e,t)}values(e,t){let i=this[e],n=i["*"]&&i["*"].values,s=i[t]&&i[t].values;return n&&s?cr.uniq(n.concat(s)):n||s||[]}group(e){let t=e.parent,i=t.index(e),{length:n}=t.nodes,s=this.unprefixed(e.prop),a=(o,u)=>{for(i+=o;i>=0&&i{l();j1.exports={"backdrop-filter":{feature:"css-backdrop-filter",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5","safari 16.5"]},element:{props:["background","background-image","border-image","mask","list-style","list-style-image","content","mask-image"],feature:"css-element-function",browsers:["firefox 114"]},"user-select":{mistakes:["-khtml-"],feature:"user-select-none",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5","safari 16.5"]},"background-clip":{feature:"background-clip-text",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},hyphens:{feature:"css-hyphens",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5","safari 16.5"]},fill:{props:["width","min-width","max-width","height","min-height","max-height","inline-size","min-inline-size","max-inline-size","block-size","min-block-size","max-block-size","grid","grid-template","grid-template-rows","grid-template-columns","grid-auto-columns","grid-auto-rows"],feature:"intrinsic-width",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"fill-available":{props:["width","min-width","max-width","height","min-height","max-height","inline-size","min-inline-size","max-inline-size","block-size","min-block-size","max-block-size","grid","grid-template","grid-template-rows","grid-template-columns","grid-auto-columns","grid-auto-rows"],feature:"intrinsic-width",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},stretch:{props:["width","min-width","max-width","height","min-height","max-height","inline-size","min-inline-size","max-inline-size","block-size","min-block-size","max-block-size","grid","grid-template","grid-template-rows","grid-template-columns","grid-auto-columns","grid-auto-rows"],feature:"intrinsic-width",browsers:["firefox 114"]},"fit-content":{props:["width","min-width","max-width","height","min-height","max-height","inline-size","min-inline-size","max-inline-size","block-size","min-block-size","max-block-size","grid","grid-template","grid-template-rows","grid-template-columns","grid-auto-columns","grid-auto-rows"],feature:"intrinsic-width",browsers:["firefox 114"]},"text-decoration-style":{feature:"text-decoration",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5"]},"text-decoration-color":{feature:"text-decoration",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5"]},"text-decoration-line":{feature:"text-decoration",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5"]},"text-decoration":{feature:"text-decoration",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5"]},"text-decoration-skip":{feature:"text-decoration",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5"]},"text-decoration-skip-ink":{feature:"text-decoration",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5"]},"text-size-adjust":{feature:"text-size-adjust",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5"]},"mask-clip":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-composite":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-image":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-origin":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-repeat":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-border-repeat":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-border-source":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},mask:{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-position":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-size":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-border":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-border-outset":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-border-width":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"mask-border-slice":{feature:"css-masks",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},"clip-path":{feature:"css-clip-path",browsers:["samsung 21"]},"box-decoration-break":{feature:"css-boxdecorationbreak",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5","opera 99","safari 16.5","samsung 21"]},appearance:{feature:"css-appearance",browsers:["samsung 21"]},"image-set":{props:["background","background-image","border-image","cursor","mask","mask-image","list-style","list-style-image","content"],feature:"css-image-set",browsers:["and_uc 15.5","chrome 109","samsung 21"]},"cross-fade":{props:["background","background-image","border-image","mask","list-style","list-style-image","content","mask-image"],feature:"css-cross-fade",browsers:["and_chr 114","and_uc 15.5","chrome 109","chrome 113","chrome 114","edge 114","opera 99","samsung 21"]},isolate:{props:["unicode-bidi"],feature:"css-unicode-bidi",browsers:["ios_saf 16.1","ios_saf 16.3","ios_saf 16.4","ios_saf 16.5","safari 16.5"]},"color-adjust":{feature:"css-color-adjust",browsers:["chrome 109","chrome 113","chrome 114","edge 114","opera 99"]}}});var W1=x((p7,V1)=>{l();V1.exports={}});var Q1=x((d7,Y1)=>{l();var a_=dl(),{agents:o_}=(rs(),ts),Cu=ag(),l_=ht(),u_=z1(),f_=U1(),c_=W1(),G1={browsers:o_,prefixes:f_},H1=`
+ Replace Autoprefixer \`browsers\` option to Browserslist config.
+ Use \`browserslist\` key in \`package.json\` or \`.browserslistrc\` file.
+
+ Using \`browsers\` option can cause errors. Browserslist config can
+ be used for Babel, Autoprefixer, postcss-normalize and other tools.
+
+ If you really need to use option, rename it to \`overrideBrowserslist\`.
+
+ Learn more at:
+ https://github.com/browserslist/browserslist#readme
+ https://twitter.com/browserslist
+
+`;function p_(r){return Object.prototype.toString.apply(r)==="[object Object]"}var Au=new Map;function d_(r,e){e.browsers.selected.length!==0&&(e.add.selectors.length>0||Object.keys(e.add).length>2||r.warn(`Autoprefixer target browsers do not need any prefixes.You do not need Autoprefixer anymore.
+Check your Browserslist config to be sure that your targets are set up correctly.
+
+ Learn more at:
+ https://github.com/postcss/autoprefixer#readme
+ https://github.com/browserslist/browserslist#readme
+
+`))}Y1.exports=pr;function pr(...r){let e;if(r.length===1&&p_(r[0])?(e=r[0],r=void 0):r.length===0||r.length===1&&!r[0]?r=void 0:r.length<=2&&(Array.isArray(r[0])||!r[0])?(e=r[1],r=r[0]):typeof r[r.length-1]=="object"&&(e=r.pop()),e||(e={}),e.browser)throw new Error("Change `browser` option to `overrideBrowserslist` in Autoprefixer");if(e.browserslist)throw new Error("Change `browserslist` option to `overrideBrowserslist` in Autoprefixer");e.overrideBrowserslist?r=e.overrideBrowserslist:e.browsers&&(typeof console!="undefined"&&console.warn&&(Cu.red?console.warn(Cu.red(H1.replace(/`[^`]+`/g,n=>Cu.yellow(n.slice(1,-1))))):console.warn(H1)),r=e.browsers);let t={ignoreUnknownVersions:e.ignoreUnknownVersions,stats:e.stats,env:e.env};function i(n){let s=G1,a=new l_(s.browsers,r,n,t),o=a.selected.join(", ")+JSON.stringify(e);return Au.has(o)||Au.set(o,new u_(s.prefixes,a,e)),Au.get(o)}return{postcssPlugin:"autoprefixer",prepare(n){let s=i({from:n.opts.from,env:e.env});return{OnceExit(a){d_(n,s),e.remove!==!1&&s.processor.remove(a,n),e.add!==!1&&s.processor.add(a,n)}}},info(n){return n=n||{},n.from=n.from||h.cwd(),c_(i(n))},options:e,browsers:r}}pr.postcss=!0;pr.data=G1;pr.defaults=a_.defaults;pr.info=()=>pr().info()});var J1={};_e(J1,{default:()=>h_});var h_,X1=S(()=>{l();h_=[]});function yt(r){return Array.isArray(r)?r.map(e=>yt(e)):typeof r=="object"&&r!==null?Object.fromEntries(Object.entries(r).map(([e,t])=>[e,yt(t)])):r}var us=S(()=>{l()});var fs=x((m7,K1)=>{l();K1.exports={content:[],presets:[],darkMode:"media",theme:{accentColor:({theme:r})=>({...r("colors"),auto:"auto"}),animation:{none:"none",spin:"spin 1s linear infinite",ping:"ping 1s cubic-bezier(0, 0, 0.2, 1) infinite",pulse:"pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",bounce:"bounce 1s infinite"},aria:{busy:'busy="true"',checked:'checked="true"',disabled:'disabled="true"',expanded:'expanded="true"',hidden:'hidden="true"',pressed:'pressed="true"',readonly:'readonly="true"',required:'required="true"',selected:'selected="true"'},aspectRatio:{auto:"auto",square:"1 / 1",video:"16 / 9"},backdropBlur:({theme:r})=>r("blur"),backdropBrightness:({theme:r})=>r("brightness"),backdropContrast:({theme:r})=>r("contrast"),backdropGrayscale:({theme:r})=>r("grayscale"),backdropHueRotate:({theme:r})=>r("hueRotate"),backdropInvert:({theme:r})=>r("invert"),backdropOpacity:({theme:r})=>r("opacity"),backdropSaturate:({theme:r})=>r("saturate"),backdropSepia:({theme:r})=>r("sepia"),backgroundColor:({theme:r})=>r("colors"),backgroundImage:{none:"none","gradient-to-t":"linear-gradient(to top, var(--tw-gradient-stops))","gradient-to-tr":"linear-gradient(to top right, var(--tw-gradient-stops))","gradient-to-r":"linear-gradient(to right, var(--tw-gradient-stops))","gradient-to-br":"linear-gradient(to bottom right, var(--tw-gradient-stops))","gradient-to-b":"linear-gradient(to bottom, var(--tw-gradient-stops))","gradient-to-bl":"linear-gradient(to bottom left, var(--tw-gradient-stops))","gradient-to-l":"linear-gradient(to left, var(--tw-gradient-stops))","gradient-to-tl":"linear-gradient(to top left, var(--tw-gradient-stops))"},backgroundOpacity:({theme:r})=>r("opacity"),backgroundPosition:{bottom:"bottom",center:"center",left:"left","left-bottom":"left bottom","left-top":"left top",right:"right","right-bottom":"right bottom","right-top":"right top",top:"top"},backgroundSize:{auto:"auto",cover:"cover",contain:"contain"},blur:{0:"0",none:"",sm:"4px",DEFAULT:"8px",md:"12px",lg:"16px",xl:"24px","2xl":"40px","3xl":"64px"},borderColor:({theme:r})=>({...r("colors"),DEFAULT:r("colors.gray.200","currentColor")}),borderOpacity:({theme:r})=>r("opacity"),borderRadius:{none:"0px",sm:"0.125rem",DEFAULT:"0.25rem",md:"0.375rem",lg:"0.5rem",xl:"0.75rem","2xl":"1rem","3xl":"1.5rem",full:"9999px"},borderSpacing:({theme:r})=>({...r("spacing")}),borderWidth:{DEFAULT:"1px",0:"0px",2:"2px",4:"4px",8:"8px"},boxShadow:{sm:"0 1px 2px 0 rgb(0 0 0 / 0.05)",DEFAULT:"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",md:"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",lg:"0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",xl:"0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)","2xl":"0 25px 50px -12px rgb(0 0 0 / 0.25)",inner:"inset 0 2px 4px 0 rgb(0 0 0 / 0.05)",none:"none"},boxShadowColor:({theme:r})=>r("colors"),brightness:{0:"0",50:".5",75:".75",90:".9",95:".95",100:"1",105:"1.05",110:"1.1",125:"1.25",150:"1.5",200:"2"},caretColor:({theme:r})=>r("colors"),colors:({colors:r})=>({inherit:r.inherit,current:r.current,transparent:r.transparent,black:r.black,white:r.white,slate:r.slate,gray:r.gray,zinc:r.zinc,neutral:r.neutral,stone:r.stone,red:r.red,orange:r.orange,amber:r.amber,yellow:r.yellow,lime:r.lime,green:r.green,emerald:r.emerald,teal:r.teal,cyan:r.cyan,sky:r.sky,blue:r.blue,indigo:r.indigo,violet:r.violet,purple:r.purple,fuchsia:r.fuchsia,pink:r.pink,rose:r.rose}),columns:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12","3xs":"16rem","2xs":"18rem",xs:"20rem",sm:"24rem",md:"28rem",lg:"32rem",xl:"36rem","2xl":"42rem","3xl":"48rem","4xl":"56rem","5xl":"64rem","6xl":"72rem","7xl":"80rem"},container:{},content:{none:"none"},contrast:{0:"0",50:".5",75:".75",100:"1",125:"1.25",150:"1.5",200:"2"},cursor:{auto:"auto",default:"default",pointer:"pointer",wait:"wait",text:"text",move:"move",help:"help","not-allowed":"not-allowed",none:"none","context-menu":"context-menu",progress:"progress",cell:"cell",crosshair:"crosshair","vertical-text":"vertical-text",alias:"alias",copy:"copy","no-drop":"no-drop",grab:"grab",grabbing:"grabbing","all-scroll":"all-scroll","col-resize":"col-resize","row-resize":"row-resize","n-resize":"n-resize","e-resize":"e-resize","s-resize":"s-resize","w-resize":"w-resize","ne-resize":"ne-resize","nw-resize":"nw-resize","se-resize":"se-resize","sw-resize":"sw-resize","ew-resize":"ew-resize","ns-resize":"ns-resize","nesw-resize":"nesw-resize","nwse-resize":"nwse-resize","zoom-in":"zoom-in","zoom-out":"zoom-out"},divideColor:({theme:r})=>r("borderColor"),divideOpacity:({theme:r})=>r("borderOpacity"),divideWidth:({theme:r})=>r("borderWidth"),dropShadow:{sm:"0 1px 1px rgb(0 0 0 / 0.05)",DEFAULT:["0 1px 2px rgb(0 0 0 / 0.1)","0 1px 1px rgb(0 0 0 / 0.06)"],md:["0 4px 3px rgb(0 0 0 / 0.07)","0 2px 2px rgb(0 0 0 / 0.06)"],lg:["0 10px 8px rgb(0 0 0 / 0.04)","0 4px 3px rgb(0 0 0 / 0.1)"],xl:["0 20px 13px rgb(0 0 0 / 0.03)","0 8px 5px rgb(0 0 0 / 0.08)"],"2xl":"0 25px 25px rgb(0 0 0 / 0.15)",none:"0 0 #0000"},fill:({theme:r})=>({none:"none",...r("colors")}),flex:{1:"1 1 0%",auto:"1 1 auto",initial:"0 1 auto",none:"none"},flexBasis:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%"}),flexGrow:{0:"0",DEFAULT:"1"},flexShrink:{0:"0",DEFAULT:"1"},fontFamily:{sans:["ui-sans-serif","system-ui","sans-serif",'"Apple Color Emoji"','"Segoe UI Emoji"','"Segoe UI Symbol"','"Noto Color Emoji"'],serif:["ui-serif","Georgia","Cambria",'"Times New Roman"',"Times","serif"],mono:["ui-monospace","SFMono-Regular","Menlo","Monaco","Consolas",'"Liberation Mono"','"Courier New"',"monospace"]},fontSize:{xs:["0.75rem",{lineHeight:"1rem"}],sm:["0.875rem",{lineHeight:"1.25rem"}],base:["1rem",{lineHeight:"1.5rem"}],lg:["1.125rem",{lineHeight:"1.75rem"}],xl:["1.25rem",{lineHeight:"1.75rem"}],"2xl":["1.5rem",{lineHeight:"2rem"}],"3xl":["1.875rem",{lineHeight:"2.25rem"}],"4xl":["2.25rem",{lineHeight:"2.5rem"}],"5xl":["3rem",{lineHeight:"1"}],"6xl":["3.75rem",{lineHeight:"1"}],"7xl":["4.5rem",{lineHeight:"1"}],"8xl":["6rem",{lineHeight:"1"}],"9xl":["8rem",{lineHeight:"1"}]},fontWeight:{thin:"100",extralight:"200",light:"300",normal:"400",medium:"500",semibold:"600",bold:"700",extrabold:"800",black:"900"},gap:({theme:r})=>r("spacing"),gradientColorStops:({theme:r})=>r("colors"),gradientColorStopPositions:{"0%":"0%","5%":"5%","10%":"10%","15%":"15%","20%":"20%","25%":"25%","30%":"30%","35%":"35%","40%":"40%","45%":"45%","50%":"50%","55%":"55%","60%":"60%","65%":"65%","70%":"70%","75%":"75%","80%":"80%","85%":"85%","90%":"90%","95%":"95%","100%":"100%"},grayscale:{0:"0",DEFAULT:"100%"},gridAutoColumns:{auto:"auto",min:"min-content",max:"max-content",fr:"minmax(0, 1fr)"},gridAutoRows:{auto:"auto",min:"min-content",max:"max-content",fr:"minmax(0, 1fr)"},gridColumn:{auto:"auto","span-1":"span 1 / span 1","span-2":"span 2 / span 2","span-3":"span 3 / span 3","span-4":"span 4 / span 4","span-5":"span 5 / span 5","span-6":"span 6 / span 6","span-7":"span 7 / span 7","span-8":"span 8 / span 8","span-9":"span 9 / span 9","span-10":"span 10 / span 10","span-11":"span 11 / span 11","span-12":"span 12 / span 12","span-full":"1 / -1"},gridColumnEnd:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13"},gridColumnStart:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13"},gridRow:{auto:"auto","span-1":"span 1 / span 1","span-2":"span 2 / span 2","span-3":"span 3 / span 3","span-4":"span 4 / span 4","span-5":"span 5 / span 5","span-6":"span 6 / span 6","span-7":"span 7 / span 7","span-8":"span 8 / span 8","span-9":"span 9 / span 9","span-10":"span 10 / span 10","span-11":"span 11 / span 11","span-12":"span 12 / span 12","span-full":"1 / -1"},gridRowEnd:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13"},gridRowStart:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13"},gridTemplateColumns:{none:"none",subgrid:"subgrid",1:"repeat(1, minmax(0, 1fr))",2:"repeat(2, minmax(0, 1fr))",3:"repeat(3, minmax(0, 1fr))",4:"repeat(4, minmax(0, 1fr))",5:"repeat(5, minmax(0, 1fr))",6:"repeat(6, minmax(0, 1fr))",7:"repeat(7, minmax(0, 1fr))",8:"repeat(8, minmax(0, 1fr))",9:"repeat(9, minmax(0, 1fr))",10:"repeat(10, minmax(0, 1fr))",11:"repeat(11, minmax(0, 1fr))",12:"repeat(12, minmax(0, 1fr))"},gridTemplateRows:{none:"none",subgrid:"subgrid",1:"repeat(1, minmax(0, 1fr))",2:"repeat(2, minmax(0, 1fr))",3:"repeat(3, minmax(0, 1fr))",4:"repeat(4, minmax(0, 1fr))",5:"repeat(5, minmax(0, 1fr))",6:"repeat(6, minmax(0, 1fr))",7:"repeat(7, minmax(0, 1fr))",8:"repeat(8, minmax(0, 1fr))",9:"repeat(9, minmax(0, 1fr))",10:"repeat(10, minmax(0, 1fr))",11:"repeat(11, minmax(0, 1fr))",12:"repeat(12, minmax(0, 1fr))"},height:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%",full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content"}),hueRotate:{0:"0deg",15:"15deg",30:"30deg",60:"60deg",90:"90deg",180:"180deg"},inset:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%",full:"100%"}),invert:{0:"0",DEFAULT:"100%"},keyframes:{spin:{to:{transform:"rotate(360deg)"}},ping:{"75%, 100%":{transform:"scale(2)",opacity:"0"}},pulse:{"50%":{opacity:".5"}},bounce:{"0%, 100%":{transform:"translateY(-25%)",animationTimingFunction:"cubic-bezier(0.8,0,1,1)"},"50%":{transform:"none",animationTimingFunction:"cubic-bezier(0,0,0.2,1)"}}},letterSpacing:{tighter:"-0.05em",tight:"-0.025em",normal:"0em",wide:"0.025em",wider:"0.05em",widest:"0.1em"},lineHeight:{none:"1",tight:"1.25",snug:"1.375",normal:"1.5",relaxed:"1.625",loose:"2",3:".75rem",4:"1rem",5:"1.25rem",6:"1.5rem",7:"1.75rem",8:"2rem",9:"2.25rem",10:"2.5rem"},listStyleType:{none:"none",disc:"disc",decimal:"decimal"},listStyleImage:{none:"none"},margin:({theme:r})=>({auto:"auto",...r("spacing")}),lineClamp:{1:"1",2:"2",3:"3",4:"4",5:"5",6:"6"},maxHeight:({theme:r})=>({...r("spacing"),none:"none",full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content"}),maxWidth:({theme:r,breakpoints:e})=>({...r("spacing"),none:"none",xs:"20rem",sm:"24rem",md:"28rem",lg:"32rem",xl:"36rem","2xl":"42rem","3xl":"48rem","4xl":"56rem","5xl":"64rem","6xl":"72rem","7xl":"80rem",full:"100%",min:"min-content",max:"max-content",fit:"fit-content",prose:"65ch",...e(r("screens"))}),minHeight:({theme:r})=>({...r("spacing"),full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content"}),minWidth:({theme:r})=>({...r("spacing"),full:"100%",min:"min-content",max:"max-content",fit:"fit-content"}),objectPosition:{bottom:"bottom",center:"center",left:"left","left-bottom":"left bottom","left-top":"left top",right:"right","right-bottom":"right bottom","right-top":"right top",top:"top"},opacity:{0:"0",5:"0.05",10:"0.1",15:"0.15",20:"0.2",25:"0.25",30:"0.3",35:"0.35",40:"0.4",45:"0.45",50:"0.5",55:"0.55",60:"0.6",65:"0.65",70:"0.7",75:"0.75",80:"0.8",85:"0.85",90:"0.9",95:"0.95",100:"1"},order:{first:"-9999",last:"9999",none:"0",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12"},outlineColor:({theme:r})=>r("colors"),outlineOffset:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},outlineWidth:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},padding:({theme:r})=>r("spacing"),placeholderColor:({theme:r})=>r("colors"),placeholderOpacity:({theme:r})=>r("opacity"),ringColor:({theme:r})=>({DEFAULT:r("colors.blue.500","#3b82f6"),...r("colors")}),ringOffsetColor:({theme:r})=>r("colors"),ringOffsetWidth:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},ringOpacity:({theme:r})=>({DEFAULT:"0.5",...r("opacity")}),ringWidth:{DEFAULT:"3px",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},rotate:{0:"0deg",1:"1deg",2:"2deg",3:"3deg",6:"6deg",12:"12deg",45:"45deg",90:"90deg",180:"180deg"},saturate:{0:"0",50:".5",100:"1",150:"1.5",200:"2"},scale:{0:"0",50:".5",75:".75",90:".9",95:".95",100:"1",105:"1.05",110:"1.1",125:"1.25",150:"1.5"},screens:{sm:"640px",md:"768px",lg:"1024px",xl:"1280px","2xl":"1536px"},scrollMargin:({theme:r})=>({...r("spacing")}),scrollPadding:({theme:r})=>r("spacing"),sepia:{0:"0",DEFAULT:"100%"},skew:{0:"0deg",1:"1deg",2:"2deg",3:"3deg",6:"6deg",12:"12deg"},space:({theme:r})=>({...r("spacing")}),spacing:{px:"1px",0:"0px",.5:"0.125rem",1:"0.25rem",1.5:"0.375rem",2:"0.5rem",2.5:"0.625rem",3:"0.75rem",3.5:"0.875rem",4:"1rem",5:"1.25rem",6:"1.5rem",7:"1.75rem",8:"2rem",9:"2.25rem",10:"2.5rem",11:"2.75rem",12:"3rem",14:"3.5rem",16:"4rem",20:"5rem",24:"6rem",28:"7rem",32:"8rem",36:"9rem",40:"10rem",44:"11rem",48:"12rem",52:"13rem",56:"14rem",60:"15rem",64:"16rem",72:"18rem",80:"20rem",96:"24rem"},stroke:({theme:r})=>({none:"none",...r("colors")}),strokeWidth:{0:"0",1:"1",2:"2"},supports:{},data:{},textColor:({theme:r})=>r("colors"),textDecorationColor:({theme:r})=>r("colors"),textDecorationThickness:{auto:"auto","from-font":"from-font",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},textIndent:({theme:r})=>({...r("spacing")}),textOpacity:({theme:r})=>r("opacity"),textUnderlineOffset:{auto:"auto",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px"},transformOrigin:{center:"center",top:"top","top-right":"top right",right:"right","bottom-right":"bottom right",bottom:"bottom","bottom-left":"bottom left",left:"left","top-left":"top left"},transitionDelay:{0:"0s",75:"75ms",100:"100ms",150:"150ms",200:"200ms",300:"300ms",500:"500ms",700:"700ms",1e3:"1000ms"},transitionDuration:{DEFAULT:"150ms",0:"0s",75:"75ms",100:"100ms",150:"150ms",200:"200ms",300:"300ms",500:"500ms",700:"700ms",1e3:"1000ms"},transitionProperty:{none:"none",all:"all",DEFAULT:"color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter",colors:"color, background-color, border-color, text-decoration-color, fill, stroke",opacity:"opacity",shadow:"box-shadow",transform:"transform"},transitionTimingFunction:{DEFAULT:"cubic-bezier(0.4, 0, 0.2, 1)",linear:"linear",in:"cubic-bezier(0.4, 0, 1, 1)",out:"cubic-bezier(0, 0, 0.2, 1)","in-out":"cubic-bezier(0.4, 0, 0.2, 1)"},translate:({theme:r})=>({...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%",full:"100%"}),size:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%",min:"min-content",max:"max-content",fit:"fit-content"}),width:({theme:r})=>({auto:"auto",...r("spacing"),"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%",screen:"100vw",svw:"100svw",lvw:"100lvw",dvw:"100dvw",min:"min-content",max:"max-content",fit:"fit-content"}),willChange:{auto:"auto",scroll:"scroll-position",contents:"contents",transform:"transform"},zIndex:{auto:"auto",0:"0",10:"10",20:"20",30:"30",40:"40",50:"50"}},plugins:[]}});var eb={};_e(eb,{default:()=>m_});var Z1,m_,tb=S(()=>{l();us();Z1=J(fs()),m_=yt(Z1.default.theme)});var ib={};_e(ib,{default:()=>g_});var rb,g_,nb=S(()=>{l();us();rb=J(fs()),g_=yt(rb.default)});function Ou(r,e,t){typeof h!="undefined"&&h.env.JEST_WORKER_ID||t&&sb.has(t)||(t&&sb.add(t),console.warn(""),e.forEach(i=>console.warn(r,"-",i)))}function _u(r){return K.dim(r)}var sb,bt,cs=S(()=>{l();Tt();sb=new Set;bt={info(r,e){Ou(K.bold(K.cyan("info")),...Array.isArray(r)?[r]:[e,r])},warn(r,e){["content-problems"].includes(r)||Ou(K.bold(K.yellow("warn")),...Array.isArray(r)?[r]:[e,r])},risk(r,e){Ou(K.bold(K.magenta("risk")),...Array.isArray(r)?[r]:[e,r])}}});var ab={};_e(ab,{default:()=>Eu});function yi({version:r,from:e,to:t}){bt.warn(`${e}-color-renamed`,[`As of Tailwind CSS ${r}, \`${e}\` has been renamed to \`${t}\`.`,"Update your configuration file to silence this warning."])}var Eu,Tu=S(()=>{l();cs();Eu={inherit:"inherit",current:"currentColor",transparent:"transparent",black:"#000",white:"#fff",slate:{50:"#f8fafc",100:"#f1f5f9",200:"#e2e8f0",300:"#cbd5e1",400:"#94a3b8",500:"#64748b",600:"#475569",700:"#334155",800:"#1e293b",900:"#0f172a",950:"#020617"},gray:{50:"#f9fafb",100:"#f3f4f6",200:"#e5e7eb",300:"#d1d5db",400:"#9ca3af",500:"#6b7280",600:"#4b5563",700:"#374151",800:"#1f2937",900:"#111827",950:"#030712"},zinc:{50:"#fafafa",100:"#f4f4f5",200:"#e4e4e7",300:"#d4d4d8",400:"#a1a1aa",500:"#71717a",600:"#52525b",700:"#3f3f46",800:"#27272a",900:"#18181b",950:"#09090b"},neutral:{50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",500:"#737373",600:"#525252",700:"#404040",800:"#262626",900:"#171717",950:"#0a0a0a"},stone:{50:"#fafaf9",100:"#f5f5f4",200:"#e7e5e4",300:"#d6d3d1",400:"#a8a29e",500:"#78716c",600:"#57534e",700:"#44403c",800:"#292524",900:"#1c1917",950:"#0c0a09"},red:{50:"#fef2f2",100:"#fee2e2",200:"#fecaca",300:"#fca5a5",400:"#f87171",500:"#ef4444",600:"#dc2626",700:"#b91c1c",800:"#991b1b",900:"#7f1d1d",950:"#450a0a"},orange:{50:"#fff7ed",100:"#ffedd5",200:"#fed7aa",300:"#fdba74",400:"#fb923c",500:"#f97316",600:"#ea580c",700:"#c2410c",800:"#9a3412",900:"#7c2d12",950:"#431407"},amber:{50:"#fffbeb",100:"#fef3c7",200:"#fde68a",300:"#fcd34d",400:"#fbbf24",500:"#f59e0b",600:"#d97706",700:"#b45309",800:"#92400e",900:"#78350f",950:"#451a03"},yellow:{50:"#fefce8",100:"#fef9c3",200:"#fef08a",300:"#fde047",400:"#facc15",500:"#eab308",600:"#ca8a04",700:"#a16207",800:"#854d0e",900:"#713f12",950:"#422006"},lime:{50:"#f7fee7",100:"#ecfccb",200:"#d9f99d",300:"#bef264",400:"#a3e635",500:"#84cc16",600:"#65a30d",700:"#4d7c0f",800:"#3f6212",900:"#365314",950:"#1a2e05"},green:{50:"#f0fdf4",100:"#dcfce7",200:"#bbf7d0",300:"#86efac",400:"#4ade80",500:"#22c55e",600:"#16a34a",700:"#15803d",800:"#166534",900:"#14532d",950:"#052e16"},emerald:{50:"#ecfdf5",100:"#d1fae5",200:"#a7f3d0",300:"#6ee7b7",400:"#34d399",500:"#10b981",600:"#059669",700:"#047857",800:"#065f46",900:"#064e3b",950:"#022c22"},teal:{50:"#f0fdfa",100:"#ccfbf1",200:"#99f6e4",300:"#5eead4",400:"#2dd4bf",500:"#14b8a6",600:"#0d9488",700:"#0f766e",800:"#115e59",900:"#134e4a",950:"#042f2e"},cyan:{50:"#ecfeff",100:"#cffafe",200:"#a5f3fc",300:"#67e8f9",400:"#22d3ee",500:"#06b6d4",600:"#0891b2",700:"#0e7490",800:"#155e75",900:"#164e63",950:"#083344"},sky:{50:"#f0f9ff",100:"#e0f2fe",200:"#bae6fd",300:"#7dd3fc",400:"#38bdf8",500:"#0ea5e9",600:"#0284c7",700:"#0369a1",800:"#075985",900:"#0c4a6e",950:"#082f49"},blue:{50:"#eff6ff",100:"#dbeafe",200:"#bfdbfe",300:"#93c5fd",400:"#60a5fa",500:"#3b82f6",600:"#2563eb",700:"#1d4ed8",800:"#1e40af",900:"#1e3a8a",950:"#172554"},indigo:{50:"#eef2ff",100:"#e0e7ff",200:"#c7d2fe",300:"#a5b4fc",400:"#818cf8",500:"#6366f1",600:"#4f46e5",700:"#4338ca",800:"#3730a3",900:"#312e81",950:"#1e1b4b"},violet:{50:"#f5f3ff",100:"#ede9fe",200:"#ddd6fe",300:"#c4b5fd",400:"#a78bfa",500:"#8b5cf6",600:"#7c3aed",700:"#6d28d9",800:"#5b21b6",900:"#4c1d95",950:"#2e1065"},purple:{50:"#faf5ff",100:"#f3e8ff",200:"#e9d5ff",300:"#d8b4fe",400:"#c084fc",500:"#a855f7",600:"#9333ea",700:"#7e22ce",800:"#6b21a8",900:"#581c87",950:"#3b0764"},fuchsia:{50:"#fdf4ff",100:"#fae8ff",200:"#f5d0fe",300:"#f0abfc",400:"#e879f9",500:"#d946ef",600:"#c026d3",700:"#a21caf",800:"#86198f",900:"#701a75",950:"#4a044e"},pink:{50:"#fdf2f8",100:"#fce7f3",200:"#fbcfe8",300:"#f9a8d4",400:"#f472b6",500:"#ec4899",600:"#db2777",700:"#be185d",800:"#9d174d",900:"#831843",950:"#500724"},rose:{50:"#fff1f2",100:"#ffe4e6",200:"#fecdd3",300:"#fda4af",400:"#fb7185",500:"#f43f5e",600:"#e11d48",700:"#be123c",800:"#9f1239",900:"#881337",950:"#4c0519"},get lightBlue(){return yi({version:"v2.2",from:"lightBlue",to:"sky"}),this.sky},get warmGray(){return yi({version:"v3.0",from:"warmGray",to:"stone"}),this.stone},get trueGray(){return yi({version:"v3.0",from:"trueGray",to:"neutral"}),this.neutral},get coolGray(){return yi({version:"v3.0",from:"coolGray",to:"gray"}),this.gray},get blueGray(){return yi({version:"v3.0",from:"blueGray",to:"slate"}),this.slate}}});function dr(r){if(r=`${r}`,r==="0")return"0";if(/^[+-]?(\d+|\d*\.\d+)(e[+-]?\d+)?(%|\w+)?$/.test(r))return r.replace(/^[+-]?/,t=>t==="-"?"":"-");let e=["var","calc","min","max","clamp"];for(let t of e)if(r.includes(`${t}(`))return`calc(${r} * -1)`}var Pu=S(()=>{l()});var ob,lb=S(()=>{l();ob=["preflight","container","accessibility","pointerEvents","visibility","position","inset","isolation","zIndex","order","gridColumn","gridColumnStart","gridColumnEnd","gridRow","gridRowStart","gridRowEnd","float","clear","margin","boxSizing","lineClamp","display","aspectRatio","size","height","maxHeight","minHeight","width","minWidth","maxWidth","flex","flexShrink","flexGrow","flexBasis","tableLayout","captionSide","borderCollapse","borderSpacing","transformOrigin","translate","rotate","skew","scale","transform","animation","cursor","touchAction","userSelect","resize","scrollSnapType","scrollSnapAlign","scrollSnapStop","scrollMargin","scrollPadding","listStylePosition","listStyleType","listStyleImage","appearance","columns","breakBefore","breakInside","breakAfter","gridAutoColumns","gridAutoFlow","gridAutoRows","gridTemplateColumns","gridTemplateRows","flexDirection","flexWrap","placeContent","placeItems","alignContent","alignItems","justifyContent","justifyItems","gap","space","divideWidth","divideStyle","divideColor","divideOpacity","placeSelf","alignSelf","justifySelf","overflow","overscrollBehavior","scrollBehavior","textOverflow","hyphens","whitespace","textWrap","wordBreak","borderRadius","borderWidth","borderStyle","borderColor","borderOpacity","backgroundColor","backgroundOpacity","backgroundImage","gradientColorStops","boxDecorationBreak","backgroundSize","backgroundAttachment","backgroundClip","backgroundPosition","backgroundRepeat","backgroundOrigin","fill","stroke","strokeWidth","objectFit","objectPosition","padding","textAlign","textIndent","verticalAlign","fontFamily","fontSize","fontWeight","textTransform","fontStyle","fontVariantNumeric","lineHeight","letterSpacing","textColor","textOpacity","textDecoration","textDecorationColor","textDecorationStyle","textDecorationThickness","textUnderlineOffset","fontSmoothing","placeholderColor","placeholderOpacity","caretColor","accentColor","opacity","backgroundBlendMode","mixBlendMode","boxShadow","boxShadowColor","outlineStyle","outlineWidth","outlineOffset","outlineColor","ringWidth","ringColor","ringOpacity","ringOffsetWidth","ringOffsetColor","blur","brightness","contrast","dropShadow","grayscale","hueRotate","invert","saturate","sepia","filter","backdropBlur","backdropBrightness","backdropContrast","backdropGrayscale","backdropHueRotate","backdropInvert","backdropOpacity","backdropSaturate","backdropSepia","backdropFilter","transitionProperty","transitionDelay","transitionDuration","transitionTimingFunction","willChange","contain","content","forcedColorAdjust"]});function ub(r,e){return r===void 0?e:Array.isArray(r)?r:[...new Set(e.filter(i=>r!==!1&&r[i]!==!1).concat(Object.keys(r).filter(i=>r[i]!==!1)))]}var fb=S(()=>{l()});function Du(r,...e){for(let t of e){for(let i in t)r?.hasOwnProperty?.(i)||(r[i]=t[i]);for(let i of Object.getOwnPropertySymbols(t))r?.hasOwnProperty?.(i)||(r[i]=t[i])}return r}var cb=S(()=>{l()});function Iu(r){if(Array.isArray(r))return r;let e=r.split("[").length-1,t=r.split("]").length-1;if(e!==t)throw new Error(`Path is invalid. Has unbalanced brackets: ${r}`);return r.split(/\.(?![^\[]*\])|[\[\]]/g).filter(Boolean)}var pb=S(()=>{l()});function bi(r,e){return hb.future.includes(e)?r.future==="all"||(r?.future?.[e]??db[e]??!1):hb.experimental.includes(e)?r.experimental==="all"||(r?.experimental?.[e]??db[e]??!1):!1}var db,hb,ps=S(()=>{l();Tt();cs();db={optimizeUniversalDefaults:!1,generalizedModifiers:!0,disableColorOpacityUtilitiesByDefault:!1,relativeContentPathsByDefault:!1},hb={future:["hoverOnlyWhenSupported","respectDefaultRingColorOpacity","disableColorOpacityUtilitiesByDefault","relativeContentPathsByDefault"],experimental:["optimizeUniversalDefaults","generalizedModifiers"]}});function mb(r){(()=>{if(r.purge||!r.content||!Array.isArray(r.content)&&!(typeof r.content=="object"&&r.content!==null))return!1;if(Array.isArray(r.content))return r.content.every(t=>typeof t=="string"?!0:!(typeof t?.raw!="string"||t?.extension&&typeof t?.extension!="string"));if(typeof r.content=="object"&&r.content!==null){if(Object.keys(r.content).some(t=>!["files","relative","extract","transform"].includes(t)))return!1;if(Array.isArray(r.content.files)){if(!r.content.files.every(t=>typeof t=="string"?!0:!(typeof t?.raw!="string"||t?.extension&&typeof t?.extension!="string")))return!1;if(typeof r.content.extract=="object"){for(let t of Object.values(r.content.extract))if(typeof t!="function")return!1}else if(!(r.content.extract===void 0||typeof r.content.extract=="function"))return!1;if(typeof r.content.transform=="object"){for(let t of Object.values(r.content.transform))if(typeof t!="function")return!1}else if(!(r.content.transform===void 0||typeof r.content.transform=="function"))return!1;if(typeof r.content.relative!="boolean"&&typeof r.content.relative!="undefined")return!1}return!0}return!1})()||bt.warn("purge-deprecation",["The `purge`/`content` options have changed in Tailwind CSS v3.0.","Update your configuration file to eliminate this warning.","https://tailwindcss.com/docs/upgrade-guide#configure-content-sources"]),r.safelist=(()=>{let{content:t,purge:i,safelist:n}=r;return Array.isArray(n)?n:Array.isArray(t?.safelist)?t.safelist:Array.isArray(i?.safelist)?i.safelist:Array.isArray(i?.options?.safelist)?i.options.safelist:[]})(),r.blocklist=(()=>{let{blocklist:t}=r;if(Array.isArray(t)){if(t.every(i=>typeof i=="string"))return t;bt.warn("blocklist-invalid",["The `blocklist` option must be an array of strings.","https://tailwindcss.com/docs/content-configuration#discarding-classes"])}return[]})(),typeof r.prefix=="function"?(bt.warn("prefix-function",["As of Tailwind CSS v3.0, `prefix` cannot be a function.","Update `prefix` in your configuration to be a string to eliminate this warning.","https://tailwindcss.com/docs/upgrade-guide#prefix-cannot-be-a-function"]),r.prefix=""):r.prefix=r.prefix??"",r.content={relative:(()=>{let{content:t}=r;return t?.relative?t.relative:bi(r,"relativeContentPathsByDefault")})(),files:(()=>{let{content:t,purge:i}=r;return Array.isArray(i)?i:Array.isArray(i?.content)?i.content:Array.isArray(t)?t:Array.isArray(t?.content)?t.content:Array.isArray(t?.files)?t.files:[]})(),extract:(()=>{let t=(()=>r.purge?.extract?r.purge.extract:r.content?.extract?r.content.extract:r.purge?.extract?.DEFAULT?r.purge.extract.DEFAULT:r.content?.extract?.DEFAULT?r.content.extract.DEFAULT:r.purge?.options?.extractors?r.purge.options.extractors:r.content?.options?.extractors?r.content.options.extractors:{})(),i={},n=(()=>{if(r.purge?.options?.defaultExtractor)return r.purge.options.defaultExtractor;if(r.content?.options?.defaultExtractor)return r.content.options.defaultExtractor})();if(n!==void 0&&(i.DEFAULT=n),typeof t=="function")i.DEFAULT=t;else if(Array.isArray(t))for(let{extensions:s,extractor:a}of t??[])for(let o of s)i[o]=a;else typeof t=="object"&&t!==null&&Object.assign(i,t);return i})(),transform:(()=>{let t=(()=>r.purge?.transform?r.purge.transform:r.content?.transform?r.content.transform:r.purge?.transform?.DEFAULT?r.purge.transform.DEFAULT:r.content?.transform?.DEFAULT?r.content.transform.DEFAULT:{})(),i={};return typeof t=="function"?i.DEFAULT=t:typeof t=="object"&&t!==null&&Object.assign(i,t),i})()};for(let t of r.content.files)if(typeof t=="string"&&/{([^,]*?)}/g.test(t)){bt.warn("invalid-glob-braces",[`The glob pattern ${_u(t)} in your Tailwind CSS configuration is invalid.`,`Update it to ${_u(t.replace(/{([^,]*?)}/g,"$1"))} to silence this warning.`]);break}return r}var gb=S(()=>{l();ps();cs()});function wt(r){if(Object.prototype.toString.call(r)!=="[object Object]")return!1;let e=Object.getPrototypeOf(r);return e===null||Object.getPrototypeOf(e)===null}var yb=S(()=>{l()});var bb=S(()=>{l()});var Ru,wb=S(()=>{l();Ru={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}});function hs(r,{loose:e=!1}={}){if(typeof r!="string")return null;if(r=r.trim(),r==="transparent")return{mode:"rgb",color:["0","0","0"],alpha:"0"};if(r in Ru)return{mode:"rgb",color:Ru[r].map(s=>s.toString())};let t=r.replace(b_,(s,a,o,u,c)=>["#",a,a,o,o,u,u,c?c+c:""].join("")).match(y_);if(t!==null)return{mode:"rgb",color:[parseInt(t[1],16),parseInt(t[2],16),parseInt(t[3],16)].map(s=>s.toString()),alpha:t[4]?(parseInt(t[4],16)/255).toString():void 0};let i=r.match(w_)??r.match(x_);if(i===null)return null;let n=[i[2],i[3],i[4]].filter(Boolean).map(s=>s.toString());return n.length===2&&n[0].startsWith("var(")?{mode:i[1],color:[n[0]],alpha:n[1]}:!e&&n.length!==3||n.length<3&&!n.some(s=>/^var\(.*?\)$/.test(s))?null:{mode:i[1],color:n,alpha:i[5]?.toString?.()}}function vb({mode:r,color:e,alpha:t}){let i=t!==void 0;return r==="rgba"||r==="hsla"?`${r}(${e.join(", ")}${i?`, ${t}`:""})`:`${r}(${e.join(" ")}${i?` / ${t}`:""})`}var y_,b_,xt,ds,xb,vt,w_,x_,qu=S(()=>{l();wb();y_=/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i,b_=/^#([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i,xt=/(?:\d+|\d*\.\d+)%?/,ds=/(?:\s*,\s*|\s+)/,xb=/\s*[,/]\s*/,vt=/var\(--(?:[^ )]*?)(?:,(?:[^ )]*?|var\(--[^ )]*?\)))?\)/,w_=new RegExp(`^(rgba?)\\(\\s*(${xt.source}|${vt.source})(?:${ds.source}(${xt.source}|${vt.source}))?(?:${ds.source}(${xt.source}|${vt.source}))?(?:${xb.source}(${xt.source}|${vt.source}))?\\s*\\)$`),x_=new RegExp(`^(hsla?)\\(\\s*((?:${xt.source})(?:deg|rad|grad|turn)?|${vt.source})(?:${ds.source}(${xt.source}|${vt.source}))?(?:${ds.source}(${xt.source}|${vt.source}))?(?:${xb.source}(${xt.source}|${vt.source}))?\\s*\\)$`)});function wi(r,e,t){if(typeof r=="function")return r({opacityValue:e});let i=hs(r,{loose:!0});return i===null?t:vb({...i,alpha:e})}var Fu=S(()=>{l();qu()});function ze(r,e){let t=[],i=[],n=0,s=!1;for(let a=0;a{l()});function Sb(r){return ze(r,",").map(t=>{let i=t.trim(),n={raw:i},s=i.split(k_),a=new Set;for(let o of s)kb.lastIndex=0,!a.has("KEYWORD")&&v_.has(o)?(n.keyword=o,a.add("KEYWORD")):kb.test(o)?a.has("X")?a.has("Y")?a.has("BLUR")?a.has("SPREAD")||(n.spread=o,a.add("SPREAD")):(n.blur=o,a.add("BLUR")):(n.y=o,a.add("Y")):(n.x=o,a.add("X")):n.color?(n.unknown||(n.unknown=[]),n.unknown.push(o)):n.color=o;return n.valid=n.x!==void 0&&n.y!==void 0,n})}var v_,k_,kb,Cb=S(()=>{l();ms();v_=new Set(["inset","inherit","initial","revert","unset"]),k_=/\ +(?![^(]*\))/g,kb=/^-?(\d+|\.\d+)(.*?)$/g});function Bu(r){return S_.some(e=>new RegExp(`^${e}\\(.*\\)`).test(r))}function je(r,e=null,t=!0){let i=e&&C_.has(e.property);return r.startsWith("--")&&!i?`var(${r})`:r.includes("url(")?r.split(/(url\(.*?\))/g).filter(Boolean).map(n=>/^url\(.*?\)$/.test(n)?n:je(n,e,!1)).join(""):(r=r.replace(/([^\\])_+/g,(n,s)=>s+" ".repeat(n.length-1)).replace(/^_/g," ").replace(/\\_/g,"_"),t&&(r=r.trim()),r=A_(r),r)}function A_(r){let e=["theme"],t=["min-content","max-content","fit-content","safe-area-inset-top","safe-area-inset-right","safe-area-inset-bottom","safe-area-inset-left","titlebar-area-x","titlebar-area-y","titlebar-area-width","titlebar-area-height","keyboard-inset-top","keyboard-inset-right","keyboard-inset-bottom","keyboard-inset-left","keyboard-inset-width","keyboard-inset-height","radial-gradient","linear-gradient","conic-gradient","repeating-radial-gradient","repeating-linear-gradient","repeating-conic-gradient","anchor-size"];return r.replace(/(calc|min|max|clamp)\(.+\)/g,i=>{let n="";function s(){let a=n.trimEnd();return a[a.length-1]}for(let a=0;ai[a+p]===d)},u=function(f){let d=1/0;for(let g of f){let b=i.indexOf(g,a);b!==-1&&bo(f))){let f=t.find(d=>o(d));n+=f,a+=f.length-1}else e.some(f=>o(f))?n+=u([")"]):o("[")?n+=u(["]"]):["+","-","*","/"].includes(c)&&!["(","+","-","*","/",","].includes(s())?n+=` ${c} `:n+=c}return n.replace(/\s+/g," ")})}function Mu(r){return r.startsWith("url(")}function Lu(r){return!isNaN(Number(r))||Bu(r)}function xi(r){return r.endsWith("%")&&Lu(r.slice(0,-1))||Bu(r)}function vi(r){return r==="0"||new RegExp(`^[+-]?[0-9]*.?[0-9]+(?:[eE][+-]?[0-9]+)?${__}$`).test(r)||Bu(r)}function Ab(r){return E_.has(r)}function Ob(r){let e=Sb(je(r));for(let t of e)if(!t.valid)return!1;return!0}function _b(r){let e=0;return ze(r,"_").every(i=>(i=je(i),i.startsWith("var(")?!0:hs(i,{loose:!0})!==null?(e++,!0):!1))?e>0:!1}function Eb(r){let e=0;return ze(r,",").every(i=>(i=je(i),i.startsWith("var(")?!0:Mu(i)||P_(i)||["element(","image(","cross-fade(","image-set("].some(n=>i.startsWith(n))?(e++,!0):!1))?e>0:!1}function P_(r){r=je(r);for(let e of T_)if(r.startsWith(`${e}(`))return!0;return!1}function Tb(r){let e=0;return ze(r,"_").every(i=>(i=je(i),i.startsWith("var(")?!0:D_.has(i)||vi(i)||xi(i)?(e++,!0):!1))?e>0:!1}function Pb(r){let e=0;return ze(r,",").every(i=>(i=je(i),i.startsWith("var(")?!0:i.includes(" ")&&!/(['"])([^"']+)\1/g.test(i)||/^\d/g.test(i)?!1:(e++,!0)))?e>0:!1}function Db(r){return I_.has(r)}function Ib(r){return R_.has(r)}function Rb(r){return q_.has(r)}var S_,C_,O_,__,E_,T_,D_,I_,R_,q_,$u=S(()=>{l();qu();Cb();ms();S_=["min","max","clamp","calc"];C_=new Set(["scroll-timeline-name","timeline-scope","view-timeline-name","font-palette","anchor-name","anchor-scope","position-anchor","position-try-options","scroll-timeline","animation-timeline","view-timeline","position-try"]);O_=["cm","mm","Q","in","pc","pt","px","em","ex","ch","rem","lh","rlh","vw","vh","vmin","vmax","vb","vi","svw","svh","lvw","lvh","dvw","dvh","cqw","cqh","cqi","cqb","cqmin","cqmax"],__=`(?:${O_.join("|")})`;E_=new Set(["thin","medium","thick"]);T_=new Set(["conic-gradient","linear-gradient","radial-gradient","repeating-conic-gradient","repeating-linear-gradient","repeating-radial-gradient"]);D_=new Set(["center","top","right","bottom","left"]);I_=new Set(["serif","sans-serif","monospace","cursive","fantasy","system-ui","ui-serif","ui-sans-serif","ui-monospace","ui-rounded","math","emoji","fangsong"]);R_=new Set(["xx-small","x-small","small","medium","large","x-large","xx-large","xxx-large"]);q_=new Set(["larger","smaller"])});function qb(r){let e=["cover","contain"];return ze(r,",").every(t=>{let i=ze(t,"_").filter(Boolean);return i.length===1&&e.includes(i[0])?!0:i.length!==1&&i.length!==2?!1:i.every(n=>vi(n)||xi(n)||n==="auto")})}var Fb=S(()=>{l();$u();ms()});function Bb(r,e){if(!ki(r))return;let t=r.slice(1,-1);if(!!e(t))return je(t)}function F_(r,e={},t){let i=e[r];if(i!==void 0)return dr(i);if(ki(r)){let n=Bb(r,t);return n===void 0?void 0:dr(n)}}function Nu(r,e={},{validate:t=()=>!0}={}){let i=e.values?.[r];return i!==void 0?i:e.supportsNegativeValues&&r.startsWith("-")?F_(r.slice(1),e.values,t):Bb(r,t)}function ki(r){return r.startsWith("[")&&r.endsWith("]")}function B_(r){let e=r.lastIndexOf("/"),t=r.lastIndexOf("[",e),i=r.indexOf("]",e);return r[e-1]==="]"||r[e+1]==="["||t!==-1&&i!==-1&&t")){let e=r;return({opacityValue:t=1})=>e.replace(//g,t)}return r}function M_(r){return je(r.slice(1,-1))}function L_(r,e={},{tailwindConfig:t={}}={}){if(e.values?.[r]!==void 0)return gs(e.values?.[r]);let[i,n]=B_(r);if(n!==void 0){let s=e.values?.[i]??(ki(i)?i.slice(1,-1):void 0);return s===void 0?void 0:(s=gs(s),ki(n)?wi(s,M_(n)):t.theme?.opacity?.[n]===void 0?void 0:wi(s,t.theme.opacity[n]))}return Nu(r,e,{validate:_b})}function $_(r,e={}){return e.values?.[r]}function we(r){return(e,t)=>Nu(e,t,{validate:r})}var N_,rR,Mb=S(()=>{l();bb();Fu();$u();Pu();Fb();ps();N_={any:Nu,color:L_,url:we(Mu),image:we(Eb),length:we(vi),percentage:we(xi),position:we(Tb),lookup:$_,"generic-name":we(Db),"family-name":we(Pb),number:we(Lu),"line-width":we(Ab),"absolute-size":we(Ib),"relative-size":we(Rb),shadow:we(Ob),size:we(qb)},rR=Object.keys(N_)});function zu(r){return typeof r=="function"?r({}):r}var Lb=S(()=>{l()});function hr(r){return typeof r=="function"}function Si(r,...e){let t=e.pop();for(let i of e)for(let n in i){let s=t(r[n],i[n]);s===void 0?wt(r[n])&&wt(i[n])?r[n]=Si({},r[n],i[n],t):r[n]=i[n]:r[n]=s}return r}function z_(r,...e){return hr(r)?r(...e):r}function j_(r){return r.reduce((e,{extend:t})=>Si(e,t,(i,n)=>i===void 0?[n]:Array.isArray(i)?[n,...i]:[n,i]),{})}function U_(r){return{...r.reduce((e,t)=>Du(e,t),{}),extend:j_(r)}}function $b(r,e){if(Array.isArray(r)&&wt(r[0]))return r.concat(e);if(Array.isArray(e)&&wt(e[0])&&wt(r))return[r,...e];if(Array.isArray(e))return e}function V_({extend:r,...e}){return Si(e,r,(t,i)=>!hr(t)&&!i.some(hr)?Si({},t,...i,$b):(n,s)=>Si({},...[t,...i].map(a=>z_(a,n,s)),$b))}function*W_(r){let e=Iu(r);if(e.length===0||(yield e,Array.isArray(r)))return;let t=/^(.*?)\s*\/\s*([^/]+)$/,i=r.match(t);if(i!==null){let[,n,s]=i,a=Iu(n);a.alpha=s,yield a}}function G_(r){let e=(t,i)=>{for(let n of W_(t)){let s=0,a=r;for(;a!=null&&s(t[i]=hr(r[i])?r[i](e,ju):r[i],t),{})}function Nb(r){let e=[];return r.forEach(t=>{e=[...e,t];let i=t?.plugins??[];i.length!==0&&i.forEach(n=>{n.__isOptionsFunction&&(n=n()),e=[...e,...Nb([n?.config??{}])]})}),e}function H_(r){return[...r].reduceRight((t,i)=>hr(i)?i({corePlugins:t}):ub(i,t),ob)}function Y_(r){return[...r].reduceRight((t,i)=>[...t,...i],[])}function Uu(r){let e=[...Nb(r),{prefix:"",important:!1,separator:":"}];return mb(Du({theme:G_(V_(U_(e.map(t=>t?.theme??{})))),corePlugins:H_(e.map(t=>t.corePlugins)),plugins:Y_(r.map(t=>t?.plugins??[]))},...e))}var ju,zb=S(()=>{l();Pu();lb();fb();Tu();cb();pb();gb();yb();us();Mb();Fu();Lb();ju={colors:Eu,negative(r){return Object.keys(r).filter(e=>r[e]!=="0").reduce((e,t)=>{let i=dr(r[t]);return i!==void 0&&(e[`-${t}`]=i),e},{})},breakpoints(r){return Object.keys(r).filter(e=>typeof r[e]=="string").reduce((e,t)=>({...e,[`screen-${t}`]:r[t]}),{})}}});function ys(r){let e=(r?.presets??[jb.default]).slice().reverse().flatMap(n=>ys(n instanceof Function?n():n)),t={respectDefaultRingColorOpacity:{theme:{ringColor:({theme:n})=>({DEFAULT:"#3b82f67f",...n("colors")})}},disableColorOpacityUtilitiesByDefault:{corePlugins:{backgroundOpacity:!1,borderOpacity:!1,divideOpacity:!1,placeholderOpacity:!1,ringOpacity:!1,textOpacity:!1}}},i=Object.keys(t).filter(n=>bi(r,n)).map(n=>t[n]);return[r,...i,...e]}var jb,Ub=S(()=>{l();jb=J(fs());ps()});var Wb={};_e(Wb,{default:()=>Vb});function Vb(...r){let[,...e]=ys(r[0]);return Uu([...r,...e])}var Gb=S(()=>{l();zb();Ub()});l();"use strict";var Q_=Ze(ng()),J_=Ze(ye()),X_=Ze(Q1()),K_=Ze((X1(),J1)),Z_=Ze((tb(),eb)),eE=Ze((nb(),ib)),tE=Ze((Tu(),ab)),rE=Ze((No(),$o)),iE=Ze((Gb(),Wb));function Ze(r){return r&&r.__esModule?r:{default:r}}console.warn("cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: https://tailwindcss.com/docs/installation");var bs="tailwind",Vu="text/tailwindcss",Hb="/template.html",Et,Yb=!0,Qb=0,Wu=new Set,Gu,Jb="",Xb=(r=!1)=>({get(e,t){return(!r||t==="config")&&typeof e[t]=="object"&&e[t]!==null?new Proxy(e[t],Xb()):e[t]},set(e,t,i){return e[t]=i,(!r||t==="config")&&Hu(!0),!0}});window[bs]=new Proxy({config:{},defaultTheme:Z_.default,defaultConfig:eE.default,colors:tE.default,plugin:rE.default,resolveConfig:iE.default},Xb(!0));function Kb(r){Gu.observe(r,{attributes:!0,attributeFilter:["type"],characterData:!0,subtree:!0,childList:!0})}new MutationObserver(async r=>{let e=!1;if(!Gu){Gu=new MutationObserver(async()=>await Hu(!0));for(let t of document.querySelectorAll(`style[type="${Vu}"]`))Kb(t)}for(let t of r)for(let i of t.addedNodes)i.nodeType===1&&i.tagName==="STYLE"&&i.getAttribute("type")===Vu&&(Kb(i),e=!0);await Hu(e)}).observe(document.documentElement,{attributes:!0,attributeFilter:["class"],childList:!0,subtree:!0});async function Hu(r=!1){r&&(Qb++,Wu.clear());let e="";for(let i of document.querySelectorAll(`style[type="${Vu}"]`))e+=i.textContent;let t=new Set;for(let i of document.querySelectorAll("[class]"))for(let n of i.classList)Wu.has(n)||t.add(n);if(document.body&&(Yb||t.size>0||e!==Jb||!Et||!Et.isConnected)){for(let n of t)Wu.add(n);Yb=!1,Jb=e,self[Hb]=Array.from(t).join(" ");let{css:i}=await(0,J_.default)([(0,Q_.default)({...window[bs].config,_hash:Qb,content:{files:[Hb],extract:{html:n=>n.split(" ")}},plugins:[...K_.default,...Array.isArray(window[bs].config.plugins)?window[bs].config.plugins:[]]}),(0,X_.default)({remove:!1})]).process(`@tailwind base;@tailwind components;@tailwind utilities;${e}`);(!Et||!Et.isConnected)&&(Et=document.createElement("style"),document.head.append(Et)),Et.textContent=i}}})();
+/*! https://mths.be/cssesc v3.0.0 by @mathias */
diff --git a/speakr/static/vendor/js/vue.global.js b/speakr/static/vendor/js/vue.global.js
new file mode 100644
index 0000000..db4fb06
--- /dev/null
+++ b/speakr/static/vendor/js/vue.global.js
@@ -0,0 +1,18272 @@
+/**
+* vue v3.5.22
+* (c) 2018-present Yuxi (Evan) You and Vue contributors
+* @license MIT
+**/
+var Vue = (function (exports) {
+ 'use strict';
+
+ // @__NO_SIDE_EFFECTS__
+ function makeMap(str) {
+ const map = /* @__PURE__ */ Object.create(null);
+ for (const key of str.split(",")) map[key] = 1;
+ return (val) => val in map;
+ }
+
+ const EMPTY_OBJ = Object.freeze({}) ;
+ const EMPTY_ARR = Object.freeze([]) ;
+ const NOOP = () => {
+ };
+ const NO = () => false;
+ const isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter
+ (key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97);
+ const isModelListener = (key) => key.startsWith("onUpdate:");
+ const extend = Object.assign;
+ const remove = (arr, el) => {
+ const i = arr.indexOf(el);
+ if (i > -1) {
+ arr.splice(i, 1);
+ }
+ };
+ const hasOwnProperty$1 = Object.prototype.hasOwnProperty;
+ const hasOwn = (val, key) => hasOwnProperty$1.call(val, key);
+ const isArray = Array.isArray;
+ const isMap = (val) => toTypeString(val) === "[object Map]";
+ const isSet = (val) => toTypeString(val) === "[object Set]";
+ const isDate = (val) => toTypeString(val) === "[object Date]";
+ const isRegExp = (val) => toTypeString(val) === "[object RegExp]";
+ const isFunction = (val) => typeof val === "function";
+ const isString = (val) => typeof val === "string";
+ const isSymbol = (val) => typeof val === "symbol";
+ const isObject = (val) => val !== null && typeof val === "object";
+ const isPromise = (val) => {
+ return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch);
+ };
+ const objectToString = Object.prototype.toString;
+ const toTypeString = (value) => objectToString.call(value);
+ const toRawType = (value) => {
+ return toTypeString(value).slice(8, -1);
+ };
+ const isPlainObject = (val) => toTypeString(val) === "[object Object]";
+ const isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key;
+ const isReservedProp = /* @__PURE__ */ makeMap(
+ // the leading comma is intentional so empty string "" is also included
+ ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"
+ );
+ const isBuiltInDirective = /* @__PURE__ */ makeMap(
+ "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"
+ );
+ const cacheStringFunction = (fn) => {
+ const cache = /* @__PURE__ */ Object.create(null);
+ return ((str) => {
+ const hit = cache[str];
+ return hit || (cache[str] = fn(str));
+ });
+ };
+ const camelizeRE = /-\w/g;
+ const camelize = cacheStringFunction(
+ (str) => {
+ return str.replace(camelizeRE, (c) => c.slice(1).toUpperCase());
+ }
+ );
+ const hyphenateRE = /\B([A-Z])/g;
+ const hyphenate = cacheStringFunction(
+ (str) => str.replace(hyphenateRE, "-$1").toLowerCase()
+ );
+ const capitalize = cacheStringFunction((str) => {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+ });
+ const toHandlerKey = cacheStringFunction(
+ (str) => {
+ const s = str ? `on${capitalize(str)}` : ``;
+ return s;
+ }
+ );
+ const hasChanged = (value, oldValue) => !Object.is(value, oldValue);
+ const invokeArrayFns = (fns, ...arg) => {
+ for (let i = 0; i < fns.length; i++) {
+ fns[i](...arg);
+ }
+ };
+ const def = (obj, key, value, writable = false) => {
+ Object.defineProperty(obj, key, {
+ configurable: true,
+ enumerable: false,
+ writable,
+ value
+ });
+ };
+ const looseToNumber = (val) => {
+ const n = parseFloat(val);
+ return isNaN(n) ? val : n;
+ };
+ const toNumber = (val) => {
+ const n = isString(val) ? Number(val) : NaN;
+ return isNaN(n) ? val : n;
+ };
+ let _globalThis;
+ const getGlobalThis = () => {
+ return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {});
+ };
+ function genCacheKey(source, options) {
+ return source + JSON.stringify(
+ options,
+ (_, val) => typeof val === "function" ? val.toString() : val
+ );
+ }
+
+ const PatchFlagNames = {
+ [1]: `TEXT`,
+ [2]: `CLASS`,
+ [4]: `STYLE`,
+ [8]: `PROPS`,
+ [16]: `FULL_PROPS`,
+ [32]: `NEED_HYDRATION`,
+ [64]: `STABLE_FRAGMENT`,
+ [128]: `KEYED_FRAGMENT`,
+ [256]: `UNKEYED_FRAGMENT`,
+ [512]: `NEED_PATCH`,
+ [1024]: `DYNAMIC_SLOTS`,
+ [2048]: `DEV_ROOT_FRAGMENT`,
+ [-1]: `CACHED`,
+ [-2]: `BAIL`
+ };
+
+ const slotFlagsText = {
+ [1]: "STABLE",
+ [2]: "DYNAMIC",
+ [3]: "FORWARDED"
+ };
+
+ const GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol";
+ const isGloballyAllowed = /* @__PURE__ */ makeMap(GLOBALS_ALLOWED);
+
+ const range = 2;
+ function generateCodeFrame(source, start = 0, end = source.length) {
+ start = Math.max(0, Math.min(start, source.length));
+ end = Math.max(0, Math.min(end, source.length));
+ if (start > end) return "";
+ let lines = source.split(/(\r?\n)/);
+ const newlineSequences = lines.filter((_, idx) => idx % 2 === 1);
+ lines = lines.filter((_, idx) => idx % 2 === 0);
+ let count = 0;
+ const res = [];
+ for (let i = 0; i < lines.length; i++) {
+ count += lines[i].length + (newlineSequences[i] && newlineSequences[i].length || 0);
+ if (count >= start) {
+ for (let j = i - range; j <= i + range || end > count; j++) {
+ if (j < 0 || j >= lines.length) continue;
+ const line = j + 1;
+ res.push(
+ `${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}`
+ );
+ const lineLength = lines[j].length;
+ const newLineSeqLength = newlineSequences[j] && newlineSequences[j].length || 0;
+ if (j === i) {
+ const pad = start - (count - (lineLength + newLineSeqLength));
+ const length = Math.max(
+ 1,
+ end > count ? lineLength - pad : end - start
+ );
+ res.push(` | ` + " ".repeat(pad) + "^".repeat(length));
+ } else if (j > i) {
+ if (end > count) {
+ const length = Math.max(Math.min(end - count, lineLength), 1);
+ res.push(` | ` + "^".repeat(length));
+ }
+ count += lineLength + newLineSeqLength;
+ }
+ }
+ break;
+ }
+ }
+ return res.join("\n");
+ }
+
+ function normalizeStyle(value) {
+ if (isArray(value)) {
+ const res = {};
+ for (let i = 0; i < value.length; i++) {
+ const item = value[i];
+ const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item);
+ if (normalized) {
+ for (const key in normalized) {
+ res[key] = normalized[key];
+ }
+ }
+ }
+ return res;
+ } else if (isString(value) || isObject(value)) {
+ return value;
+ }
+ }
+ const listDelimiterRE = /;(?![^(]*\))/g;
+ const propertyDelimiterRE = /:([^]+)/;
+ const styleCommentRE = /\/\*[^]*?\*\//g;
+ function parseStringStyle(cssText) {
+ const ret = {};
+ cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => {
+ if (item) {
+ const tmp = item.split(propertyDelimiterRE);
+ tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim());
+ }
+ });
+ return ret;
+ }
+ function stringifyStyle(styles) {
+ if (!styles) return "";
+ if (isString(styles)) return styles;
+ let ret = "";
+ for (const key in styles) {
+ const value = styles[key];
+ if (isString(value) || typeof value === "number") {
+ const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key);
+ ret += `${normalizedKey}:${value};`;
+ }
+ }
+ return ret;
+ }
+ function normalizeClass(value) {
+ let res = "";
+ if (isString(value)) {
+ res = value;
+ } else if (isArray(value)) {
+ for (let i = 0; i < value.length; i++) {
+ const normalized = normalizeClass(value[i]);
+ if (normalized) {
+ res += normalized + " ";
+ }
+ }
+ } else if (isObject(value)) {
+ for (const name in value) {
+ if (value[name]) {
+ res += name + " ";
+ }
+ }
+ }
+ return res.trim();
+ }
+ function normalizeProps(props) {
+ if (!props) return null;
+ let { class: klass, style } = props;
+ if (klass && !isString(klass)) {
+ props.class = normalizeClass(klass);
+ }
+ if (style) {
+ props.style = normalizeStyle(style);
+ }
+ return props;
+ }
+
+ const HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot";
+ const SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view";
+ const MATH_TAGS = "annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics";
+ const VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr";
+ const isHTMLTag = /* @__PURE__ */ makeMap(HTML_TAGS);
+ const isSVGTag = /* @__PURE__ */ makeMap(SVG_TAGS);
+ const isMathMLTag = /* @__PURE__ */ makeMap(MATH_TAGS);
+ const isVoidTag = /* @__PURE__ */ makeMap(VOID_TAGS);
+
+ const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`;
+ const isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs);
+ const isBooleanAttr = /* @__PURE__ */ makeMap(
+ specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected`
+ );
+ function includeBooleanAttr(value) {
+ return !!value || value === "";
+ }
+ const isKnownHtmlAttr = /* @__PURE__ */ makeMap(
+ `accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,inert,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap`
+ );
+ const isKnownSvgAttr = /* @__PURE__ */ makeMap(
+ `xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan`
+ );
+ function isRenderableAttrValue(value) {
+ if (value == null) {
+ return false;
+ }
+ const type = typeof value;
+ return type === "string" || type === "number" || type === "boolean";
+ }
+
+ const cssVarNameEscapeSymbolsRE = /[ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g;
+ function getEscapedCssVarName(key, doubleEscape) {
+ return key.replace(
+ cssVarNameEscapeSymbolsRE,
+ (s) => `\\${s}`
+ );
+ }
+
+ function looseCompareArrays(a, b) {
+ if (a.length !== b.length) return false;
+ let equal = true;
+ for (let i = 0; equal && i < a.length; i++) {
+ equal = looseEqual(a[i], b[i]);
+ }
+ return equal;
+ }
+ function looseEqual(a, b) {
+ if (a === b) return true;
+ let aValidType = isDate(a);
+ let bValidType = isDate(b);
+ if (aValidType || bValidType) {
+ return aValidType && bValidType ? a.getTime() === b.getTime() : false;
+ }
+ aValidType = isSymbol(a);
+ bValidType = isSymbol(b);
+ if (aValidType || bValidType) {
+ return a === b;
+ }
+ aValidType = isArray(a);
+ bValidType = isArray(b);
+ if (aValidType || bValidType) {
+ return aValidType && bValidType ? looseCompareArrays(a, b) : false;
+ }
+ aValidType = isObject(a);
+ bValidType = isObject(b);
+ if (aValidType || bValidType) {
+ if (!aValidType || !bValidType) {
+ return false;
+ }
+ const aKeysCount = Object.keys(a).length;
+ const bKeysCount = Object.keys(b).length;
+ if (aKeysCount !== bKeysCount) {
+ return false;
+ }
+ for (const key in a) {
+ const aHasKey = a.hasOwnProperty(key);
+ const bHasKey = b.hasOwnProperty(key);
+ if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) {
+ return false;
+ }
+ }
+ }
+ return String(a) === String(b);
+ }
+ function looseIndexOf(arr, val) {
+ return arr.findIndex((item) => looseEqual(item, val));
+ }
+
+ const isRef$1 = (val) => {
+ return !!(val && val["__v_isRef"] === true);
+ };
+ const toDisplayString = (val) => {
+ return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? isRef$1(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val);
+ };
+ const replacer = (_key, val) => {
+ if (isRef$1(val)) {
+ return replacer(_key, val.value);
+ } else if (isMap(val)) {
+ return {
+ [`Map(${val.size})`]: [...val.entries()].reduce(
+ (entries, [key, val2], i) => {
+ entries[stringifySymbol(key, i) + " =>"] = val2;
+ return entries;
+ },
+ {}
+ )
+ };
+ } else if (isSet(val)) {
+ return {
+ [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v))
+ };
+ } else if (isSymbol(val)) {
+ return stringifySymbol(val);
+ } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) {
+ return String(val);
+ }
+ return val;
+ };
+ const stringifySymbol = (v, i = "") => {
+ var _a;
+ return (
+ // Symbol.description in es2019+ so we need to cast here to pass
+ // the lib: es2016 check
+ isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v
+ );
+ };
+
+ function normalizeCssVarValue(value) {
+ if (value == null) {
+ return "initial";
+ }
+ if (typeof value === "string") {
+ return value === "" ? " " : value;
+ }
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ {
+ console.warn(
+ "[Vue warn] Invalid value used for CSS binding. Expected a string or a finite number but received:",
+ value
+ );
+ }
+ }
+ return String(value);
+ }
+
+ function warn$2(msg, ...args) {
+ console.warn(`[Vue warn] ${msg}`, ...args);
+ }
+
+ let activeEffectScope;
+ class EffectScope {
+ constructor(detached = false) {
+ this.detached = detached;
+ /**
+ * @internal
+ */
+ this._active = true;
+ /**
+ * @internal track `on` calls, allow `on` call multiple times
+ */
+ this._on = 0;
+ /**
+ * @internal
+ */
+ this.effects = [];
+ /**
+ * @internal
+ */
+ this.cleanups = [];
+ this._isPaused = false;
+ this.parent = activeEffectScope;
+ if (!detached && activeEffectScope) {
+ this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
+ this
+ ) - 1;
+ }
+ }
+ get active() {
+ return this._active;
+ }
+ pause() {
+ if (this._active) {
+ this._isPaused = true;
+ let i, l;
+ if (this.scopes) {
+ for (i = 0, l = this.scopes.length; i < l; i++) {
+ this.scopes[i].pause();
+ }
+ }
+ for (i = 0, l = this.effects.length; i < l; i++) {
+ this.effects[i].pause();
+ }
+ }
+ }
+ /**
+ * Resumes the effect scope, including all child scopes and effects.
+ */
+ resume() {
+ if (this._active) {
+ if (this._isPaused) {
+ this._isPaused = false;
+ let i, l;
+ if (this.scopes) {
+ for (i = 0, l = this.scopes.length; i < l; i++) {
+ this.scopes[i].resume();
+ }
+ }
+ for (i = 0, l = this.effects.length; i < l; i++) {
+ this.effects[i].resume();
+ }
+ }
+ }
+ }
+ run(fn) {
+ if (this._active) {
+ const currentEffectScope = activeEffectScope;
+ try {
+ activeEffectScope = this;
+ return fn();
+ } finally {
+ activeEffectScope = currentEffectScope;
+ }
+ } else {
+ warn$2(`cannot run an inactive effect scope.`);
+ }
+ }
+ /**
+ * This should only be called on non-detached scopes
+ * @internal
+ */
+ on() {
+ if (++this._on === 1) {
+ this.prevScope = activeEffectScope;
+ activeEffectScope = this;
+ }
+ }
+ /**
+ * This should only be called on non-detached scopes
+ * @internal
+ */
+ off() {
+ if (this._on > 0 && --this._on === 0) {
+ activeEffectScope = this.prevScope;
+ this.prevScope = void 0;
+ }
+ }
+ stop(fromParent) {
+ if (this._active) {
+ this._active = false;
+ let i, l;
+ for (i = 0, l = this.effects.length; i < l; i++) {
+ this.effects[i].stop();
+ }
+ this.effects.length = 0;
+ for (i = 0, l = this.cleanups.length; i < l; i++) {
+ this.cleanups[i]();
+ }
+ this.cleanups.length = 0;
+ if (this.scopes) {
+ for (i = 0, l = this.scopes.length; i < l; i++) {
+ this.scopes[i].stop(true);
+ }
+ this.scopes.length = 0;
+ }
+ if (!this.detached && this.parent && !fromParent) {
+ const last = this.parent.scopes.pop();
+ if (last && last !== this) {
+ this.parent.scopes[this.index] = last;
+ last.index = this.index;
+ }
+ }
+ this.parent = void 0;
+ }
+ }
+ }
+ function effectScope(detached) {
+ return new EffectScope(detached);
+ }
+ function getCurrentScope() {
+ return activeEffectScope;
+ }
+ function onScopeDispose(fn, failSilently = false) {
+ if (activeEffectScope) {
+ activeEffectScope.cleanups.push(fn);
+ } else if (!failSilently) {
+ warn$2(
+ `onScopeDispose() is called when there is no active effect scope to be associated with.`
+ );
+ }
+ }
+
+ let activeSub;
+ const pausedQueueEffects = /* @__PURE__ */ new WeakSet();
+ class ReactiveEffect {
+ constructor(fn) {
+ this.fn = fn;
+ /**
+ * @internal
+ */
+ this.deps = void 0;
+ /**
+ * @internal
+ */
+ this.depsTail = void 0;
+ /**
+ * @internal
+ */
+ this.flags = 1 | 4;
+ /**
+ * @internal
+ */
+ this.next = void 0;
+ /**
+ * @internal
+ */
+ this.cleanup = void 0;
+ this.scheduler = void 0;
+ if (activeEffectScope && activeEffectScope.active) {
+ activeEffectScope.effects.push(this);
+ }
+ }
+ pause() {
+ this.flags |= 64;
+ }
+ resume() {
+ if (this.flags & 64) {
+ this.flags &= -65;
+ if (pausedQueueEffects.has(this)) {
+ pausedQueueEffects.delete(this);
+ this.trigger();
+ }
+ }
+ }
+ /**
+ * @internal
+ */
+ notify() {
+ if (this.flags & 2 && !(this.flags & 32)) {
+ return;
+ }
+ if (!(this.flags & 8)) {
+ batch(this);
+ }
+ }
+ run() {
+ if (!(this.flags & 1)) {
+ return this.fn();
+ }
+ this.flags |= 2;
+ cleanupEffect(this);
+ prepareDeps(this);
+ const prevEffect = activeSub;
+ const prevShouldTrack = shouldTrack;
+ activeSub = this;
+ shouldTrack = true;
+ try {
+ return this.fn();
+ } finally {
+ if (activeSub !== this) {
+ warn$2(
+ "Active effect was not restored correctly - this is likely a Vue internal bug."
+ );
+ }
+ cleanupDeps(this);
+ activeSub = prevEffect;
+ shouldTrack = prevShouldTrack;
+ this.flags &= -3;
+ }
+ }
+ stop() {
+ if (this.flags & 1) {
+ for (let link = this.deps; link; link = link.nextDep) {
+ removeSub(link);
+ }
+ this.deps = this.depsTail = void 0;
+ cleanupEffect(this);
+ this.onStop && this.onStop();
+ this.flags &= -2;
+ }
+ }
+ trigger() {
+ if (this.flags & 64) {
+ pausedQueueEffects.add(this);
+ } else if (this.scheduler) {
+ this.scheduler();
+ } else {
+ this.runIfDirty();
+ }
+ }
+ /**
+ * @internal
+ */
+ runIfDirty() {
+ if (isDirty(this)) {
+ this.run();
+ }
+ }
+ get dirty() {
+ return isDirty(this);
+ }
+ }
+ let batchDepth = 0;
+ let batchedSub;
+ let batchedComputed;
+ function batch(sub, isComputed = false) {
+ sub.flags |= 8;
+ if (isComputed) {
+ sub.next = batchedComputed;
+ batchedComputed = sub;
+ return;
+ }
+ sub.next = batchedSub;
+ batchedSub = sub;
+ }
+ function startBatch() {
+ batchDepth++;
+ }
+ function endBatch() {
+ if (--batchDepth > 0) {
+ return;
+ }
+ if (batchedComputed) {
+ let e = batchedComputed;
+ batchedComputed = void 0;
+ while (e) {
+ const next = e.next;
+ e.next = void 0;
+ e.flags &= -9;
+ e = next;
+ }
+ }
+ let error;
+ while (batchedSub) {
+ let e = batchedSub;
+ batchedSub = void 0;
+ while (e) {
+ const next = e.next;
+ e.next = void 0;
+ e.flags &= -9;
+ if (e.flags & 1) {
+ try {
+ ;
+ e.trigger();
+ } catch (err) {
+ if (!error) error = err;
+ }
+ }
+ e = next;
+ }
+ }
+ if (error) throw error;
+ }
+ function prepareDeps(sub) {
+ for (let link = sub.deps; link; link = link.nextDep) {
+ link.version = -1;
+ link.prevActiveLink = link.dep.activeLink;
+ link.dep.activeLink = link;
+ }
+ }
+ function cleanupDeps(sub) {
+ let head;
+ let tail = sub.depsTail;
+ let link = tail;
+ while (link) {
+ const prev = link.prevDep;
+ if (link.version === -1) {
+ if (link === tail) tail = prev;
+ removeSub(link);
+ removeDep(link);
+ } else {
+ head = link;
+ }
+ link.dep.activeLink = link.prevActiveLink;
+ link.prevActiveLink = void 0;
+ link = prev;
+ }
+ sub.deps = head;
+ sub.depsTail = tail;
+ }
+ function isDirty(sub) {
+ for (let link = sub.deps; link; link = link.nextDep) {
+ if (link.dep.version !== link.version || link.dep.computed && (refreshComputed(link.dep.computed) || link.dep.version !== link.version)) {
+ return true;
+ }
+ }
+ if (sub._dirty) {
+ return true;
+ }
+ return false;
+ }
+ function refreshComputed(computed) {
+ if (computed.flags & 4 && !(computed.flags & 16)) {
+ return;
+ }
+ computed.flags &= -17;
+ if (computed.globalVersion === globalVersion) {
+ return;
+ }
+ computed.globalVersion = globalVersion;
+ if (!computed.isSSR && computed.flags & 128 && (!computed.deps && !computed._dirty || !isDirty(computed))) {
+ return;
+ }
+ computed.flags |= 2;
+ const dep = computed.dep;
+ const prevSub = activeSub;
+ const prevShouldTrack = shouldTrack;
+ activeSub = computed;
+ shouldTrack = true;
+ try {
+ prepareDeps(computed);
+ const value = computed.fn(computed._value);
+ if (dep.version === 0 || hasChanged(value, computed._value)) {
+ computed.flags |= 128;
+ computed._value = value;
+ dep.version++;
+ }
+ } catch (err) {
+ dep.version++;
+ throw err;
+ } finally {
+ activeSub = prevSub;
+ shouldTrack = prevShouldTrack;
+ cleanupDeps(computed);
+ computed.flags &= -3;
+ }
+ }
+ function removeSub(link, soft = false) {
+ const { dep, prevSub, nextSub } = link;
+ if (prevSub) {
+ prevSub.nextSub = nextSub;
+ link.prevSub = void 0;
+ }
+ if (nextSub) {
+ nextSub.prevSub = prevSub;
+ link.nextSub = void 0;
+ }
+ if (dep.subsHead === link) {
+ dep.subsHead = nextSub;
+ }
+ if (dep.subs === link) {
+ dep.subs = prevSub;
+ if (!prevSub && dep.computed) {
+ dep.computed.flags &= -5;
+ for (let l = dep.computed.deps; l; l = l.nextDep) {
+ removeSub(l, true);
+ }
+ }
+ }
+ if (!soft && !--dep.sc && dep.map) {
+ dep.map.delete(dep.key);
+ }
+ }
+ function removeDep(link) {
+ const { prevDep, nextDep } = link;
+ if (prevDep) {
+ prevDep.nextDep = nextDep;
+ link.prevDep = void 0;
+ }
+ if (nextDep) {
+ nextDep.prevDep = prevDep;
+ link.nextDep = void 0;
+ }
+ }
+ function effect(fn, options) {
+ if (fn.effect instanceof ReactiveEffect) {
+ fn = fn.effect.fn;
+ }
+ const e = new ReactiveEffect(fn);
+ if (options) {
+ extend(e, options);
+ }
+ try {
+ e.run();
+ } catch (err) {
+ e.stop();
+ throw err;
+ }
+ const runner = e.run.bind(e);
+ runner.effect = e;
+ return runner;
+ }
+ function stop(runner) {
+ runner.effect.stop();
+ }
+ let shouldTrack = true;
+ const trackStack = [];
+ function pauseTracking() {
+ trackStack.push(shouldTrack);
+ shouldTrack = false;
+ }
+ function resetTracking() {
+ const last = trackStack.pop();
+ shouldTrack = last === void 0 ? true : last;
+ }
+ function cleanupEffect(e) {
+ const { cleanup } = e;
+ e.cleanup = void 0;
+ if (cleanup) {
+ const prevSub = activeSub;
+ activeSub = void 0;
+ try {
+ cleanup();
+ } finally {
+ activeSub = prevSub;
+ }
+ }
+ }
+
+ let globalVersion = 0;
+ class Link {
+ constructor(sub, dep) {
+ this.sub = sub;
+ this.dep = dep;
+ this.version = dep.version;
+ this.nextDep = this.prevDep = this.nextSub = this.prevSub = this.prevActiveLink = void 0;
+ }
+ }
+ class Dep {
+ // TODO isolatedDeclarations "__v_skip"
+ constructor(computed) {
+ this.computed = computed;
+ this.version = 0;
+ /**
+ * Link between this dep and the current active effect
+ */
+ this.activeLink = void 0;
+ /**
+ * Doubly linked list representing the subscribing effects (tail)
+ */
+ this.subs = void 0;
+ /**
+ * For object property deps cleanup
+ */
+ this.map = void 0;
+ this.key = void 0;
+ /**
+ * Subscriber counter
+ */
+ this.sc = 0;
+ /**
+ * @internal
+ */
+ this.__v_skip = true;
+ {
+ this.subsHead = void 0;
+ }
+ }
+ track(debugInfo) {
+ if (!activeSub || !shouldTrack || activeSub === this.computed) {
+ return;
+ }
+ let link = this.activeLink;
+ if (link === void 0 || link.sub !== activeSub) {
+ link = this.activeLink = new Link(activeSub, this);
+ if (!activeSub.deps) {
+ activeSub.deps = activeSub.depsTail = link;
+ } else {
+ link.prevDep = activeSub.depsTail;
+ activeSub.depsTail.nextDep = link;
+ activeSub.depsTail = link;
+ }
+ addSub(link);
+ } else if (link.version === -1) {
+ link.version = this.version;
+ if (link.nextDep) {
+ const next = link.nextDep;
+ next.prevDep = link.prevDep;
+ if (link.prevDep) {
+ link.prevDep.nextDep = next;
+ }
+ link.prevDep = activeSub.depsTail;
+ link.nextDep = void 0;
+ activeSub.depsTail.nextDep = link;
+ activeSub.depsTail = link;
+ if (activeSub.deps === link) {
+ activeSub.deps = next;
+ }
+ }
+ }
+ if (activeSub.onTrack) {
+ activeSub.onTrack(
+ extend(
+ {
+ effect: activeSub
+ },
+ debugInfo
+ )
+ );
+ }
+ return link;
+ }
+ trigger(debugInfo) {
+ this.version++;
+ globalVersion++;
+ this.notify(debugInfo);
+ }
+ notify(debugInfo) {
+ startBatch();
+ try {
+ if (true) {
+ for (let head = this.subsHead; head; head = head.nextSub) {
+ if (head.sub.onTrigger && !(head.sub.flags & 8)) {
+ head.sub.onTrigger(
+ extend(
+ {
+ effect: head.sub
+ },
+ debugInfo
+ )
+ );
+ }
+ }
+ }
+ for (let link = this.subs; link; link = link.prevSub) {
+ if (link.sub.notify()) {
+ ;
+ link.sub.dep.notify();
+ }
+ }
+ } finally {
+ endBatch();
+ }
+ }
+ }
+ function addSub(link) {
+ link.dep.sc++;
+ if (link.sub.flags & 4) {
+ const computed = link.dep.computed;
+ if (computed && !link.dep.subs) {
+ computed.flags |= 4 | 16;
+ for (let l = computed.deps; l; l = l.nextDep) {
+ addSub(l);
+ }
+ }
+ const currentTail = link.dep.subs;
+ if (currentTail !== link) {
+ link.prevSub = currentTail;
+ if (currentTail) currentTail.nextSub = link;
+ }
+ if (link.dep.subsHead === void 0) {
+ link.dep.subsHead = link;
+ }
+ link.dep.subs = link;
+ }
+ }
+ const targetMap = /* @__PURE__ */ new WeakMap();
+ const ITERATE_KEY = Symbol(
+ "Object iterate"
+ );
+ const MAP_KEY_ITERATE_KEY = Symbol(
+ "Map keys iterate"
+ );
+ const ARRAY_ITERATE_KEY = Symbol(
+ "Array iterate"
+ );
+ function track(target, type, key) {
+ if (shouldTrack && activeSub) {
+ let depsMap = targetMap.get(target);
+ if (!depsMap) {
+ targetMap.set(target, depsMap = /* @__PURE__ */ new Map());
+ }
+ let dep = depsMap.get(key);
+ if (!dep) {
+ depsMap.set(key, dep = new Dep());
+ dep.map = depsMap;
+ dep.key = key;
+ }
+ {
+ dep.track({
+ target,
+ type,
+ key
+ });
+ }
+ }
+ }
+ function trigger(target, type, key, newValue, oldValue, oldTarget) {
+ const depsMap = targetMap.get(target);
+ if (!depsMap) {
+ globalVersion++;
+ return;
+ }
+ const run = (dep) => {
+ if (dep) {
+ {
+ dep.trigger({
+ target,
+ type,
+ key,
+ newValue,
+ oldValue,
+ oldTarget
+ });
+ }
+ }
+ };
+ startBatch();
+ if (type === "clear") {
+ depsMap.forEach(run);
+ } else {
+ const targetIsArray = isArray(target);
+ const isArrayIndex = targetIsArray && isIntegerKey(key);
+ if (targetIsArray && key === "length") {
+ const newLength = Number(newValue);
+ depsMap.forEach((dep, key2) => {
+ if (key2 === "length" || key2 === ARRAY_ITERATE_KEY || !isSymbol(key2) && key2 >= newLength) {
+ run(dep);
+ }
+ });
+ } else {
+ if (key !== void 0 || depsMap.has(void 0)) {
+ run(depsMap.get(key));
+ }
+ if (isArrayIndex) {
+ run(depsMap.get(ARRAY_ITERATE_KEY));
+ }
+ switch (type) {
+ case "add":
+ if (!targetIsArray) {
+ run(depsMap.get(ITERATE_KEY));
+ if (isMap(target)) {
+ run(depsMap.get(MAP_KEY_ITERATE_KEY));
+ }
+ } else if (isArrayIndex) {
+ run(depsMap.get("length"));
+ }
+ break;
+ case "delete":
+ if (!targetIsArray) {
+ run(depsMap.get(ITERATE_KEY));
+ if (isMap(target)) {
+ run(depsMap.get(MAP_KEY_ITERATE_KEY));
+ }
+ }
+ break;
+ case "set":
+ if (isMap(target)) {
+ run(depsMap.get(ITERATE_KEY));
+ }
+ break;
+ }
+ }
+ }
+ endBatch();
+ }
+ function getDepFromReactive(object, key) {
+ const depMap = targetMap.get(object);
+ return depMap && depMap.get(key);
+ }
+
+ function reactiveReadArray(array) {
+ const raw = toRaw(array);
+ if (raw === array) return raw;
+ track(raw, "iterate", ARRAY_ITERATE_KEY);
+ return isShallow(array) ? raw : raw.map(toReactive);
+ }
+ function shallowReadArray(arr) {
+ track(arr = toRaw(arr), "iterate", ARRAY_ITERATE_KEY);
+ return arr;
+ }
+ const arrayInstrumentations = {
+ __proto__: null,
+ [Symbol.iterator]() {
+ return iterator(this, Symbol.iterator, toReactive);
+ },
+ concat(...args) {
+ return reactiveReadArray(this).concat(
+ ...args.map((x) => isArray(x) ? reactiveReadArray(x) : x)
+ );
+ },
+ entries() {
+ return iterator(this, "entries", (value) => {
+ value[1] = toReactive(value[1]);
+ return value;
+ });
+ },
+ every(fn, thisArg) {
+ return apply(this, "every", fn, thisArg, void 0, arguments);
+ },
+ filter(fn, thisArg) {
+ return apply(this, "filter", fn, thisArg, (v) => v.map(toReactive), arguments);
+ },
+ find(fn, thisArg) {
+ return apply(this, "find", fn, thisArg, toReactive, arguments);
+ },
+ findIndex(fn, thisArg) {
+ return apply(this, "findIndex", fn, thisArg, void 0, arguments);
+ },
+ findLast(fn, thisArg) {
+ return apply(this, "findLast", fn, thisArg, toReactive, arguments);
+ },
+ findLastIndex(fn, thisArg) {
+ return apply(this, "findLastIndex", fn, thisArg, void 0, arguments);
+ },
+ // flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement
+ forEach(fn, thisArg) {
+ return apply(this, "forEach", fn, thisArg, void 0, arguments);
+ },
+ includes(...args) {
+ return searchProxy(this, "includes", args);
+ },
+ indexOf(...args) {
+ return searchProxy(this, "indexOf", args);
+ },
+ join(separator) {
+ return reactiveReadArray(this).join(separator);
+ },
+ // keys() iterator only reads `length`, no optimization required
+ lastIndexOf(...args) {
+ return searchProxy(this, "lastIndexOf", args);
+ },
+ map(fn, thisArg) {
+ return apply(this, "map", fn, thisArg, void 0, arguments);
+ },
+ pop() {
+ return noTracking(this, "pop");
+ },
+ push(...args) {
+ return noTracking(this, "push", args);
+ },
+ reduce(fn, ...args) {
+ return reduce(this, "reduce", fn, args);
+ },
+ reduceRight(fn, ...args) {
+ return reduce(this, "reduceRight", fn, args);
+ },
+ shift() {
+ return noTracking(this, "shift");
+ },
+ // slice could use ARRAY_ITERATE but also seems to beg for range tracking
+ some(fn, thisArg) {
+ return apply(this, "some", fn, thisArg, void 0, arguments);
+ },
+ splice(...args) {
+ return noTracking(this, "splice", args);
+ },
+ toReversed() {
+ return reactiveReadArray(this).toReversed();
+ },
+ toSorted(comparer) {
+ return reactiveReadArray(this).toSorted(comparer);
+ },
+ toSpliced(...args) {
+ return reactiveReadArray(this).toSpliced(...args);
+ },
+ unshift(...args) {
+ return noTracking(this, "unshift", args);
+ },
+ values() {
+ return iterator(this, "values", toReactive);
+ }
+ };
+ function iterator(self, method, wrapValue) {
+ const arr = shallowReadArray(self);
+ const iter = arr[method]();
+ if (arr !== self && !isShallow(self)) {
+ iter._next = iter.next;
+ iter.next = () => {
+ const result = iter._next();
+ if (!result.done) {
+ result.value = wrapValue(result.value);
+ }
+ return result;
+ };
+ }
+ return iter;
+ }
+ const arrayProto = Array.prototype;
+ function apply(self, method, fn, thisArg, wrappedRetFn, args) {
+ const arr = shallowReadArray(self);
+ const needsWrap = arr !== self && !isShallow(self);
+ const methodFn = arr[method];
+ if (methodFn !== arrayProto[method]) {
+ const result2 = methodFn.apply(self, args);
+ return needsWrap ? toReactive(result2) : result2;
+ }
+ let wrappedFn = fn;
+ if (arr !== self) {
+ if (needsWrap) {
+ wrappedFn = function(item, index) {
+ return fn.call(this, toReactive(item), index, self);
+ };
+ } else if (fn.length > 2) {
+ wrappedFn = function(item, index) {
+ return fn.call(this, item, index, self);
+ };
+ }
+ }
+ const result = methodFn.call(arr, wrappedFn, thisArg);
+ return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result;
+ }
+ function reduce(self, method, fn, args) {
+ const arr = shallowReadArray(self);
+ let wrappedFn = fn;
+ if (arr !== self) {
+ if (!isShallow(self)) {
+ wrappedFn = function(acc, item, index) {
+ return fn.call(this, acc, toReactive(item), index, self);
+ };
+ } else if (fn.length > 3) {
+ wrappedFn = function(acc, item, index) {
+ return fn.call(this, acc, item, index, self);
+ };
+ }
+ }
+ return arr[method](wrappedFn, ...args);
+ }
+ function searchProxy(self, method, args) {
+ const arr = toRaw(self);
+ track(arr, "iterate", ARRAY_ITERATE_KEY);
+ const res = arr[method](...args);
+ if ((res === -1 || res === false) && isProxy(args[0])) {
+ args[0] = toRaw(args[0]);
+ return arr[method](...args);
+ }
+ return res;
+ }
+ function noTracking(self, method, args = []) {
+ pauseTracking();
+ startBatch();
+ const res = toRaw(self)[method].apply(self, args);
+ endBatch();
+ resetTracking();
+ return res;
+ }
+
+ const isNonTrackableKeys = /* @__PURE__ */ makeMap(`__proto__,__v_isRef,__isVue`);
+ const builtInSymbols = new Set(
+ /* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol)
+ );
+ function hasOwnProperty(key) {
+ if (!isSymbol(key)) key = String(key);
+ const obj = toRaw(this);
+ track(obj, "has", key);
+ return obj.hasOwnProperty(key);
+ }
+ class BaseReactiveHandler {
+ constructor(_isReadonly = false, _isShallow = false) {
+ this._isReadonly = _isReadonly;
+ this._isShallow = _isShallow;
+ }
+ get(target, key, receiver) {
+ if (key === "__v_skip") return target["__v_skip"];
+ const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow;
+ if (key === "__v_isReactive") {
+ return !isReadonly2;
+ } else if (key === "__v_isReadonly") {
+ return isReadonly2;
+ } else if (key === "__v_isShallow") {
+ return isShallow2;
+ } else if (key === "__v_raw") {
+ if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype
+ // this means the receiver is a user proxy of the reactive proxy
+ Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) {
+ return target;
+ }
+ return;
+ }
+ const targetIsArray = isArray(target);
+ if (!isReadonly2) {
+ let fn;
+ if (targetIsArray && (fn = arrayInstrumentations[key])) {
+ return fn;
+ }
+ if (key === "hasOwnProperty") {
+ return hasOwnProperty;
+ }
+ }
+ const res = Reflect.get(
+ target,
+ key,
+ // if this is a proxy wrapping a ref, return methods using the raw ref
+ // as receiver so that we don't have to call `toRaw` on the ref in all
+ // its class methods
+ isRef(target) ? target : receiver
+ );
+ if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
+ return res;
+ }
+ if (!isReadonly2) {
+ track(target, "get", key);
+ }
+ if (isShallow2) {
+ return res;
+ }
+ if (isRef(res)) {
+ const value = targetIsArray && isIntegerKey(key) ? res : res.value;
+ return isReadonly2 && isObject(value) ? readonly(value) : value;
+ }
+ if (isObject(res)) {
+ return isReadonly2 ? readonly(res) : reactive(res);
+ }
+ return res;
+ }
+ }
+ class MutableReactiveHandler extends BaseReactiveHandler {
+ constructor(isShallow2 = false) {
+ super(false, isShallow2);
+ }
+ set(target, key, value, receiver) {
+ let oldValue = target[key];
+ if (!this._isShallow) {
+ const isOldValueReadonly = isReadonly(oldValue);
+ if (!isShallow(value) && !isReadonly(value)) {
+ oldValue = toRaw(oldValue);
+ value = toRaw(value);
+ }
+ if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
+ if (isOldValueReadonly) {
+ {
+ warn$2(
+ `Set operation on key "${String(key)}" failed: target is readonly.`,
+ target[key]
+ );
+ }
+ return true;
+ } else {
+ oldValue.value = value;
+ return true;
+ }
+ }
+ }
+ const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key);
+ const result = Reflect.set(
+ target,
+ key,
+ value,
+ isRef(target) ? target : receiver
+ );
+ if (target === toRaw(receiver)) {
+ if (!hadKey) {
+ trigger(target, "add", key, value);
+ } else if (hasChanged(value, oldValue)) {
+ trigger(target, "set", key, value, oldValue);
+ }
+ }
+ return result;
+ }
+ deleteProperty(target, key) {
+ const hadKey = hasOwn(target, key);
+ const oldValue = target[key];
+ const result = Reflect.deleteProperty(target, key);
+ if (result && hadKey) {
+ trigger(target, "delete", key, void 0, oldValue);
+ }
+ return result;
+ }
+ has(target, key) {
+ const result = Reflect.has(target, key);
+ if (!isSymbol(key) || !builtInSymbols.has(key)) {
+ track(target, "has", key);
+ }
+ return result;
+ }
+ ownKeys(target) {
+ track(
+ target,
+ "iterate",
+ isArray(target) ? "length" : ITERATE_KEY
+ );
+ return Reflect.ownKeys(target);
+ }
+ }
+ class ReadonlyReactiveHandler extends BaseReactiveHandler {
+ constructor(isShallow2 = false) {
+ super(true, isShallow2);
+ }
+ set(target, key) {
+ {
+ warn$2(
+ `Set operation on key "${String(key)}" failed: target is readonly.`,
+ target
+ );
+ }
+ return true;
+ }
+ deleteProperty(target, key) {
+ {
+ warn$2(
+ `Delete operation on key "${String(key)}" failed: target is readonly.`,
+ target
+ );
+ }
+ return true;
+ }
+ }
+ const mutableHandlers = /* @__PURE__ */ new MutableReactiveHandler();
+ const readonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler();
+ const shallowReactiveHandlers = /* @__PURE__ */ new MutableReactiveHandler(true);
+ const shallowReadonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(true);
+
+ const toShallow = (value) => value;
+ const getProto = (v) => Reflect.getPrototypeOf(v);
+ function createIterableMethod(method, isReadonly2, isShallow2) {
+ return function(...args) {
+ const target = this["__v_raw"];
+ const rawTarget = toRaw(target);
+ const targetIsMap = isMap(rawTarget);
+ const isPair = method === "entries" || method === Symbol.iterator && targetIsMap;
+ const isKeyOnly = method === "keys" && targetIsMap;
+ const innerIterator = target[method](...args);
+ const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive;
+ !isReadonly2 && track(
+ rawTarget,
+ "iterate",
+ isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY
+ );
+ return {
+ // iterator protocol
+ next() {
+ const { value, done } = innerIterator.next();
+ return done ? { value, done } : {
+ value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
+ done
+ };
+ },
+ // iterable protocol
+ [Symbol.iterator]() {
+ return this;
+ }
+ };
+ };
+ }
+ function createReadonlyMethod(type) {
+ return function(...args) {
+ {
+ const key = args[0] ? `on key "${args[0]}" ` : ``;
+ warn$2(
+ `${capitalize(type)} operation ${key}failed: target is readonly.`,
+ toRaw(this)
+ );
+ }
+ return type === "delete" ? false : type === "clear" ? void 0 : this;
+ };
+ }
+ function createInstrumentations(readonly, shallow) {
+ const instrumentations = {
+ get(key) {
+ const target = this["__v_raw"];
+ const rawTarget = toRaw(target);
+ const rawKey = toRaw(key);
+ if (!readonly) {
+ if (hasChanged(key, rawKey)) {
+ track(rawTarget, "get", key);
+ }
+ track(rawTarget, "get", rawKey);
+ }
+ const { has } = getProto(rawTarget);
+ const wrap = shallow ? toShallow : readonly ? toReadonly : toReactive;
+ if (has.call(rawTarget, key)) {
+ return wrap(target.get(key));
+ } else if (has.call(rawTarget, rawKey)) {
+ return wrap(target.get(rawKey));
+ } else if (target !== rawTarget) {
+ target.get(key);
+ }
+ },
+ get size() {
+ const target = this["__v_raw"];
+ !readonly && track(toRaw(target), "iterate", ITERATE_KEY);
+ return target.size;
+ },
+ has(key) {
+ const target = this["__v_raw"];
+ const rawTarget = toRaw(target);
+ const rawKey = toRaw(key);
+ if (!readonly) {
+ if (hasChanged(key, rawKey)) {
+ track(rawTarget, "has", key);
+ }
+ track(rawTarget, "has", rawKey);
+ }
+ return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey);
+ },
+ forEach(callback, thisArg) {
+ const observed = this;
+ const target = observed["__v_raw"];
+ const rawTarget = toRaw(target);
+ const wrap = shallow ? toShallow : readonly ? toReadonly : toReactive;
+ !readonly && track(rawTarget, "iterate", ITERATE_KEY);
+ return target.forEach((value, key) => {
+ return callback.call(thisArg, wrap(value), wrap(key), observed);
+ });
+ }
+ };
+ extend(
+ instrumentations,
+ readonly ? {
+ add: createReadonlyMethod("add"),
+ set: createReadonlyMethod("set"),
+ delete: createReadonlyMethod("delete"),
+ clear: createReadonlyMethod("clear")
+ } : {
+ add(value) {
+ if (!shallow && !isShallow(value) && !isReadonly(value)) {
+ value = toRaw(value);
+ }
+ const target = toRaw(this);
+ const proto = getProto(target);
+ const hadKey = proto.has.call(target, value);
+ if (!hadKey) {
+ target.add(value);
+ trigger(target, "add", value, value);
+ }
+ return this;
+ },
+ set(key, value) {
+ if (!shallow && !isShallow(value) && !isReadonly(value)) {
+ value = toRaw(value);
+ }
+ const target = toRaw(this);
+ const { has, get } = getProto(target);
+ let hadKey = has.call(target, key);
+ if (!hadKey) {
+ key = toRaw(key);
+ hadKey = has.call(target, key);
+ } else {
+ checkIdentityKeys(target, has, key);
+ }
+ const oldValue = get.call(target, key);
+ target.set(key, value);
+ if (!hadKey) {
+ trigger(target, "add", key, value);
+ } else if (hasChanged(value, oldValue)) {
+ trigger(target, "set", key, value, oldValue);
+ }
+ return this;
+ },
+ delete(key) {
+ const target = toRaw(this);
+ const { has, get } = getProto(target);
+ let hadKey = has.call(target, key);
+ if (!hadKey) {
+ key = toRaw(key);
+ hadKey = has.call(target, key);
+ } else {
+ checkIdentityKeys(target, has, key);
+ }
+ const oldValue = get ? get.call(target, key) : void 0;
+ const result = target.delete(key);
+ if (hadKey) {
+ trigger(target, "delete", key, void 0, oldValue);
+ }
+ return result;
+ },
+ clear() {
+ const target = toRaw(this);
+ const hadItems = target.size !== 0;
+ const oldTarget = isMap(target) ? new Map(target) : new Set(target) ;
+ const result = target.clear();
+ if (hadItems) {
+ trigger(
+ target,
+ "clear",
+ void 0,
+ void 0,
+ oldTarget
+ );
+ }
+ return result;
+ }
+ }
+ );
+ const iteratorMethods = [
+ "keys",
+ "values",
+ "entries",
+ Symbol.iterator
+ ];
+ iteratorMethods.forEach((method) => {
+ instrumentations[method] = createIterableMethod(method, readonly, shallow);
+ });
+ return instrumentations;
+ }
+ function createInstrumentationGetter(isReadonly2, shallow) {
+ const instrumentations = createInstrumentations(isReadonly2, shallow);
+ return (target, key, receiver) => {
+ if (key === "__v_isReactive") {
+ return !isReadonly2;
+ } else if (key === "__v_isReadonly") {
+ return isReadonly2;
+ } else if (key === "__v_raw") {
+ return target;
+ }
+ return Reflect.get(
+ hasOwn(instrumentations, key) && key in target ? instrumentations : target,
+ key,
+ receiver
+ );
+ };
+ }
+ const mutableCollectionHandlers = {
+ get: /* @__PURE__ */ createInstrumentationGetter(false, false)
+ };
+ const shallowCollectionHandlers = {
+ get: /* @__PURE__ */ createInstrumentationGetter(false, true)
+ };
+ const readonlyCollectionHandlers = {
+ get: /* @__PURE__ */ createInstrumentationGetter(true, false)
+ };
+ const shallowReadonlyCollectionHandlers = {
+ get: /* @__PURE__ */ createInstrumentationGetter(true, true)
+ };
+ function checkIdentityKeys(target, has, key) {
+ const rawKey = toRaw(key);
+ if (rawKey !== key && has.call(target, rawKey)) {
+ const type = toRawType(target);
+ warn$2(
+ `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`
+ );
+ }
+ }
+
+ const reactiveMap = /* @__PURE__ */ new WeakMap();
+ const shallowReactiveMap = /* @__PURE__ */ new WeakMap();
+ const readonlyMap = /* @__PURE__ */ new WeakMap();
+ const shallowReadonlyMap = /* @__PURE__ */ new WeakMap();
+ function targetTypeMap(rawType) {
+ switch (rawType) {
+ case "Object":
+ case "Array":
+ return 1 /* COMMON */;
+ case "Map":
+ case "Set":
+ case "WeakMap":
+ case "WeakSet":
+ return 2 /* COLLECTION */;
+ default:
+ return 0 /* INVALID */;
+ }
+ }
+ function getTargetType(value) {
+ return value["__v_skip"] || !Object.isExtensible(value) ? 0 /* INVALID */ : targetTypeMap(toRawType(value));
+ }
+ function reactive(target) {
+ if (isReadonly(target)) {
+ return target;
+ }
+ return createReactiveObject(
+ target,
+ false,
+ mutableHandlers,
+ mutableCollectionHandlers,
+ reactiveMap
+ );
+ }
+ function shallowReactive(target) {
+ return createReactiveObject(
+ target,
+ false,
+ shallowReactiveHandlers,
+ shallowCollectionHandlers,
+ shallowReactiveMap
+ );
+ }
+ function readonly(target) {
+ return createReactiveObject(
+ target,
+ true,
+ readonlyHandlers,
+ readonlyCollectionHandlers,
+ readonlyMap
+ );
+ }
+ function shallowReadonly(target) {
+ return createReactiveObject(
+ target,
+ true,
+ shallowReadonlyHandlers,
+ shallowReadonlyCollectionHandlers,
+ shallowReadonlyMap
+ );
+ }
+ function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) {
+ if (!isObject(target)) {
+ {
+ warn$2(
+ `value cannot be made ${isReadonly2 ? "readonly" : "reactive"}: ${String(
+ target
+ )}`
+ );
+ }
+ return target;
+ }
+ if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) {
+ return target;
+ }
+ const targetType = getTargetType(target);
+ if (targetType === 0 /* INVALID */) {
+ return target;
+ }
+ const existingProxy = proxyMap.get(target);
+ if (existingProxy) {
+ return existingProxy;
+ }
+ const proxy = new Proxy(
+ target,
+ targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers
+ );
+ proxyMap.set(target, proxy);
+ return proxy;
+ }
+ function isReactive(value) {
+ if (isReadonly(value)) {
+ return isReactive(value["__v_raw"]);
+ }
+ return !!(value && value["__v_isReactive"]);
+ }
+ function isReadonly(value) {
+ return !!(value && value["__v_isReadonly"]);
+ }
+ function isShallow(value) {
+ return !!(value && value["__v_isShallow"]);
+ }
+ function isProxy(value) {
+ return value ? !!value["__v_raw"] : false;
+ }
+ function toRaw(observed) {
+ const raw = observed && observed["__v_raw"];
+ return raw ? toRaw(raw) : observed;
+ }
+ function markRaw(value) {
+ if (!hasOwn(value, "__v_skip") && Object.isExtensible(value)) {
+ def(value, "__v_skip", true);
+ }
+ return value;
+ }
+ const toReactive = (value) => isObject(value) ? reactive(value) : value;
+ const toReadonly = (value) => isObject(value) ? readonly(value) : value;
+
+ function isRef(r) {
+ return r ? r["__v_isRef"] === true : false;
+ }
+ function ref(value) {
+ return createRef(value, false);
+ }
+ function shallowRef(value) {
+ return createRef(value, true);
+ }
+ function createRef(rawValue, shallow) {
+ if (isRef(rawValue)) {
+ return rawValue;
+ }
+ return new RefImpl(rawValue, shallow);
+ }
+ class RefImpl {
+ constructor(value, isShallow2) {
+ this.dep = new Dep();
+ this["__v_isRef"] = true;
+ this["__v_isShallow"] = false;
+ this._rawValue = isShallow2 ? value : toRaw(value);
+ this._value = isShallow2 ? value : toReactive(value);
+ this["__v_isShallow"] = isShallow2;
+ }
+ get value() {
+ {
+ this.dep.track({
+ target: this,
+ type: "get",
+ key: "value"
+ });
+ }
+ return this._value;
+ }
+ set value(newValue) {
+ const oldValue = this._rawValue;
+ const useDirectValue = this["__v_isShallow"] || isShallow(newValue) || isReadonly(newValue);
+ newValue = useDirectValue ? newValue : toRaw(newValue);
+ if (hasChanged(newValue, oldValue)) {
+ this._rawValue = newValue;
+ this._value = useDirectValue ? newValue : toReactive(newValue);
+ {
+ this.dep.trigger({
+ target: this,
+ type: "set",
+ key: "value",
+ newValue,
+ oldValue
+ });
+ }
+ }
+ }
+ }
+ function triggerRef(ref2) {
+ if (ref2.dep) {
+ {
+ ref2.dep.trigger({
+ target: ref2,
+ type: "set",
+ key: "value",
+ newValue: ref2._value
+ });
+ }
+ }
+ }
+ function unref(ref2) {
+ return isRef(ref2) ? ref2.value : ref2;
+ }
+ function toValue(source) {
+ return isFunction(source) ? source() : unref(source);
+ }
+ const shallowUnwrapHandlers = {
+ get: (target, key, receiver) => key === "__v_raw" ? target : unref(Reflect.get(target, key, receiver)),
+ set: (target, key, value, receiver) => {
+ const oldValue = target[key];
+ if (isRef(oldValue) && !isRef(value)) {
+ oldValue.value = value;
+ return true;
+ } else {
+ return Reflect.set(target, key, value, receiver);
+ }
+ }
+ };
+ function proxyRefs(objectWithRefs) {
+ return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers);
+ }
+ class CustomRefImpl {
+ constructor(factory) {
+ this["__v_isRef"] = true;
+ this._value = void 0;
+ const dep = this.dep = new Dep();
+ const { get, set } = factory(dep.track.bind(dep), dep.trigger.bind(dep));
+ this._get = get;
+ this._set = set;
+ }
+ get value() {
+ return this._value = this._get();
+ }
+ set value(newVal) {
+ this._set(newVal);
+ }
+ }
+ function customRef(factory) {
+ return new CustomRefImpl(factory);
+ }
+ function toRefs(object) {
+ if (!isProxy(object)) {
+ warn$2(`toRefs() expects a reactive object but received a plain one.`);
+ }
+ const ret = isArray(object) ? new Array(object.length) : {};
+ for (const key in object) {
+ ret[key] = propertyToRef(object, key);
+ }
+ return ret;
+ }
+ class ObjectRefImpl {
+ constructor(_object, _key, _defaultValue) {
+ this._object = _object;
+ this._key = _key;
+ this._defaultValue = _defaultValue;
+ this["__v_isRef"] = true;
+ this._value = void 0;
+ }
+ get value() {
+ const val = this._object[this._key];
+ return this._value = val === void 0 ? this._defaultValue : val;
+ }
+ set value(newVal) {
+ this._object[this._key] = newVal;
+ }
+ get dep() {
+ return getDepFromReactive(toRaw(this._object), this._key);
+ }
+ }
+ class GetterRefImpl {
+ constructor(_getter) {
+ this._getter = _getter;
+ this["__v_isRef"] = true;
+ this["__v_isReadonly"] = true;
+ this._value = void 0;
+ }
+ get value() {
+ return this._value = this._getter();
+ }
+ }
+ function toRef(source, key, defaultValue) {
+ if (isRef(source)) {
+ return source;
+ } else if (isFunction(source)) {
+ return new GetterRefImpl(source);
+ } else if (isObject(source) && arguments.length > 1) {
+ return propertyToRef(source, key, defaultValue);
+ } else {
+ return ref(source);
+ }
+ }
+ function propertyToRef(source, key, defaultValue) {
+ const val = source[key];
+ return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue);
+ }
+
+ class ComputedRefImpl {
+ constructor(fn, setter, isSSR) {
+ this.fn = fn;
+ this.setter = setter;
+ /**
+ * @internal
+ */
+ this._value = void 0;
+ /**
+ * @internal
+ */
+ this.dep = new Dep(this);
+ /**
+ * @internal
+ */
+ this.__v_isRef = true;
+ // TODO isolatedDeclarations "__v_isReadonly"
+ // A computed is also a subscriber that tracks other deps
+ /**
+ * @internal
+ */
+ this.deps = void 0;
+ /**
+ * @internal
+ */
+ this.depsTail = void 0;
+ /**
+ * @internal
+ */
+ this.flags = 16;
+ /**
+ * @internal
+ */
+ this.globalVersion = globalVersion - 1;
+ /**
+ * @internal
+ */
+ this.next = void 0;
+ // for backwards compat
+ this.effect = this;
+ this["__v_isReadonly"] = !setter;
+ this.isSSR = isSSR;
+ }
+ /**
+ * @internal
+ */
+ notify() {
+ this.flags |= 16;
+ if (!(this.flags & 8) && // avoid infinite self recursion
+ activeSub !== this) {
+ batch(this, true);
+ return true;
+ }
+ }
+ get value() {
+ const link = this.dep.track({
+ target: this,
+ type: "get",
+ key: "value"
+ }) ;
+ refreshComputed(this);
+ if (link) {
+ link.version = this.dep.version;
+ }
+ return this._value;
+ }
+ set value(newValue) {
+ if (this.setter) {
+ this.setter(newValue);
+ } else {
+ warn$2("Write operation failed: computed value is readonly");
+ }
+ }
+ }
+ function computed$1(getterOrOptions, debugOptions, isSSR = false) {
+ let getter;
+ let setter;
+ if (isFunction(getterOrOptions)) {
+ getter = getterOrOptions;
+ } else {
+ getter = getterOrOptions.get;
+ setter = getterOrOptions.set;
+ }
+ const cRef = new ComputedRefImpl(getter, setter, isSSR);
+ if (debugOptions && !isSSR) {
+ cRef.onTrack = debugOptions.onTrack;
+ cRef.onTrigger = debugOptions.onTrigger;
+ }
+ return cRef;
+ }
+
+ const TrackOpTypes = {
+ "GET": "get",
+ "HAS": "has",
+ "ITERATE": "iterate"
+ };
+ const TriggerOpTypes = {
+ "SET": "set",
+ "ADD": "add",
+ "DELETE": "delete",
+ "CLEAR": "clear"
+ };
+
+ const INITIAL_WATCHER_VALUE = {};
+ const cleanupMap = /* @__PURE__ */ new WeakMap();
+ let activeWatcher = void 0;
+ function getCurrentWatcher() {
+ return activeWatcher;
+ }
+ function onWatcherCleanup(cleanupFn, failSilently = false, owner = activeWatcher) {
+ if (owner) {
+ let cleanups = cleanupMap.get(owner);
+ if (!cleanups) cleanupMap.set(owner, cleanups = []);
+ cleanups.push(cleanupFn);
+ } else if (!failSilently) {
+ warn$2(
+ `onWatcherCleanup() was called when there was no active watcher to associate with.`
+ );
+ }
+ }
+ function watch$1(source, cb, options = EMPTY_OBJ) {
+ const { immediate, deep, once, scheduler, augmentJob, call } = options;
+ const warnInvalidSource = (s) => {
+ (options.onWarn || warn$2)(
+ `Invalid watch source: `,
+ s,
+ `A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.`
+ );
+ };
+ const reactiveGetter = (source2) => {
+ if (deep) return source2;
+ if (isShallow(source2) || deep === false || deep === 0)
+ return traverse(source2, 1);
+ return traverse(source2);
+ };
+ let effect;
+ let getter;
+ let cleanup;
+ let boundCleanup;
+ let forceTrigger = false;
+ let isMultiSource = false;
+ if (isRef(source)) {
+ getter = () => source.value;
+ forceTrigger = isShallow(source);
+ } else if (isReactive(source)) {
+ getter = () => reactiveGetter(source);
+ forceTrigger = true;
+ } else if (isArray(source)) {
+ isMultiSource = true;
+ forceTrigger = source.some((s) => isReactive(s) || isShallow(s));
+ getter = () => source.map((s) => {
+ if (isRef(s)) {
+ return s.value;
+ } else if (isReactive(s)) {
+ return reactiveGetter(s);
+ } else if (isFunction(s)) {
+ return call ? call(s, 2) : s();
+ } else {
+ warnInvalidSource(s);
+ }
+ });
+ } else if (isFunction(source)) {
+ if (cb) {
+ getter = call ? () => call(source, 2) : source;
+ } else {
+ getter = () => {
+ if (cleanup) {
+ pauseTracking();
+ try {
+ cleanup();
+ } finally {
+ resetTracking();
+ }
+ }
+ const currentEffect = activeWatcher;
+ activeWatcher = effect;
+ try {
+ return call ? call(source, 3, [boundCleanup]) : source(boundCleanup);
+ } finally {
+ activeWatcher = currentEffect;
+ }
+ };
+ }
+ } else {
+ getter = NOOP;
+ warnInvalidSource(source);
+ }
+ if (cb && deep) {
+ const baseGetter = getter;
+ const depth = deep === true ? Infinity : deep;
+ getter = () => traverse(baseGetter(), depth);
+ }
+ const scope = getCurrentScope();
+ const watchHandle = () => {
+ effect.stop();
+ if (scope && scope.active) {
+ remove(scope.effects, effect);
+ }
+ };
+ if (once && cb) {
+ const _cb = cb;
+ cb = (...args) => {
+ _cb(...args);
+ watchHandle();
+ };
+ }
+ let oldValue = isMultiSource ? new Array(source.length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE;
+ const job = (immediateFirstRun) => {
+ if (!(effect.flags & 1) || !effect.dirty && !immediateFirstRun) {
+ return;
+ }
+ if (cb) {
+ const newValue = effect.run();
+ if (deep || forceTrigger || (isMultiSource ? newValue.some((v, i) => hasChanged(v, oldValue[i])) : hasChanged(newValue, oldValue))) {
+ if (cleanup) {
+ cleanup();
+ }
+ const currentWatcher = activeWatcher;
+ activeWatcher = effect;
+ try {
+ const args = [
+ newValue,
+ // pass undefined as the old value when it's changed for the first time
+ oldValue === INITIAL_WATCHER_VALUE ? void 0 : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? [] : oldValue,
+ boundCleanup
+ ];
+ oldValue = newValue;
+ call ? call(cb, 3, args) : (
+ // @ts-expect-error
+ cb(...args)
+ );
+ } finally {
+ activeWatcher = currentWatcher;
+ }
+ }
+ } else {
+ effect.run();
+ }
+ };
+ if (augmentJob) {
+ augmentJob(job);
+ }
+ effect = new ReactiveEffect(getter);
+ effect.scheduler = scheduler ? () => scheduler(job, false) : job;
+ boundCleanup = (fn) => onWatcherCleanup(fn, false, effect);
+ cleanup = effect.onStop = () => {
+ const cleanups = cleanupMap.get(effect);
+ if (cleanups) {
+ if (call) {
+ call(cleanups, 4);
+ } else {
+ for (const cleanup2 of cleanups) cleanup2();
+ }
+ cleanupMap.delete(effect);
+ }
+ };
+ {
+ effect.onTrack = options.onTrack;
+ effect.onTrigger = options.onTrigger;
+ }
+ if (cb) {
+ if (immediate) {
+ job(true);
+ } else {
+ oldValue = effect.run();
+ }
+ } else if (scheduler) {
+ scheduler(job.bind(null, true), true);
+ } else {
+ effect.run();
+ }
+ watchHandle.pause = effect.pause.bind(effect);
+ watchHandle.resume = effect.resume.bind(effect);
+ watchHandle.stop = watchHandle;
+ return watchHandle;
+ }
+ function traverse(value, depth = Infinity, seen) {
+ if (depth <= 0 || !isObject(value) || value["__v_skip"]) {
+ return value;
+ }
+ seen = seen || /* @__PURE__ */ new Map();
+ if ((seen.get(value) || 0) >= depth) {
+ return value;
+ }
+ seen.set(value, depth);
+ depth--;
+ if (isRef(value)) {
+ traverse(value.value, depth, seen);
+ } else if (isArray(value)) {
+ for (let i = 0; i < value.length; i++) {
+ traverse(value[i], depth, seen);
+ }
+ } else if (isSet(value) || isMap(value)) {
+ value.forEach((v) => {
+ traverse(v, depth, seen);
+ });
+ } else if (isPlainObject(value)) {
+ for (const key in value) {
+ traverse(value[key], depth, seen);
+ }
+ for (const key of Object.getOwnPropertySymbols(value)) {
+ if (Object.prototype.propertyIsEnumerable.call(value, key)) {
+ traverse(value[key], depth, seen);
+ }
+ }
+ }
+ return value;
+ }
+
+ const stack$1 = [];
+ function pushWarningContext(vnode) {
+ stack$1.push(vnode);
+ }
+ function popWarningContext() {
+ stack$1.pop();
+ }
+ let isWarning = false;
+ function warn$1(msg, ...args) {
+ if (isWarning) return;
+ isWarning = true;
+ pauseTracking();
+ const instance = stack$1.length ? stack$1[stack$1.length - 1].component : null;
+ const appWarnHandler = instance && instance.appContext.config.warnHandler;
+ const trace = getComponentTrace();
+ if (appWarnHandler) {
+ callWithErrorHandling(
+ appWarnHandler,
+ instance,
+ 11,
+ [
+ // eslint-disable-next-line no-restricted-syntax
+ msg + args.map((a) => {
+ var _a, _b;
+ return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a);
+ }).join(""),
+ instance && instance.proxy,
+ trace.map(
+ ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>`
+ ).join("\n"),
+ trace
+ ]
+ );
+ } else {
+ const warnArgs = [`[Vue warn]: ${msg}`, ...args];
+ if (trace.length && // avoid spamming console during tests
+ true) {
+ warnArgs.push(`
+`, ...formatTrace(trace));
+ }
+ console.warn(...warnArgs);
+ }
+ resetTracking();
+ isWarning = false;
+ }
+ function getComponentTrace() {
+ let currentVNode = stack$1[stack$1.length - 1];
+ if (!currentVNode) {
+ return [];
+ }
+ const normalizedStack = [];
+ while (currentVNode) {
+ const last = normalizedStack[0];
+ if (last && last.vnode === currentVNode) {
+ last.recurseCount++;
+ } else {
+ normalizedStack.push({
+ vnode: currentVNode,
+ recurseCount: 0
+ });
+ }
+ const parentInstance = currentVNode.component && currentVNode.component.parent;
+ currentVNode = parentInstance && parentInstance.vnode;
+ }
+ return normalizedStack;
+ }
+ function formatTrace(trace) {
+ const logs = [];
+ trace.forEach((entry, i) => {
+ logs.push(...i === 0 ? [] : [`
+`], ...formatTraceEntry(entry));
+ });
+ return logs;
+ }
+ function formatTraceEntry({ vnode, recurseCount }) {
+ const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``;
+ const isRoot = vnode.component ? vnode.component.parent == null : false;
+ const open = ` at <${formatComponentName(
+ vnode.component,
+ vnode.type,
+ isRoot
+ )}`;
+ const close = `>` + postfix;
+ return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close];
+ }
+ function formatProps(props) {
+ const res = [];
+ const keys = Object.keys(props);
+ keys.slice(0, 3).forEach((key) => {
+ res.push(...formatProp(key, props[key]));
+ });
+ if (keys.length > 3) {
+ res.push(` ...`);
+ }
+ return res;
+ }
+ function formatProp(key, value, raw) {
+ if (isString(value)) {
+ value = JSON.stringify(value);
+ return raw ? value : [`${key}=${value}`];
+ } else if (typeof value === "number" || typeof value === "boolean" || value == null) {
+ return raw ? value : [`${key}=${value}`];
+ } else if (isRef(value)) {
+ value = formatProp(key, toRaw(value.value), true);
+ return raw ? value : [`${key}=Ref<`, value, `>`];
+ } else if (isFunction(value)) {
+ return [`${key}=fn${value.name ? `<${value.name}>` : ``}`];
+ } else {
+ value = toRaw(value);
+ return raw ? value : [`${key}=`, value];
+ }
+ }
+ function assertNumber(val, type) {
+ if (val === void 0) {
+ return;
+ } else if (typeof val !== "number") {
+ warn$1(`${type} is not a valid number - got ${JSON.stringify(val)}.`);
+ } else if (isNaN(val)) {
+ warn$1(`${type} is NaN - the duration expression might be incorrect.`);
+ }
+ }
+
+ const ErrorCodes = {
+ "SETUP_FUNCTION": 0,
+ "0": "SETUP_FUNCTION",
+ "RENDER_FUNCTION": 1,
+ "1": "RENDER_FUNCTION",
+ "NATIVE_EVENT_HANDLER": 5,
+ "5": "NATIVE_EVENT_HANDLER",
+ "COMPONENT_EVENT_HANDLER": 6,
+ "6": "COMPONENT_EVENT_HANDLER",
+ "VNODE_HOOK": 7,
+ "7": "VNODE_HOOK",
+ "DIRECTIVE_HOOK": 8,
+ "8": "DIRECTIVE_HOOK",
+ "TRANSITION_HOOK": 9,
+ "9": "TRANSITION_HOOK",
+ "APP_ERROR_HANDLER": 10,
+ "10": "APP_ERROR_HANDLER",
+ "APP_WARN_HANDLER": 11,
+ "11": "APP_WARN_HANDLER",
+ "FUNCTION_REF": 12,
+ "12": "FUNCTION_REF",
+ "ASYNC_COMPONENT_LOADER": 13,
+ "13": "ASYNC_COMPONENT_LOADER",
+ "SCHEDULER": 14,
+ "14": "SCHEDULER",
+ "COMPONENT_UPDATE": 15,
+ "15": "COMPONENT_UPDATE",
+ "APP_UNMOUNT_CLEANUP": 16,
+ "16": "APP_UNMOUNT_CLEANUP"
+ };
+ const ErrorTypeStrings$1 = {
+ ["sp"]: "serverPrefetch hook",
+ ["bc"]: "beforeCreate hook",
+ ["c"]: "created hook",
+ ["bm"]: "beforeMount hook",
+ ["m"]: "mounted hook",
+ ["bu"]: "beforeUpdate hook",
+ ["u"]: "updated",
+ ["bum"]: "beforeUnmount hook",
+ ["um"]: "unmounted hook",
+ ["a"]: "activated hook",
+ ["da"]: "deactivated hook",
+ ["ec"]: "errorCaptured hook",
+ ["rtc"]: "renderTracked hook",
+ ["rtg"]: "renderTriggered hook",
+ [0]: "setup function",
+ [1]: "render function",
+ [2]: "watcher getter",
+ [3]: "watcher callback",
+ [4]: "watcher cleanup function",
+ [5]: "native event handler",
+ [6]: "component event handler",
+ [7]: "vnode hook",
+ [8]: "directive hook",
+ [9]: "transition hook",
+ [10]: "app errorHandler",
+ [11]: "app warnHandler",
+ [12]: "ref function",
+ [13]: "async component loader",
+ [14]: "scheduler flush",
+ [15]: "component update",
+ [16]: "app unmount cleanup function"
+ };
+ function callWithErrorHandling(fn, instance, type, args) {
+ try {
+ return args ? fn(...args) : fn();
+ } catch (err) {
+ handleError(err, instance, type);
+ }
+ }
+ function callWithAsyncErrorHandling(fn, instance, type, args) {
+ if (isFunction(fn)) {
+ const res = callWithErrorHandling(fn, instance, type, args);
+ if (res && isPromise(res)) {
+ res.catch((err) => {
+ handleError(err, instance, type);
+ });
+ }
+ return res;
+ }
+ if (isArray(fn)) {
+ const values = [];
+ for (let i = 0; i < fn.length; i++) {
+ values.push(callWithAsyncErrorHandling(fn[i], instance, type, args));
+ }
+ return values;
+ } else {
+ warn$1(
+ `Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}`
+ );
+ }
+ }
+ function handleError(err, instance, type, throwInDev = true) {
+ const contextVNode = instance ? instance.vnode : null;
+ const { errorHandler, throwUnhandledErrorInProduction } = instance && instance.appContext.config || EMPTY_OBJ;
+ if (instance) {
+ let cur = instance.parent;
+ const exposedInstance = instance.proxy;
+ const errorInfo = ErrorTypeStrings$1[type] ;
+ while (cur) {
+ const errorCapturedHooks = cur.ec;
+ if (errorCapturedHooks) {
+ for (let i = 0; i < errorCapturedHooks.length; i++) {
+ if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) {
+ return;
+ }
+ }
+ }
+ cur = cur.parent;
+ }
+ if (errorHandler) {
+ pauseTracking();
+ callWithErrorHandling(errorHandler, null, 10, [
+ err,
+ exposedInstance,
+ errorInfo
+ ]);
+ resetTracking();
+ return;
+ }
+ }
+ logError(err, type, contextVNode, throwInDev, throwUnhandledErrorInProduction);
+ }
+ function logError(err, type, contextVNode, throwInDev = true, throwInProd = false) {
+ {
+ const info = ErrorTypeStrings$1[type];
+ if (contextVNode) {
+ pushWarningContext(contextVNode);
+ }
+ warn$1(`Unhandled error${info ? ` during execution of ${info}` : ``}`);
+ if (contextVNode) {
+ popWarningContext();
+ }
+ if (throwInDev) {
+ throw err;
+ } else {
+ console.error(err);
+ }
+ }
+ }
+
+ const queue = [];
+ let flushIndex = -1;
+ const pendingPostFlushCbs = [];
+ let activePostFlushCbs = null;
+ let postFlushIndex = 0;
+ const resolvedPromise = /* @__PURE__ */ Promise.resolve();
+ let currentFlushPromise = null;
+ const RECURSION_LIMIT = 100;
+ function nextTick(fn) {
+ const p = currentFlushPromise || resolvedPromise;
+ return fn ? p.then(this ? fn.bind(this) : fn) : p;
+ }
+ function findInsertionIndex(id) {
+ let start = flushIndex + 1;
+ let end = queue.length;
+ while (start < end) {
+ const middle = start + end >>> 1;
+ const middleJob = queue[middle];
+ const middleJobId = getId(middleJob);
+ if (middleJobId < id || middleJobId === id && middleJob.flags & 2) {
+ start = middle + 1;
+ } else {
+ end = middle;
+ }
+ }
+ return start;
+ }
+ function queueJob(job) {
+ if (!(job.flags & 1)) {
+ const jobId = getId(job);
+ const lastJob = queue[queue.length - 1];
+ if (!lastJob || // fast path when the job id is larger than the tail
+ !(job.flags & 2) && jobId >= getId(lastJob)) {
+ queue.push(job);
+ } else {
+ queue.splice(findInsertionIndex(jobId), 0, job);
+ }
+ job.flags |= 1;
+ queueFlush();
+ }
+ }
+ function queueFlush() {
+ if (!currentFlushPromise) {
+ currentFlushPromise = resolvedPromise.then(flushJobs);
+ }
+ }
+ function queuePostFlushCb(cb) {
+ if (!isArray(cb)) {
+ if (activePostFlushCbs && cb.id === -1) {
+ activePostFlushCbs.splice(postFlushIndex + 1, 0, cb);
+ } else if (!(cb.flags & 1)) {
+ pendingPostFlushCbs.push(cb);
+ cb.flags |= 1;
+ }
+ } else {
+ pendingPostFlushCbs.push(...cb);
+ }
+ queueFlush();
+ }
+ function flushPreFlushCbs(instance, seen, i = flushIndex + 1) {
+ {
+ seen = seen || /* @__PURE__ */ new Map();
+ }
+ for (; i < queue.length; i++) {
+ const cb = queue[i];
+ if (cb && cb.flags & 2) {
+ if (instance && cb.id !== instance.uid) {
+ continue;
+ }
+ if (checkRecursiveUpdates(seen, cb)) {
+ continue;
+ }
+ queue.splice(i, 1);
+ i--;
+ if (cb.flags & 4) {
+ cb.flags &= -2;
+ }
+ cb();
+ if (!(cb.flags & 4)) {
+ cb.flags &= -2;
+ }
+ }
+ }
+ }
+ function flushPostFlushCbs(seen) {
+ if (pendingPostFlushCbs.length) {
+ const deduped = [...new Set(pendingPostFlushCbs)].sort(
+ (a, b) => getId(a) - getId(b)
+ );
+ pendingPostFlushCbs.length = 0;
+ if (activePostFlushCbs) {
+ activePostFlushCbs.push(...deduped);
+ return;
+ }
+ activePostFlushCbs = deduped;
+ {
+ seen = seen || /* @__PURE__ */ new Map();
+ }
+ for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) {
+ const cb = activePostFlushCbs[postFlushIndex];
+ if (checkRecursiveUpdates(seen, cb)) {
+ continue;
+ }
+ if (cb.flags & 4) {
+ cb.flags &= -2;
+ }
+ if (!(cb.flags & 8)) cb();
+ cb.flags &= -2;
+ }
+ activePostFlushCbs = null;
+ postFlushIndex = 0;
+ }
+ }
+ const getId = (job) => job.id == null ? job.flags & 2 ? -1 : Infinity : job.id;
+ function flushJobs(seen) {
+ {
+ seen = seen || /* @__PURE__ */ new Map();
+ }
+ const check = (job) => checkRecursiveUpdates(seen, job) ;
+ try {
+ for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
+ const job = queue[flushIndex];
+ if (job && !(job.flags & 8)) {
+ if (check(job)) {
+ continue;
+ }
+ if (job.flags & 4) {
+ job.flags &= ~1;
+ }
+ callWithErrorHandling(
+ job,
+ job.i,
+ job.i ? 15 : 14
+ );
+ if (!(job.flags & 4)) {
+ job.flags &= ~1;
+ }
+ }
+ }
+ } finally {
+ for (; flushIndex < queue.length; flushIndex++) {
+ const job = queue[flushIndex];
+ if (job) {
+ job.flags &= -2;
+ }
+ }
+ flushIndex = -1;
+ queue.length = 0;
+ flushPostFlushCbs(seen);
+ currentFlushPromise = null;
+ if (queue.length || pendingPostFlushCbs.length) {
+ flushJobs(seen);
+ }
+ }
+ }
+ function checkRecursiveUpdates(seen, fn) {
+ const count = seen.get(fn) || 0;
+ if (count > RECURSION_LIMIT) {
+ const instance = fn.i;
+ const componentName = instance && getComponentName(instance.type);
+ handleError(
+ `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`,
+ null,
+ 10
+ );
+ return true;
+ }
+ seen.set(fn, count + 1);
+ return false;
+ }
+
+ let isHmrUpdating = false;
+ const hmrDirtyComponents = /* @__PURE__ */ new Map();
+ {
+ getGlobalThis().__VUE_HMR_RUNTIME__ = {
+ createRecord: tryWrap(createRecord),
+ rerender: tryWrap(rerender),
+ reload: tryWrap(reload)
+ };
+ }
+ const map = /* @__PURE__ */ new Map();
+ function registerHMR(instance) {
+ const id = instance.type.__hmrId;
+ let record = map.get(id);
+ if (!record) {
+ createRecord(id, instance.type);
+ record = map.get(id);
+ }
+ record.instances.add(instance);
+ }
+ function unregisterHMR(instance) {
+ map.get(instance.type.__hmrId).instances.delete(instance);
+ }
+ function createRecord(id, initialDef) {
+ if (map.has(id)) {
+ return false;
+ }
+ map.set(id, {
+ initialDef: normalizeClassComponent(initialDef),
+ instances: /* @__PURE__ */ new Set()
+ });
+ return true;
+ }
+ function normalizeClassComponent(component) {
+ return isClassComponent(component) ? component.__vccOpts : component;
+ }
+ function rerender(id, newRender) {
+ const record = map.get(id);
+ if (!record) {
+ return;
+ }
+ record.initialDef.render = newRender;
+ [...record.instances].forEach((instance) => {
+ if (newRender) {
+ instance.render = newRender;
+ normalizeClassComponent(instance.type).render = newRender;
+ }
+ instance.renderCache = [];
+ isHmrUpdating = true;
+ if (!(instance.job.flags & 8)) {
+ instance.update();
+ }
+ isHmrUpdating = false;
+ });
+ }
+ function reload(id, newComp) {
+ const record = map.get(id);
+ if (!record) return;
+ newComp = normalizeClassComponent(newComp);
+ updateComponentDef(record.initialDef, newComp);
+ const instances = [...record.instances];
+ for (let i = 0; i < instances.length; i++) {
+ const instance = instances[i];
+ const oldComp = normalizeClassComponent(instance.type);
+ let dirtyInstances = hmrDirtyComponents.get(oldComp);
+ if (!dirtyInstances) {
+ if (oldComp !== record.initialDef) {
+ updateComponentDef(oldComp, newComp);
+ }
+ hmrDirtyComponents.set(oldComp, dirtyInstances = /* @__PURE__ */ new Set());
+ }
+ dirtyInstances.add(instance);
+ instance.appContext.propsCache.delete(instance.type);
+ instance.appContext.emitsCache.delete(instance.type);
+ instance.appContext.optionsCache.delete(instance.type);
+ if (instance.ceReload) {
+ dirtyInstances.add(instance);
+ instance.ceReload(newComp.styles);
+ dirtyInstances.delete(instance);
+ } else if (instance.parent) {
+ queueJob(() => {
+ if (!(instance.job.flags & 8)) {
+ isHmrUpdating = true;
+ instance.parent.update();
+ isHmrUpdating = false;
+ dirtyInstances.delete(instance);
+ }
+ });
+ } else if (instance.appContext.reload) {
+ instance.appContext.reload();
+ } else if (typeof window !== "undefined") {
+ window.location.reload();
+ } else {
+ console.warn(
+ "[HMR] Root or manually mounted instance modified. Full reload required."
+ );
+ }
+ if (instance.root.ce && instance !== instance.root) {
+ instance.root.ce._removeChildStyle(oldComp);
+ }
+ }
+ queuePostFlushCb(() => {
+ hmrDirtyComponents.clear();
+ });
+ }
+ function updateComponentDef(oldComp, newComp) {
+ extend(oldComp, newComp);
+ for (const key in oldComp) {
+ if (key !== "__file" && !(key in newComp)) {
+ delete oldComp[key];
+ }
+ }
+ }
+ function tryWrap(fn) {
+ return (id, arg) => {
+ try {
+ return fn(id, arg);
+ } catch (e) {
+ console.error(e);
+ console.warn(
+ `[HMR] Something went wrong during Vue component hot-reload. Full reload required.`
+ );
+ }
+ };
+ }
+
+ let devtools$1;
+ let buffer = [];
+ let devtoolsNotInstalled = false;
+ function emit$1(event, ...args) {
+ if (devtools$1) {
+ devtools$1.emit(event, ...args);
+ } else if (!devtoolsNotInstalled) {
+ buffer.push({ event, args });
+ }
+ }
+ function setDevtoolsHook$1(hook, target) {
+ var _a, _b;
+ devtools$1 = hook;
+ if (devtools$1) {
+ devtools$1.enabled = true;
+ buffer.forEach(({ event, args }) => devtools$1.emit(event, ...args));
+ buffer = [];
+ } else if (
+ // handle late devtools injection - only do this if we are in an actual
+ // browser environment to avoid the timer handle stalling test runner exit
+ // (#4815)
+ typeof window !== "undefined" && // some envs mock window but not fully
+ window.HTMLElement && // also exclude jsdom
+ // eslint-disable-next-line no-restricted-syntax
+ !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom"))
+ ) {
+ const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || [];
+ replay.push((newHook) => {
+ setDevtoolsHook$1(newHook, target);
+ });
+ setTimeout(() => {
+ if (!devtools$1) {
+ target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null;
+ devtoolsNotInstalled = true;
+ buffer = [];
+ }
+ }, 3e3);
+ } else {
+ devtoolsNotInstalled = true;
+ buffer = [];
+ }
+ }
+ function devtoolsInitApp(app, version) {
+ emit$1("app:init" /* APP_INIT */, app, version, {
+ Fragment,
+ Text,
+ Comment,
+ Static
+ });
+ }
+ function devtoolsUnmountApp(app) {
+ emit$1("app:unmount" /* APP_UNMOUNT */, app);
+ }
+ const devtoolsComponentAdded = /* @__PURE__ */ createDevtoolsComponentHook("component:added" /* COMPONENT_ADDED */);
+ const devtoolsComponentUpdated = /* @__PURE__ */ createDevtoolsComponentHook("component:updated" /* COMPONENT_UPDATED */);
+ const _devtoolsComponentRemoved = /* @__PURE__ */ createDevtoolsComponentHook(
+ "component:removed" /* COMPONENT_REMOVED */
+ );
+ const devtoolsComponentRemoved = (component) => {
+ if (devtools$1 && typeof devtools$1.cleanupBuffer === "function" && // remove the component if it wasn't buffered
+ !devtools$1.cleanupBuffer(component)) {
+ _devtoolsComponentRemoved(component);
+ }
+ };
+ // @__NO_SIDE_EFFECTS__
+ function createDevtoolsComponentHook(hook) {
+ return (component) => {
+ emit$1(
+ hook,
+ component.appContext.app,
+ component.uid,
+ component.parent ? component.parent.uid : void 0,
+ component
+ );
+ };
+ }
+ const devtoolsPerfStart = /* @__PURE__ */ createDevtoolsPerformanceHook("perf:start" /* PERFORMANCE_START */);
+ const devtoolsPerfEnd = /* @__PURE__ */ createDevtoolsPerformanceHook("perf:end" /* PERFORMANCE_END */);
+ function createDevtoolsPerformanceHook(hook) {
+ return (component, type, time) => {
+ emit$1(hook, component.appContext.app, component.uid, component, type, time);
+ };
+ }
+ function devtoolsComponentEmit(component, event, params) {
+ emit$1(
+ "component:emit" /* COMPONENT_EMIT */,
+ component.appContext.app,
+ component,
+ event,
+ params
+ );
+ }
+
+ let currentRenderingInstance = null;
+ let currentScopeId = null;
+ function setCurrentRenderingInstance(instance) {
+ const prev = currentRenderingInstance;
+ currentRenderingInstance = instance;
+ currentScopeId = instance && instance.type.__scopeId || null;
+ return prev;
+ }
+ function pushScopeId(id) {
+ currentScopeId = id;
+ }
+ function popScopeId() {
+ currentScopeId = null;
+ }
+ const withScopeId = (_id) => withCtx;
+ function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) {
+ if (!ctx) return fn;
+ if (fn._n) {
+ return fn;
+ }
+ const renderFnWithContext = (...args) => {
+ if (renderFnWithContext._d) {
+ setBlockTracking(-1);
+ }
+ const prevInstance = setCurrentRenderingInstance(ctx);
+ let res;
+ try {
+ res = fn(...args);
+ } finally {
+ setCurrentRenderingInstance(prevInstance);
+ if (renderFnWithContext._d) {
+ setBlockTracking(1);
+ }
+ }
+ {
+ devtoolsComponentUpdated(ctx);
+ }
+ return res;
+ };
+ renderFnWithContext._n = true;
+ renderFnWithContext._c = true;
+ renderFnWithContext._d = true;
+ return renderFnWithContext;
+ }
+
+ function validateDirectiveName(name) {
+ if (isBuiltInDirective(name)) {
+ warn$1("Do not use built-in directive ids as custom directive id: " + name);
+ }
+ }
+ function withDirectives(vnode, directives) {
+ if (currentRenderingInstance === null) {
+ warn$1(`withDirectives can only be used inside render functions.`);
+ return vnode;
+ }
+ const instance = getComponentPublicInstance(currentRenderingInstance);
+ const bindings = vnode.dirs || (vnode.dirs = []);
+ for (let i = 0; i < directives.length; i++) {
+ let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i];
+ if (dir) {
+ if (isFunction(dir)) {
+ dir = {
+ mounted: dir,
+ updated: dir
+ };
+ }
+ if (dir.deep) {
+ traverse(value);
+ }
+ bindings.push({
+ dir,
+ instance,
+ value,
+ oldValue: void 0,
+ arg,
+ modifiers
+ });
+ }
+ }
+ return vnode;
+ }
+ function invokeDirectiveHook(vnode, prevVNode, instance, name) {
+ const bindings = vnode.dirs;
+ const oldBindings = prevVNode && prevVNode.dirs;
+ for (let i = 0; i < bindings.length; i++) {
+ const binding = bindings[i];
+ if (oldBindings) {
+ binding.oldValue = oldBindings[i].value;
+ }
+ let hook = binding.dir[name];
+ if (hook) {
+ pauseTracking();
+ callWithAsyncErrorHandling(hook, instance, 8, [
+ vnode.el,
+ binding,
+ vnode,
+ prevVNode
+ ]);
+ resetTracking();
+ }
+ }
+ }
+
+ const TeleportEndKey = Symbol("_vte");
+ const isTeleport = (type) => type.__isTeleport;
+ const isTeleportDisabled = (props) => props && (props.disabled || props.disabled === "");
+ const isTeleportDeferred = (props) => props && (props.defer || props.defer === "");
+ const isTargetSVG = (target) => typeof SVGElement !== "undefined" && target instanceof SVGElement;
+ const isTargetMathML = (target) => typeof MathMLElement === "function" && target instanceof MathMLElement;
+ const resolveTarget = (props, select) => {
+ const targetSelector = props && props.to;
+ if (isString(targetSelector)) {
+ if (!select) {
+ warn$1(
+ `Current renderer does not support string target for Teleports. (missing querySelector renderer option)`
+ );
+ return null;
+ } else {
+ const target = select(targetSelector);
+ if (!target && !isTeleportDisabled(props)) {
+ warn$1(
+ `Failed to locate Teleport target with selector "${targetSelector}". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.`
+ );
+ }
+ return target;
+ }
+ } else {
+ if (!targetSelector && !isTeleportDisabled(props)) {
+ warn$1(`Invalid Teleport target: ${targetSelector}`);
+ }
+ return targetSelector;
+ }
+ };
+ const TeleportImpl = {
+ name: "Teleport",
+ __isTeleport: true,
+ process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) {
+ const {
+ mc: mountChildren,
+ pc: patchChildren,
+ pbc: patchBlockChildren,
+ o: { insert, querySelector, createText, createComment }
+ } = internals;
+ const disabled = isTeleportDisabled(n2.props);
+ let { shapeFlag, children, dynamicChildren } = n2;
+ if (isHmrUpdating) {
+ optimized = false;
+ dynamicChildren = null;
+ }
+ if (n1 == null) {
+ const placeholder = n2.el = createComment("teleport start") ;
+ const mainAnchor = n2.anchor = createComment("teleport end") ;
+ insert(placeholder, container, anchor);
+ insert(mainAnchor, container, anchor);
+ const mount = (container2, anchor2) => {
+ if (shapeFlag & 16) {
+ mountChildren(
+ children,
+ container2,
+ anchor2,
+ parentComponent,
+ parentSuspense,
+ namespace,
+ slotScopeIds,
+ optimized
+ );
+ }
+ };
+ const mountToTarget = () => {
+ const target = n2.target = resolveTarget(n2.props, querySelector);
+ const targetAnchor = prepareAnchor(target, n2, createText, insert);
+ if (target) {
+ if (namespace !== "svg" && isTargetSVG(target)) {
+ namespace = "svg";
+ } else if (namespace !== "mathml" && isTargetMathML(target)) {
+ namespace = "mathml";
+ }
+ if (parentComponent && parentComponent.isCE) {
+ (parentComponent.ce._teleportTargets || (parentComponent.ce._teleportTargets = /* @__PURE__ */ new Set())).add(target);
+ }
+ if (!disabled) {
+ mount(target, targetAnchor);
+ updateCssVars(n2, false);
+ }
+ } else if (!disabled) {
+ warn$1(
+ "Invalid Teleport target on mount:",
+ target,
+ `(${typeof target})`
+ );
+ }
+ };
+ if (disabled) {
+ mount(container, mainAnchor);
+ updateCssVars(n2, true);
+ }
+ if (isTeleportDeferred(n2.props)) {
+ n2.el.__isMounted = false;
+ queuePostRenderEffect(() => {
+ mountToTarget();
+ delete n2.el.__isMounted;
+ }, parentSuspense);
+ } else {
+ mountToTarget();
+ }
+ } else {
+ if (isTeleportDeferred(n2.props) && n1.el.__isMounted === false) {
+ queuePostRenderEffect(() => {
+ TeleportImpl.process(
+ n1,
+ n2,
+ container,
+ anchor,
+ parentComponent,
+ parentSuspense,
+ namespace,
+ slotScopeIds,
+ optimized,
+ internals
+ );
+ }, parentSuspense);
+ return;
+ }
+ n2.el = n1.el;
+ n2.targetStart = n1.targetStart;
+ const mainAnchor = n2.anchor = n1.anchor;
+ const target = n2.target = n1.target;
+ const targetAnchor = n2.targetAnchor = n1.targetAnchor;
+ const wasDisabled = isTeleportDisabled(n1.props);
+ const currentContainer = wasDisabled ? container : target;
+ const currentAnchor = wasDisabled ? mainAnchor : targetAnchor;
+ if (namespace === "svg" || isTargetSVG(target)) {
+ namespace = "svg";
+ } else if (namespace === "mathml" || isTargetMathML(target)) {
+ namespace = "mathml";
+ }
+ if (dynamicChildren) {
+ patchBlockChildren(
+ n1.dynamicChildren,
+ dynamicChildren,
+ currentContainer,
+ parentComponent,
+ parentSuspense,
+ namespace,
+ slotScopeIds
+ );
+ traverseStaticChildren(n1, n2, false);
+ } else if (!optimized) {
+ patchChildren(
+ n1,
+ n2,
+ currentContainer,
+ currentAnchor,
+ parentComponent,
+ parentSuspense,
+ namespace,
+ slotScopeIds,
+ false
+ );
+ }
+ if (disabled) {
+ if (!wasDisabled) {
+ moveTeleport(
+ n2,
+ container,
+ mainAnchor,
+ internals,
+ 1
+ );
+ } else {
+ if (n2.props && n1.props && n2.props.to !== n1.props.to) {
+ n2.props.to = n1.props.to;
+ }
+ }
+ } else {
+ if ((n2.props && n2.props.to) !== (n1.props && n1.props.to)) {
+ const nextTarget = n2.target = resolveTarget(
+ n2.props,
+ querySelector
+ );
+ if (nextTarget) {
+ moveTeleport(
+ n2,
+ nextTarget,
+ null,
+ internals,
+ 0
+ );
+ } else {
+ warn$1(
+ "Invalid Teleport target on update:",
+ target,
+ `(${typeof target})`
+ );
+ }
+ } else if (wasDisabled) {
+ moveTeleport(
+ n2,
+ target,
+ targetAnchor,
+ internals,
+ 1
+ );
+ }
+ }
+ updateCssVars(n2, disabled);
+ }
+ },
+ remove(vnode, parentComponent, parentSuspense, { um: unmount, o: { remove: hostRemove } }, doRemove) {
+ const {
+ shapeFlag,
+ children,
+ anchor,
+ targetStart,
+ targetAnchor,
+ target,
+ props
+ } = vnode;
+ if (target) {
+ hostRemove(targetStart);
+ hostRemove(targetAnchor);
+ }
+ doRemove && hostRemove(anchor);
+ if (shapeFlag & 16) {
+ const shouldRemove = doRemove || !isTeleportDisabled(props);
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ unmount(
+ child,
+ parentComponent,
+ parentSuspense,
+ shouldRemove,
+ !!child.dynamicChildren
+ );
+ }
+ }
+ },
+ move: moveTeleport,
+ hydrate: hydrateTeleport
+ };
+ function moveTeleport(vnode, container, parentAnchor, { o: { insert }, m: move }, moveType = 2) {
+ if (moveType === 0) {
+ insert(vnode.targetAnchor, container, parentAnchor);
+ }
+ const { el, anchor, shapeFlag, children, props } = vnode;
+ const isReorder = moveType === 2;
+ if (isReorder) {
+ insert(el, container, parentAnchor);
+ }
+ if (!isReorder || isTeleportDisabled(props)) {
+ if (shapeFlag & 16) {
+ for (let i = 0; i < children.length; i++) {
+ move(
+ children[i],
+ container,
+ parentAnchor,
+ 2
+ );
+ }
+ }
+ }
+ if (isReorder) {
+ insert(anchor, container, parentAnchor);
+ }
+ }
+ function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized, {
+ o: { nextSibling, parentNode, querySelector, insert, createText }
+ }, hydrateChildren) {
+ function hydrateDisabledTeleport(node2, vnode2, targetStart, targetAnchor) {
+ vnode2.anchor = hydrateChildren(
+ nextSibling(node2),
+ vnode2,
+ parentNode(node2),
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized
+ );
+ vnode2.targetStart = targetStart;
+ vnode2.targetAnchor = targetAnchor;
+ }
+ const target = vnode.target = resolveTarget(
+ vnode.props,
+ querySelector
+ );
+ const disabled = isTeleportDisabled(vnode.props);
+ if (target) {
+ const targetNode = target._lpa || target.firstChild;
+ if (vnode.shapeFlag & 16) {
+ if (disabled) {
+ hydrateDisabledTeleport(
+ node,
+ vnode,
+ targetNode,
+ targetNode && nextSibling(targetNode)
+ );
+ } else {
+ vnode.anchor = nextSibling(node);
+ let targetAnchor = targetNode;
+ while (targetAnchor) {
+ if (targetAnchor && targetAnchor.nodeType === 8) {
+ if (targetAnchor.data === "teleport start anchor") {
+ vnode.targetStart = targetAnchor;
+ } else if (targetAnchor.data === "teleport anchor") {
+ vnode.targetAnchor = targetAnchor;
+ target._lpa = vnode.targetAnchor && nextSibling(vnode.targetAnchor);
+ break;
+ }
+ }
+ targetAnchor = nextSibling(targetAnchor);
+ }
+ if (!vnode.targetAnchor) {
+ prepareAnchor(target, vnode, createText, insert);
+ }
+ hydrateChildren(
+ targetNode && nextSibling(targetNode),
+ vnode,
+ target,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized
+ );
+ }
+ }
+ updateCssVars(vnode, disabled);
+ } else if (disabled) {
+ if (vnode.shapeFlag & 16) {
+ hydrateDisabledTeleport(node, vnode, node, nextSibling(node));
+ }
+ }
+ return vnode.anchor && nextSibling(vnode.anchor);
+ }
+ const Teleport = TeleportImpl;
+ function updateCssVars(vnode, isDisabled) {
+ const ctx = vnode.ctx;
+ if (ctx && ctx.ut) {
+ let node, anchor;
+ if (isDisabled) {
+ node = vnode.el;
+ anchor = vnode.anchor;
+ } else {
+ node = vnode.targetStart;
+ anchor = vnode.targetAnchor;
+ }
+ while (node && node !== anchor) {
+ if (node.nodeType === 1) node.setAttribute("data-v-owner", ctx.uid);
+ node = node.nextSibling;
+ }
+ ctx.ut();
+ }
+ }
+ function prepareAnchor(target, vnode, createText, insert) {
+ const targetStart = vnode.targetStart = createText("");
+ const targetAnchor = vnode.targetAnchor = createText("");
+ targetStart[TeleportEndKey] = targetAnchor;
+ if (target) {
+ insert(targetStart, target);
+ insert(targetAnchor, target);
+ }
+ return targetAnchor;
+ }
+
+ const leaveCbKey = Symbol("_leaveCb");
+ const enterCbKey$1 = Symbol("_enterCb");
+ function useTransitionState() {
+ const state = {
+ isMounted: false,
+ isLeaving: false,
+ isUnmounting: false,
+ leavingVNodes: /* @__PURE__ */ new Map()
+ };
+ onMounted(() => {
+ state.isMounted = true;
+ });
+ onBeforeUnmount(() => {
+ state.isUnmounting = true;
+ });
+ return state;
+ }
+ const TransitionHookValidator = [Function, Array];
+ const BaseTransitionPropsValidators = {
+ mode: String,
+ appear: Boolean,
+ persisted: Boolean,
+ // enter
+ onBeforeEnter: TransitionHookValidator,
+ onEnter: TransitionHookValidator,
+ onAfterEnter: TransitionHookValidator,
+ onEnterCancelled: TransitionHookValidator,
+ // leave
+ onBeforeLeave: TransitionHookValidator,
+ onLeave: TransitionHookValidator,
+ onAfterLeave: TransitionHookValidator,
+ onLeaveCancelled: TransitionHookValidator,
+ // appear
+ onBeforeAppear: TransitionHookValidator,
+ onAppear: TransitionHookValidator,
+ onAfterAppear: TransitionHookValidator,
+ onAppearCancelled: TransitionHookValidator
+ };
+ const recursiveGetSubtree = (instance) => {
+ const subTree = instance.subTree;
+ return subTree.component ? recursiveGetSubtree(subTree.component) : subTree;
+ };
+ const BaseTransitionImpl = {
+ name: `BaseTransition`,
+ props: BaseTransitionPropsValidators,
+ setup(props, { slots }) {
+ const instance = getCurrentInstance();
+ const state = useTransitionState();
+ return () => {
+ const children = slots.default && getTransitionRawChildren(slots.default(), true);
+ if (!children || !children.length) {
+ return;
+ }
+ const child = findNonCommentChild(children);
+ const rawProps = toRaw(props);
+ const { mode } = rawProps;
+ if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") {
+ warn$1(`invalid mode: ${mode}`);
+ }
+ if (state.isLeaving) {
+ return emptyPlaceholder(child);
+ }
+ const innerChild = getInnerChild$1(child);
+ if (!innerChild) {
+ return emptyPlaceholder(child);
+ }
+ let enterHooks = resolveTransitionHooks(
+ innerChild,
+ rawProps,
+ state,
+ instance,
+ // #11061, ensure enterHooks is fresh after clone
+ (hooks) => enterHooks = hooks
+ );
+ if (innerChild.type !== Comment) {
+ setTransitionHooks(innerChild, enterHooks);
+ }
+ let oldInnerChild = instance.subTree && getInnerChild$1(instance.subTree);
+ if (oldInnerChild && oldInnerChild.type !== Comment && !isSameVNodeType(oldInnerChild, innerChild) && recursiveGetSubtree(instance).type !== Comment) {
+ let leavingHooks = resolveTransitionHooks(
+ oldInnerChild,
+ rawProps,
+ state,
+ instance
+ );
+ setTransitionHooks(oldInnerChild, leavingHooks);
+ if (mode === "out-in" && innerChild.type !== Comment) {
+ state.isLeaving = true;
+ leavingHooks.afterLeave = () => {
+ state.isLeaving = false;
+ if (!(instance.job.flags & 8)) {
+ instance.update();
+ }
+ delete leavingHooks.afterLeave;
+ oldInnerChild = void 0;
+ };
+ return emptyPlaceholder(child);
+ } else if (mode === "in-out" && innerChild.type !== Comment) {
+ leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => {
+ const leavingVNodesCache = getLeavingNodesForType(
+ state,
+ oldInnerChild
+ );
+ leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild;
+ el[leaveCbKey] = () => {
+ earlyRemove();
+ el[leaveCbKey] = void 0;
+ delete enterHooks.delayedLeave;
+ oldInnerChild = void 0;
+ };
+ enterHooks.delayedLeave = () => {
+ delayedLeave();
+ delete enterHooks.delayedLeave;
+ oldInnerChild = void 0;
+ };
+ };
+ } else {
+ oldInnerChild = void 0;
+ }
+ } else if (oldInnerChild) {
+ oldInnerChild = void 0;
+ }
+ return child;
+ };
+ }
+ };
+ function findNonCommentChild(children) {
+ let child = children[0];
+ if (children.length > 1) {
+ let hasFound = false;
+ for (const c of children) {
+ if (c.type !== Comment) {
+ if (hasFound) {
+ warn$1(
+ " can only be used on a single element or component. Use for lists."
+ );
+ break;
+ }
+ child = c;
+ hasFound = true;
+ }
+ }
+ }
+ return child;
+ }
+ const BaseTransition = BaseTransitionImpl;
+ function getLeavingNodesForType(state, vnode) {
+ const { leavingVNodes } = state;
+ let leavingVNodesCache = leavingVNodes.get(vnode.type);
+ if (!leavingVNodesCache) {
+ leavingVNodesCache = /* @__PURE__ */ Object.create(null);
+ leavingVNodes.set(vnode.type, leavingVNodesCache);
+ }
+ return leavingVNodesCache;
+ }
+ function resolveTransitionHooks(vnode, props, state, instance, postClone) {
+ const {
+ appear,
+ mode,
+ persisted = false,
+ onBeforeEnter,
+ onEnter,
+ onAfterEnter,
+ onEnterCancelled,
+ onBeforeLeave,
+ onLeave,
+ onAfterLeave,
+ onLeaveCancelled,
+ onBeforeAppear,
+ onAppear,
+ onAfterAppear,
+ onAppearCancelled
+ } = props;
+ const key = String(vnode.key);
+ const leavingVNodesCache = getLeavingNodesForType(state, vnode);
+ const callHook = (hook, args) => {
+ hook && callWithAsyncErrorHandling(
+ hook,
+ instance,
+ 9,
+ args
+ );
+ };
+ const callAsyncHook = (hook, args) => {
+ const done = args[1];
+ callHook(hook, args);
+ if (isArray(hook)) {
+ if (hook.every((hook2) => hook2.length <= 1)) done();
+ } else if (hook.length <= 1) {
+ done();
+ }
+ };
+ const hooks = {
+ mode,
+ persisted,
+ beforeEnter(el) {
+ let hook = onBeforeEnter;
+ if (!state.isMounted) {
+ if (appear) {
+ hook = onBeforeAppear || onBeforeEnter;
+ } else {
+ return;
+ }
+ }
+ if (el[leaveCbKey]) {
+ el[leaveCbKey](
+ true
+ /* cancelled */
+ );
+ }
+ const leavingVNode = leavingVNodesCache[key];
+ if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) {
+ leavingVNode.el[leaveCbKey]();
+ }
+ callHook(hook, [el]);
+ },
+ enter(el) {
+ let hook = onEnter;
+ let afterHook = onAfterEnter;
+ let cancelHook = onEnterCancelled;
+ if (!state.isMounted) {
+ if (appear) {
+ hook = onAppear || onEnter;
+ afterHook = onAfterAppear || onAfterEnter;
+ cancelHook = onAppearCancelled || onEnterCancelled;
+ } else {
+ return;
+ }
+ }
+ let called = false;
+ const done = el[enterCbKey$1] = (cancelled) => {
+ if (called) return;
+ called = true;
+ if (cancelled) {
+ callHook(cancelHook, [el]);
+ } else {
+ callHook(afterHook, [el]);
+ }
+ if (hooks.delayedLeave) {
+ hooks.delayedLeave();
+ }
+ el[enterCbKey$1] = void 0;
+ };
+ if (hook) {
+ callAsyncHook(hook, [el, done]);
+ } else {
+ done();
+ }
+ },
+ leave(el, remove) {
+ const key2 = String(vnode.key);
+ if (el[enterCbKey$1]) {
+ el[enterCbKey$1](
+ true
+ /* cancelled */
+ );
+ }
+ if (state.isUnmounting) {
+ return remove();
+ }
+ callHook(onBeforeLeave, [el]);
+ let called = false;
+ const done = el[leaveCbKey] = (cancelled) => {
+ if (called) return;
+ called = true;
+ remove();
+ if (cancelled) {
+ callHook(onLeaveCancelled, [el]);
+ } else {
+ callHook(onAfterLeave, [el]);
+ }
+ el[leaveCbKey] = void 0;
+ if (leavingVNodesCache[key2] === vnode) {
+ delete leavingVNodesCache[key2];
+ }
+ };
+ leavingVNodesCache[key2] = vnode;
+ if (onLeave) {
+ callAsyncHook(onLeave, [el, done]);
+ } else {
+ done();
+ }
+ },
+ clone(vnode2) {
+ const hooks2 = resolveTransitionHooks(
+ vnode2,
+ props,
+ state,
+ instance,
+ postClone
+ );
+ if (postClone) postClone(hooks2);
+ return hooks2;
+ }
+ };
+ return hooks;
+ }
+ function emptyPlaceholder(vnode) {
+ if (isKeepAlive(vnode)) {
+ vnode = cloneVNode(vnode);
+ vnode.children = null;
+ return vnode;
+ }
+ }
+ function getInnerChild$1(vnode) {
+ if (!isKeepAlive(vnode)) {
+ if (isTeleport(vnode.type) && vnode.children) {
+ return findNonCommentChild(vnode.children);
+ }
+ return vnode;
+ }
+ if (vnode.component) {
+ return vnode.component.subTree;
+ }
+ const { shapeFlag, children } = vnode;
+ if (children) {
+ if (shapeFlag & 16) {
+ return children[0];
+ }
+ if (shapeFlag & 32 && isFunction(children.default)) {
+ return children.default();
+ }
+ }
+ }
+ function setTransitionHooks(vnode, hooks) {
+ if (vnode.shapeFlag & 6 && vnode.component) {
+ vnode.transition = hooks;
+ setTransitionHooks(vnode.component.subTree, hooks);
+ } else if (vnode.shapeFlag & 128) {
+ vnode.ssContent.transition = hooks.clone(vnode.ssContent);
+ vnode.ssFallback.transition = hooks.clone(vnode.ssFallback);
+ } else {
+ vnode.transition = hooks;
+ }
+ }
+ function getTransitionRawChildren(children, keepComment = false, parentKey) {
+ let ret = [];
+ let keyedFragmentCount = 0;
+ for (let i = 0; i < children.length; i++) {
+ let child = children[i];
+ const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i);
+ if (child.type === Fragment) {
+ if (child.patchFlag & 128) keyedFragmentCount++;
+ ret = ret.concat(
+ getTransitionRawChildren(child.children, keepComment, key)
+ );
+ } else if (keepComment || child.type !== Comment) {
+ ret.push(key != null ? cloneVNode(child, { key }) : child);
+ }
+ }
+ if (keyedFragmentCount > 1) {
+ for (let i = 0; i < ret.length; i++) {
+ ret[i].patchFlag = -2;
+ }
+ }
+ return ret;
+ }
+
+ // @__NO_SIDE_EFFECTS__
+ function defineComponent(options, extraOptions) {
+ return isFunction(options) ? (
+ // #8236: extend call and options.name access are considered side-effects
+ // by Rollup, so we have to wrap it in a pure-annotated IIFE.
+ /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))()
+ ) : options;
+ }
+
+ function useId() {
+ const i = getCurrentInstance();
+ if (i) {
+ return (i.appContext.config.idPrefix || "v") + "-" + i.ids[0] + i.ids[1]++;
+ } else {
+ warn$1(
+ `useId() is called when there is no active component instance to be associated with.`
+ );
+ }
+ return "";
+ }
+ function markAsyncBoundary(instance) {
+ instance.ids = [instance.ids[0] + instance.ids[2]++ + "-", 0, 0];
+ }
+
+ const knownTemplateRefs = /* @__PURE__ */ new WeakSet();
+ function useTemplateRef(key) {
+ const i = getCurrentInstance();
+ const r = shallowRef(null);
+ if (i) {
+ const refs = i.refs === EMPTY_OBJ ? i.refs = {} : i.refs;
+ let desc;
+ if ((desc = Object.getOwnPropertyDescriptor(refs, key)) && !desc.configurable) {
+ warn$1(`useTemplateRef('${key}') already exists.`);
+ } else {
+ Object.defineProperty(refs, key, {
+ enumerable: true,
+ get: () => r.value,
+ set: (val) => r.value = val
+ });
+ }
+ } else {
+ warn$1(
+ `useTemplateRef() is called when there is no active component instance to be associated with.`
+ );
+ }
+ const ret = readonly(r) ;
+ {
+ knownTemplateRefs.add(ret);
+ }
+ return ret;
+ }
+
+ const pendingSetRefMap = /* @__PURE__ */ new WeakMap();
+ function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) {
+ if (isArray(rawRef)) {
+ rawRef.forEach(
+ (r, i) => setRef(
+ r,
+ oldRawRef && (isArray(oldRawRef) ? oldRawRef[i] : oldRawRef),
+ parentSuspense,
+ vnode,
+ isUnmount
+ )
+ );
+ return;
+ }
+ if (isAsyncWrapper(vnode) && !isUnmount) {
+ if (vnode.shapeFlag & 512 && vnode.type.__asyncResolved && vnode.component.subTree.component) {
+ setRef(rawRef, oldRawRef, parentSuspense, vnode.component.subTree);
+ }
+ return;
+ }
+ const refValue = vnode.shapeFlag & 4 ? getComponentPublicInstance(vnode.component) : vnode.el;
+ const value = isUnmount ? null : refValue;
+ const { i: owner, r: ref } = rawRef;
+ if (!owner) {
+ warn$1(
+ `Missing ref owner context. ref cannot be used on hoisted vnodes. A vnode with ref must be created inside the render function.`
+ );
+ return;
+ }
+ const oldRef = oldRawRef && oldRawRef.r;
+ const refs = owner.refs === EMPTY_OBJ ? owner.refs = {} : owner.refs;
+ const setupState = owner.setupState;
+ const rawSetupState = toRaw(setupState);
+ const canSetSetupRef = setupState === EMPTY_OBJ ? NO : (key) => {
+ {
+ if (hasOwn(rawSetupState, key) && !isRef(rawSetupState[key])) {
+ warn$1(
+ `Template ref "${key}" used on a non-ref value. It will not work in the production build.`
+ );
+ }
+ if (knownTemplateRefs.has(rawSetupState[key])) {
+ return false;
+ }
+ }
+ return hasOwn(rawSetupState, key);
+ };
+ const canSetRef = (ref2) => {
+ return !knownTemplateRefs.has(ref2);
+ };
+ if (oldRef != null && oldRef !== ref) {
+ invalidatePendingSetRef(oldRawRef);
+ if (isString(oldRef)) {
+ refs[oldRef] = null;
+ if (canSetSetupRef(oldRef)) {
+ setupState[oldRef] = null;
+ }
+ } else if (isRef(oldRef)) {
+ if (canSetRef(oldRef)) {
+ oldRef.value = null;
+ }
+ const oldRawRefAtom = oldRawRef;
+ if (oldRawRefAtom.k) refs[oldRawRefAtom.k] = null;
+ }
+ }
+ if (isFunction(ref)) {
+ callWithErrorHandling(ref, owner, 12, [value, refs]);
+ } else {
+ const _isString = isString(ref);
+ const _isRef = isRef(ref);
+ if (_isString || _isRef) {
+ const doSet = () => {
+ if (rawRef.f) {
+ const existing = _isString ? canSetSetupRef(ref) ? setupState[ref] : refs[ref] : canSetRef(ref) || !rawRef.k ? ref.value : refs[rawRef.k];
+ if (isUnmount) {
+ isArray(existing) && remove(existing, refValue);
+ } else {
+ if (!isArray(existing)) {
+ if (_isString) {
+ refs[ref] = [refValue];
+ if (canSetSetupRef(ref)) {
+ setupState[ref] = refs[ref];
+ }
+ } else {
+ const newVal = [refValue];
+ if (canSetRef(ref)) {
+ ref.value = newVal;
+ }
+ if (rawRef.k) refs[rawRef.k] = newVal;
+ }
+ } else if (!existing.includes(refValue)) {
+ existing.push(refValue);
+ }
+ }
+ } else if (_isString) {
+ refs[ref] = value;
+ if (canSetSetupRef(ref)) {
+ setupState[ref] = value;
+ }
+ } else if (_isRef) {
+ if (canSetRef(ref)) {
+ ref.value = value;
+ }
+ if (rawRef.k) refs[rawRef.k] = value;
+ } else {
+ warn$1("Invalid template ref type:", ref, `(${typeof ref})`);
+ }
+ };
+ if (value) {
+ const job = () => {
+ doSet();
+ pendingSetRefMap.delete(rawRef);
+ };
+ job.id = -1;
+ pendingSetRefMap.set(rawRef, job);
+ queuePostRenderEffect(job, parentSuspense);
+ } else {
+ invalidatePendingSetRef(rawRef);
+ doSet();
+ }
+ } else {
+ warn$1("Invalid template ref type:", ref, `(${typeof ref})`);
+ }
+ }
+ }
+ function invalidatePendingSetRef(rawRef) {
+ const pendingSetRef = pendingSetRefMap.get(rawRef);
+ if (pendingSetRef) {
+ pendingSetRef.flags |= 8;
+ pendingSetRefMap.delete(rawRef);
+ }
+ }
+
+ let hasLoggedMismatchError = false;
+ const logMismatchError = () => {
+ if (hasLoggedMismatchError) {
+ return;
+ }
+ console.error("Hydration completed but contains mismatches.");
+ hasLoggedMismatchError = true;
+ };
+ const isSVGContainer = (container) => container.namespaceURI.includes("svg") && container.tagName !== "foreignObject";
+ const isMathMLContainer = (container) => container.namespaceURI.includes("MathML");
+ const getContainerType = (container) => {
+ if (container.nodeType !== 1) return void 0;
+ if (isSVGContainer(container)) return "svg";
+ if (isMathMLContainer(container)) return "mathml";
+ return void 0;
+ };
+ const isComment = (node) => node.nodeType === 8;
+ function createHydrationFunctions(rendererInternals) {
+ const {
+ mt: mountComponent,
+ p: patch,
+ o: {
+ patchProp,
+ createText,
+ nextSibling,
+ parentNode,
+ remove,
+ insert,
+ createComment
+ }
+ } = rendererInternals;
+ const hydrate = (vnode, container) => {
+ if (!container.hasChildNodes()) {
+ warn$1(
+ `Attempting to hydrate existing markup but container is empty. Performing full mount instead.`
+ );
+ patch(null, vnode, container);
+ flushPostFlushCbs();
+ container._vnode = vnode;
+ return;
+ }
+ hydrateNode(container.firstChild, vnode, null, null, null);
+ flushPostFlushCbs();
+ container._vnode = vnode;
+ };
+ const hydrateNode = (node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized = false) => {
+ optimized = optimized || !!vnode.dynamicChildren;
+ const isFragmentStart = isComment(node) && node.data === "[";
+ const onMismatch = () => handleMismatch(
+ node,
+ vnode,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ isFragmentStart
+ );
+ const { type, ref, shapeFlag, patchFlag } = vnode;
+ let domType = node.nodeType;
+ vnode.el = node;
+ {
+ def(node, "__vnode", vnode, true);
+ def(node, "__vueParentComponent", parentComponent, true);
+ }
+ if (patchFlag === -2) {
+ optimized = false;
+ vnode.dynamicChildren = null;
+ }
+ let nextNode = null;
+ switch (type) {
+ case Text:
+ if (domType !== 3) {
+ if (vnode.children === "") {
+ insert(vnode.el = createText(""), parentNode(node), node);
+ nextNode = node;
+ } else {
+ nextNode = onMismatch();
+ }
+ } else {
+ if (node.data !== vnode.children) {
+ warn$1(
+ `Hydration text mismatch in`,
+ node.parentNode,
+ `
+ - rendered on server: ${JSON.stringify(
+ node.data
+ )}
+ - expected on client: ${JSON.stringify(vnode.children)}`
+ );
+ logMismatchError();
+ node.data = vnode.children;
+ }
+ nextNode = nextSibling(node);
+ }
+ break;
+ case Comment:
+ if (isTemplateNode(node)) {
+ nextNode = nextSibling(node);
+ replaceNode(
+ vnode.el = node.content.firstChild,
+ node,
+ parentComponent
+ );
+ } else if (domType !== 8 || isFragmentStart) {
+ nextNode = onMismatch();
+ } else {
+ nextNode = nextSibling(node);
+ }
+ break;
+ case Static:
+ if (isFragmentStart) {
+ node = nextSibling(node);
+ domType = node.nodeType;
+ }
+ if (domType === 1 || domType === 3) {
+ nextNode = node;
+ const needToAdoptContent = !vnode.children.length;
+ for (let i = 0; i < vnode.staticCount; i++) {
+ if (needToAdoptContent)
+ vnode.children += nextNode.nodeType === 1 ? nextNode.outerHTML : nextNode.data;
+ if (i === vnode.staticCount - 1) {
+ vnode.anchor = nextNode;
+ }
+ nextNode = nextSibling(nextNode);
+ }
+ return isFragmentStart ? nextSibling(nextNode) : nextNode;
+ } else {
+ onMismatch();
+ }
+ break;
+ case Fragment:
+ if (!isFragmentStart) {
+ nextNode = onMismatch();
+ } else {
+ nextNode = hydrateFragment(
+ node,
+ vnode,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized
+ );
+ }
+ break;
+ default:
+ if (shapeFlag & 1) {
+ if ((domType !== 1 || vnode.type.toLowerCase() !== node.tagName.toLowerCase()) && !isTemplateNode(node)) {
+ nextNode = onMismatch();
+ } else {
+ nextNode = hydrateElement(
+ node,
+ vnode,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized
+ );
+ }
+ } else if (shapeFlag & 6) {
+ vnode.slotScopeIds = slotScopeIds;
+ const container = parentNode(node);
+ if (isFragmentStart) {
+ nextNode = locateClosingAnchor(node);
+ } else if (isComment(node) && node.data === "teleport start") {
+ nextNode = locateClosingAnchor(node, node.data, "teleport end");
+ } else {
+ nextNode = nextSibling(node);
+ }
+ mountComponent(
+ vnode,
+ container,
+ null,
+ parentComponent,
+ parentSuspense,
+ getContainerType(container),
+ optimized
+ );
+ if (isAsyncWrapper(vnode) && !vnode.type.__asyncResolved) {
+ let subTree;
+ if (isFragmentStart) {
+ subTree = createVNode(Fragment);
+ subTree.anchor = nextNode ? nextNode.previousSibling : container.lastChild;
+ } else {
+ subTree = node.nodeType === 3 ? createTextVNode("") : createVNode("div");
+ }
+ subTree.el = node;
+ vnode.component.subTree = subTree;
+ }
+ } else if (shapeFlag & 64) {
+ if (domType !== 8) {
+ nextNode = onMismatch();
+ } else {
+ nextNode = vnode.type.hydrate(
+ node,
+ vnode,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized,
+ rendererInternals,
+ hydrateChildren
+ );
+ }
+ } else if (shapeFlag & 128) {
+ nextNode = vnode.type.hydrate(
+ node,
+ vnode,
+ parentComponent,
+ parentSuspense,
+ getContainerType(parentNode(node)),
+ slotScopeIds,
+ optimized,
+ rendererInternals,
+ hydrateNode
+ );
+ } else {
+ warn$1("Invalid HostVNode type:", type, `(${typeof type})`);
+ }
+ }
+ if (ref != null) {
+ setRef(ref, null, parentSuspense, vnode);
+ }
+ return nextNode;
+ };
+ const hydrateElement = (el, vnode, parentComponent, parentSuspense, slotScopeIds, optimized) => {
+ optimized = optimized || !!vnode.dynamicChildren;
+ const { type, props, patchFlag, shapeFlag, dirs, transition } = vnode;
+ const forcePatch = type === "input" || type === "option";
+ {
+ if (dirs) {
+ invokeDirectiveHook(vnode, null, parentComponent, "created");
+ }
+ let needCallTransitionHooks = false;
+ if (isTemplateNode(el)) {
+ needCallTransitionHooks = needTransition(
+ null,
+ // no need check parentSuspense in hydration
+ transition
+ ) && parentComponent && parentComponent.vnode.props && parentComponent.vnode.props.appear;
+ const content = el.content.firstChild;
+ if (needCallTransitionHooks) {
+ const cls = content.getAttribute("class");
+ if (cls) content.$cls = cls;
+ transition.beforeEnter(content);
+ }
+ replaceNode(content, el, parentComponent);
+ vnode.el = el = content;
+ }
+ if (shapeFlag & 16 && // skip if element has innerHTML / textContent
+ !(props && (props.innerHTML || props.textContent))) {
+ let next = hydrateChildren(
+ el.firstChild,
+ vnode,
+ el,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized
+ );
+ let hasWarned = false;
+ while (next) {
+ if (!isMismatchAllowed(el, 1 /* CHILDREN */)) {
+ if (!hasWarned) {
+ warn$1(
+ `Hydration children mismatch on`,
+ el,
+ `
+Server rendered element contains more child nodes than client vdom.`
+ );
+ hasWarned = true;
+ }
+ logMismatchError();
+ }
+ const cur = next;
+ next = next.nextSibling;
+ remove(cur);
+ }
+ } else if (shapeFlag & 8) {
+ let clientText = vnode.children;
+ if (clientText[0] === "\n" && (el.tagName === "PRE" || el.tagName === "TEXTAREA")) {
+ clientText = clientText.slice(1);
+ }
+ if (el.textContent !== clientText) {
+ if (!isMismatchAllowed(el, 0 /* TEXT */)) {
+ warn$1(
+ `Hydration text content mismatch on`,
+ el,
+ `
+ - rendered on server: ${el.textContent}
+ - expected on client: ${vnode.children}`
+ );
+ logMismatchError();
+ }
+ el.textContent = vnode.children;
+ }
+ }
+ if (props) {
+ {
+ const isCustomElement = el.tagName.includes("-");
+ for (const key in props) {
+ if (// #11189 skip if this node has directives that have created hooks
+ // as it could have mutated the DOM in any possible way
+ !(dirs && dirs.some((d) => d.dir.created)) && propHasMismatch(el, key, props[key], vnode, parentComponent)) {
+ logMismatchError();
+ }
+ if (forcePatch && (key.endsWith("value") || key === "indeterminate") || isOn(key) && !isReservedProp(key) || // force hydrate v-bind with .prop modifiers
+ key[0] === "." || isCustomElement) {
+ patchProp(el, key, null, props[key], void 0, parentComponent);
+ }
+ }
+ }
+ }
+ let vnodeHooks;
+ if (vnodeHooks = props && props.onVnodeBeforeMount) {
+ invokeVNodeHook(vnodeHooks, parentComponent, vnode);
+ }
+ if (dirs) {
+ invokeDirectiveHook(vnode, null, parentComponent, "beforeMount");
+ }
+ if ((vnodeHooks = props && props.onVnodeMounted) || dirs || needCallTransitionHooks) {
+ queueEffectWithSuspense(() => {
+ vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode);
+ needCallTransitionHooks && transition.enter(el);
+ dirs && invokeDirectiveHook(vnode, null, parentComponent, "mounted");
+ }, parentSuspense);
+ }
+ }
+ return el.nextSibling;
+ };
+ const hydrateChildren = (node, parentVNode, container, parentComponent, parentSuspense, slotScopeIds, optimized) => {
+ optimized = optimized || !!parentVNode.dynamicChildren;
+ const children = parentVNode.children;
+ const l = children.length;
+ let hasWarned = false;
+ for (let i = 0; i < l; i++) {
+ const vnode = optimized ? children[i] : children[i] = normalizeVNode(children[i]);
+ const isText = vnode.type === Text;
+ if (node) {
+ if (isText && !optimized) {
+ if (i + 1 < l && normalizeVNode(children[i + 1]).type === Text) {
+ insert(
+ createText(
+ node.data.slice(vnode.children.length)
+ ),
+ container,
+ nextSibling(node)
+ );
+ node.data = vnode.children;
+ }
+ }
+ node = hydrateNode(
+ node,
+ vnode,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized
+ );
+ } else if (isText && !vnode.children) {
+ insert(vnode.el = createText(""), container);
+ } else {
+ if (!isMismatchAllowed(container, 1 /* CHILDREN */)) {
+ if (!hasWarned) {
+ warn$1(
+ `Hydration children mismatch on`,
+ container,
+ `
+Server rendered element contains fewer child nodes than client vdom.`
+ );
+ hasWarned = true;
+ }
+ logMismatchError();
+ }
+ patch(
+ null,
+ vnode,
+ container,
+ null,
+ parentComponent,
+ parentSuspense,
+ getContainerType(container),
+ slotScopeIds
+ );
+ }
+ }
+ return node;
+ };
+ const hydrateFragment = (node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized) => {
+ const { slotScopeIds: fragmentSlotScopeIds } = vnode;
+ if (fragmentSlotScopeIds) {
+ slotScopeIds = slotScopeIds ? slotScopeIds.concat(fragmentSlotScopeIds) : fragmentSlotScopeIds;
+ }
+ const container = parentNode(node);
+ const next = hydrateChildren(
+ nextSibling(node),
+ vnode,
+ container,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized
+ );
+ if (next && isComment(next) && next.data === "]") {
+ return nextSibling(vnode.anchor = next);
+ } else {
+ logMismatchError();
+ insert(vnode.anchor = createComment(`]`), container, next);
+ return next;
+ }
+ };
+ const handleMismatch = (node, vnode, parentComponent, parentSuspense, slotScopeIds, isFragment) => {
+ if (!isMismatchAllowed(node.parentElement, 1 /* CHILDREN */)) {
+ warn$1(
+ `Hydration node mismatch:
+- rendered on server:`,
+ node,
+ node.nodeType === 3 ? `(text)` : isComment(node) && node.data === "[" ? `(start of fragment)` : ``,
+ `
+- expected on client:`,
+ vnode.type
+ );
+ logMismatchError();
+ }
+ vnode.el = null;
+ if (isFragment) {
+ const end = locateClosingAnchor(node);
+ while (true) {
+ const next2 = nextSibling(node);
+ if (next2 && next2 !== end) {
+ remove(next2);
+ } else {
+ break;
+ }
+ }
+ }
+ const next = nextSibling(node);
+ const container = parentNode(node);
+ remove(node);
+ patch(
+ null,
+ vnode,
+ container,
+ next,
+ parentComponent,
+ parentSuspense,
+ getContainerType(container),
+ slotScopeIds
+ );
+ if (parentComponent) {
+ parentComponent.vnode.el = vnode.el;
+ updateHOCHostEl(parentComponent, vnode.el);
+ }
+ return next;
+ };
+ const locateClosingAnchor = (node, open = "[", close = "]") => {
+ let match = 0;
+ while (node) {
+ node = nextSibling(node);
+ if (node && isComment(node)) {
+ if (node.data === open) match++;
+ if (node.data === close) {
+ if (match === 0) {
+ return nextSibling(node);
+ } else {
+ match--;
+ }
+ }
+ }
+ }
+ return node;
+ };
+ const replaceNode = (newNode, oldNode, parentComponent) => {
+ const parentNode2 = oldNode.parentNode;
+ if (parentNode2) {
+ parentNode2.replaceChild(newNode, oldNode);
+ }
+ let parent = parentComponent;
+ while (parent) {
+ if (parent.vnode.el === oldNode) {
+ parent.vnode.el = parent.subTree.el = newNode;
+ }
+ parent = parent.parent;
+ }
+ };
+ const isTemplateNode = (node) => {
+ return node.nodeType === 1 && node.tagName === "TEMPLATE";
+ };
+ return [hydrate, hydrateNode];
+ }
+ function propHasMismatch(el, key, clientValue, vnode, instance) {
+ let mismatchType;
+ let mismatchKey;
+ let actual;
+ let expected;
+ if (key === "class") {
+ if (el.$cls) {
+ actual = el.$cls;
+ delete el.$cls;
+ } else {
+ actual = el.getAttribute("class");
+ }
+ expected = normalizeClass(clientValue);
+ if (!isSetEqual(toClassSet(actual || ""), toClassSet(expected))) {
+ mismatchType = 2 /* CLASS */;
+ mismatchKey = `class`;
+ }
+ } else if (key === "style") {
+ actual = el.getAttribute("style") || "";
+ expected = isString(clientValue) ? clientValue : stringifyStyle(normalizeStyle(clientValue));
+ const actualMap = toStyleMap(actual);
+ const expectedMap = toStyleMap(expected);
+ if (vnode.dirs) {
+ for (const { dir, value } of vnode.dirs) {
+ if (dir.name === "show" && !value) {
+ expectedMap.set("display", "none");
+ }
+ }
+ }
+ if (instance) {
+ resolveCssVars(instance, vnode, expectedMap);
+ }
+ if (!isMapEqual(actualMap, expectedMap)) {
+ mismatchType = 3 /* STYLE */;
+ mismatchKey = "style";
+ }
+ } else if (el instanceof SVGElement && isKnownSvgAttr(key) || el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) {
+ if (isBooleanAttr(key)) {
+ actual = el.hasAttribute(key);
+ expected = includeBooleanAttr(clientValue);
+ } else if (clientValue == null) {
+ actual = el.hasAttribute(key);
+ expected = false;
+ } else {
+ if (el.hasAttribute(key)) {
+ actual = el.getAttribute(key);
+ } else if (key === "value" && el.tagName === "TEXTAREA") {
+ actual = el.value;
+ } else {
+ actual = false;
+ }
+ expected = isRenderableAttrValue(clientValue) ? String(clientValue) : false;
+ }
+ if (actual !== expected) {
+ mismatchType = 4 /* ATTRIBUTE */;
+ mismatchKey = key;
+ }
+ }
+ if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) {
+ const format = (v) => v === false ? `(not rendered)` : `${mismatchKey}="${v}"`;
+ const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on`;
+ const postSegment = `
+ - rendered on server: ${format(actual)}
+ - expected on client: ${format(expected)}
+ Note: this mismatch is check-only. The DOM will not be rectified in production due to performance overhead.
+ You should fix the source of the mismatch.`;
+ {
+ warn$1(preSegment, el, postSegment);
+ }
+ return true;
+ }
+ return false;
+ }
+ function toClassSet(str) {
+ return new Set(str.trim().split(/\s+/));
+ }
+ function isSetEqual(a, b) {
+ if (a.size !== b.size) {
+ return false;
+ }
+ for (const s of a) {
+ if (!b.has(s)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ function toStyleMap(str) {
+ const styleMap = /* @__PURE__ */ new Map();
+ for (const item of str.split(";")) {
+ let [key, value] = item.split(":");
+ key = key.trim();
+ value = value && value.trim();
+ if (key && value) {
+ styleMap.set(key, value);
+ }
+ }
+ return styleMap;
+ }
+ function isMapEqual(a, b) {
+ if (a.size !== b.size) {
+ return false;
+ }
+ for (const [key, value] of a) {
+ if (value !== b.get(key)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ function resolveCssVars(instance, vnode, expectedMap) {
+ const root = instance.subTree;
+ if (instance.getCssVars && (vnode === root || root && root.type === Fragment && root.children.includes(vnode))) {
+ const cssVars = instance.getCssVars();
+ for (const key in cssVars) {
+ const value = normalizeCssVarValue(cssVars[key]);
+ expectedMap.set(`--${getEscapedCssVarName(key)}`, value);
+ }
+ }
+ if (vnode === root && instance.parent) {
+ resolveCssVars(instance.parent, instance.vnode, expectedMap);
+ }
+ }
+ const allowMismatchAttr = "data-allow-mismatch";
+ const MismatchTypeString = {
+ [0 /* TEXT */]: "text",
+ [1 /* CHILDREN */]: "children",
+ [2 /* CLASS */]: "class",
+ [3 /* STYLE */]: "style",
+ [4 /* ATTRIBUTE */]: "attribute"
+ };
+ function isMismatchAllowed(el, allowedType) {
+ if (allowedType === 0 /* TEXT */ || allowedType === 1 /* CHILDREN */) {
+ while (el && !el.hasAttribute(allowMismatchAttr)) {
+ el = el.parentElement;
+ }
+ }
+ const allowedAttr = el && el.getAttribute(allowMismatchAttr);
+ if (allowedAttr == null) {
+ return false;
+ } else if (allowedAttr === "") {
+ return true;
+ } else {
+ const list = allowedAttr.split(",");
+ if (allowedType === 0 /* TEXT */ && list.includes("children")) {
+ return true;
+ }
+ return list.includes(MismatchTypeString[allowedType]);
+ }
+ }
+
+ const requestIdleCallback = getGlobalThis().requestIdleCallback || ((cb) => setTimeout(cb, 1));
+ const cancelIdleCallback = getGlobalThis().cancelIdleCallback || ((id) => clearTimeout(id));
+ const hydrateOnIdle = (timeout = 1e4) => (hydrate) => {
+ const id = requestIdleCallback(hydrate, { timeout });
+ return () => cancelIdleCallback(id);
+ };
+ function elementIsVisibleInViewport(el) {
+ const { top, left, bottom, right } = el.getBoundingClientRect();
+ const { innerHeight, innerWidth } = window;
+ return (top > 0 && top < innerHeight || bottom > 0 && bottom < innerHeight) && (left > 0 && left < innerWidth || right > 0 && right < innerWidth);
+ }
+ const hydrateOnVisible = (opts) => (hydrate, forEach) => {
+ const ob = new IntersectionObserver((entries) => {
+ for (const e of entries) {
+ if (!e.isIntersecting) continue;
+ ob.disconnect();
+ hydrate();
+ break;
+ }
+ }, opts);
+ forEach((el) => {
+ if (!(el instanceof Element)) return;
+ if (elementIsVisibleInViewport(el)) {
+ hydrate();
+ ob.disconnect();
+ return false;
+ }
+ ob.observe(el);
+ });
+ return () => ob.disconnect();
+ };
+ const hydrateOnMediaQuery = (query) => (hydrate) => {
+ if (query) {
+ const mql = matchMedia(query);
+ if (mql.matches) {
+ hydrate();
+ } else {
+ mql.addEventListener("change", hydrate, { once: true });
+ return () => mql.removeEventListener("change", hydrate);
+ }
+ }
+ };
+ const hydrateOnInteraction = (interactions = []) => (hydrate, forEach) => {
+ if (isString(interactions)) interactions = [interactions];
+ let hasHydrated = false;
+ const doHydrate = (e) => {
+ if (!hasHydrated) {
+ hasHydrated = true;
+ teardown();
+ hydrate();
+ e.target.dispatchEvent(new e.constructor(e.type, e));
+ }
+ };
+ const teardown = () => {
+ forEach((el) => {
+ for (const i of interactions) {
+ el.removeEventListener(i, doHydrate);
+ }
+ });
+ };
+ forEach((el) => {
+ for (const i of interactions) {
+ el.addEventListener(i, doHydrate, { once: true });
+ }
+ });
+ return teardown;
+ };
+ function forEachElement(node, cb) {
+ if (isComment(node) && node.data === "[") {
+ let depth = 1;
+ let next = node.nextSibling;
+ while (next) {
+ if (next.nodeType === 1) {
+ const result = cb(next);
+ if (result === false) {
+ break;
+ }
+ } else if (isComment(next)) {
+ if (next.data === "]") {
+ if (--depth === 0) break;
+ } else if (next.data === "[") {
+ depth++;
+ }
+ }
+ next = next.nextSibling;
+ }
+ } else {
+ cb(node);
+ }
+ }
+
+ const isAsyncWrapper = (i) => !!i.type.__asyncLoader;
+ // @__NO_SIDE_EFFECTS__
+ function defineAsyncComponent(source) {
+ if (isFunction(source)) {
+ source = { loader: source };
+ }
+ const {
+ loader,
+ loadingComponent,
+ errorComponent,
+ delay = 200,
+ hydrate: hydrateStrategy,
+ timeout,
+ // undefined = never times out
+ suspensible = true,
+ onError: userOnError
+ } = source;
+ let pendingRequest = null;
+ let resolvedComp;
+ let retries = 0;
+ const retry = () => {
+ retries++;
+ pendingRequest = null;
+ return load();
+ };
+ const load = () => {
+ let thisRequest;
+ return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => {
+ err = err instanceof Error ? err : new Error(String(err));
+ if (userOnError) {
+ return new Promise((resolve, reject) => {
+ const userRetry = () => resolve(retry());
+ const userFail = () => reject(err);
+ userOnError(err, userRetry, userFail, retries + 1);
+ });
+ } else {
+ throw err;
+ }
+ }).then((comp) => {
+ if (thisRequest !== pendingRequest && pendingRequest) {
+ return pendingRequest;
+ }
+ if (!comp) {
+ warn$1(
+ `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.`
+ );
+ }
+ if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) {
+ comp = comp.default;
+ }
+ if (comp && !isObject(comp) && !isFunction(comp)) {
+ throw new Error(`Invalid async component load result: ${comp}`);
+ }
+ resolvedComp = comp;
+ return comp;
+ }));
+ };
+ return defineComponent({
+ name: "AsyncComponentWrapper",
+ __asyncLoader: load,
+ __asyncHydrate(el, instance, hydrate) {
+ let patched = false;
+ (instance.bu || (instance.bu = [])).push(() => patched = true);
+ const performHydrate = () => {
+ if (patched) {
+ {
+ warn$1(
+ `Skipping lazy hydration for component '${getComponentName(resolvedComp) || resolvedComp.__file}': it was updated before lazy hydration performed.`
+ );
+ }
+ return;
+ }
+ hydrate();
+ };
+ const doHydrate = hydrateStrategy ? () => {
+ const teardown = hydrateStrategy(
+ performHydrate,
+ (cb) => forEachElement(el, cb)
+ );
+ if (teardown) {
+ (instance.bum || (instance.bum = [])).push(teardown);
+ }
+ } : performHydrate;
+ if (resolvedComp) {
+ doHydrate();
+ } else {
+ load().then(() => !instance.isUnmounted && doHydrate());
+ }
+ },
+ get __asyncResolved() {
+ return resolvedComp;
+ },
+ setup() {
+ const instance = currentInstance;
+ markAsyncBoundary(instance);
+ if (resolvedComp) {
+ return () => createInnerComp(resolvedComp, instance);
+ }
+ const onError = (err) => {
+ pendingRequest = null;
+ handleError(
+ err,
+ instance,
+ 13,
+ !errorComponent
+ );
+ };
+ if (suspensible && instance.suspense || false) {
+ return load().then((comp) => {
+ return () => createInnerComp(comp, instance);
+ }).catch((err) => {
+ onError(err);
+ return () => errorComponent ? createVNode(errorComponent, {
+ error: err
+ }) : null;
+ });
+ }
+ const loaded = ref(false);
+ const error = ref();
+ const delayed = ref(!!delay);
+ if (delay) {
+ setTimeout(() => {
+ delayed.value = false;
+ }, delay);
+ }
+ if (timeout != null) {
+ setTimeout(() => {
+ if (!loaded.value && !error.value) {
+ const err = new Error(
+ `Async component timed out after ${timeout}ms.`
+ );
+ onError(err);
+ error.value = err;
+ }
+ }, timeout);
+ }
+ load().then(() => {
+ loaded.value = true;
+ if (instance.parent && isKeepAlive(instance.parent.vnode)) {
+ instance.parent.update();
+ }
+ }).catch((err) => {
+ onError(err);
+ error.value = err;
+ });
+ return () => {
+ if (loaded.value && resolvedComp) {
+ return createInnerComp(resolvedComp, instance);
+ } else if (error.value && errorComponent) {
+ return createVNode(errorComponent, {
+ error: error.value
+ });
+ } else if (loadingComponent && !delayed.value) {
+ return createVNode(loadingComponent);
+ }
+ };
+ }
+ });
+ }
+ function createInnerComp(comp, parent) {
+ const { ref: ref2, props, children, ce } = parent.vnode;
+ const vnode = createVNode(comp, props, children);
+ vnode.ref = ref2;
+ vnode.ce = ce;
+ delete parent.vnode.ce;
+ return vnode;
+ }
+
+ const isKeepAlive = (vnode) => vnode.type.__isKeepAlive;
+ const KeepAliveImpl = {
+ name: `KeepAlive`,
+ // Marker for special handling inside the renderer. We are not using a ===
+ // check directly on KeepAlive in the renderer, because importing it directly
+ // would prevent it from being tree-shaken.
+ __isKeepAlive: true,
+ props: {
+ include: [String, RegExp, Array],
+ exclude: [String, RegExp, Array],
+ max: [String, Number]
+ },
+ setup(props, { slots }) {
+ const instance = getCurrentInstance();
+ const sharedContext = instance.ctx;
+ const cache = /* @__PURE__ */ new Map();
+ const keys = /* @__PURE__ */ new Set();
+ let current = null;
+ {
+ instance.__v_cache = cache;
+ }
+ const parentSuspense = instance.suspense;
+ const {
+ renderer: {
+ p: patch,
+ m: move,
+ um: _unmount,
+ o: { createElement }
+ }
+ } = sharedContext;
+ const storageContainer = createElement("div");
+ sharedContext.activate = (vnode, container, anchor, namespace, optimized) => {
+ const instance2 = vnode.component;
+ move(vnode, container, anchor, 0, parentSuspense);
+ patch(
+ instance2.vnode,
+ vnode,
+ container,
+ anchor,
+ instance2,
+ parentSuspense,
+ namespace,
+ vnode.slotScopeIds,
+ optimized
+ );
+ queuePostRenderEffect(() => {
+ instance2.isDeactivated = false;
+ if (instance2.a) {
+ invokeArrayFns(instance2.a);
+ }
+ const vnodeHook = vnode.props && vnode.props.onVnodeMounted;
+ if (vnodeHook) {
+ invokeVNodeHook(vnodeHook, instance2.parent, vnode);
+ }
+ }, parentSuspense);
+ {
+ devtoolsComponentAdded(instance2);
+ }
+ };
+ sharedContext.deactivate = (vnode) => {
+ const instance2 = vnode.component;
+ invalidateMount(instance2.m);
+ invalidateMount(instance2.a);
+ move(vnode, storageContainer, null, 1, parentSuspense);
+ queuePostRenderEffect(() => {
+ if (instance2.da) {
+ invokeArrayFns(instance2.da);
+ }
+ const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted;
+ if (vnodeHook) {
+ invokeVNodeHook(vnodeHook, instance2.parent, vnode);
+ }
+ instance2.isDeactivated = true;
+ }, parentSuspense);
+ {
+ devtoolsComponentAdded(instance2);
+ }
+ {
+ instance2.__keepAliveStorageContainer = storageContainer;
+ }
+ };
+ function unmount(vnode) {
+ resetShapeFlag(vnode);
+ _unmount(vnode, instance, parentSuspense, true);
+ }
+ function pruneCache(filter) {
+ cache.forEach((vnode, key) => {
+ const name = getComponentName(vnode.type);
+ if (name && !filter(name)) {
+ pruneCacheEntry(key);
+ }
+ });
+ }
+ function pruneCacheEntry(key) {
+ const cached = cache.get(key);
+ if (cached && (!current || !isSameVNodeType(cached, current))) {
+ unmount(cached);
+ } else if (current) {
+ resetShapeFlag(current);
+ }
+ cache.delete(key);
+ keys.delete(key);
+ }
+ watch(
+ () => [props.include, props.exclude],
+ ([include, exclude]) => {
+ include && pruneCache((name) => matches(include, name));
+ exclude && pruneCache((name) => !matches(exclude, name));
+ },
+ // prune post-render after `current` has been updated
+ { flush: "post", deep: true }
+ );
+ let pendingCacheKey = null;
+ const cacheSubtree = () => {
+ if (pendingCacheKey != null) {
+ if (isSuspense(instance.subTree.type)) {
+ queuePostRenderEffect(() => {
+ cache.set(pendingCacheKey, getInnerChild(instance.subTree));
+ }, instance.subTree.suspense);
+ } else {
+ cache.set(pendingCacheKey, getInnerChild(instance.subTree));
+ }
+ }
+ };
+ onMounted(cacheSubtree);
+ onUpdated(cacheSubtree);
+ onBeforeUnmount(() => {
+ cache.forEach((cached) => {
+ const { subTree, suspense } = instance;
+ const vnode = getInnerChild(subTree);
+ if (cached.type === vnode.type && cached.key === vnode.key) {
+ resetShapeFlag(vnode);
+ const da = vnode.component.da;
+ da && queuePostRenderEffect(da, suspense);
+ return;
+ }
+ unmount(cached);
+ });
+ });
+ return () => {
+ pendingCacheKey = null;
+ if (!slots.default) {
+ return current = null;
+ }
+ const children = slots.default();
+ const rawVNode = children[0];
+ if (children.length > 1) {
+ {
+ warn$1(`KeepAlive should contain exactly one component child.`);
+ }
+ current = null;
+ return children;
+ } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) {
+ current = null;
+ return rawVNode;
+ }
+ let vnode = getInnerChild(rawVNode);
+ if (vnode.type === Comment) {
+ current = null;
+ return vnode;
+ }
+ const comp = vnode.type;
+ const name = getComponentName(
+ isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp
+ );
+ const { include, exclude, max } = props;
+ if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) {
+ vnode.shapeFlag &= -257;
+ current = vnode;
+ return rawVNode;
+ }
+ const key = vnode.key == null ? comp : vnode.key;
+ const cachedVNode = cache.get(key);
+ if (vnode.el) {
+ vnode = cloneVNode(vnode);
+ if (rawVNode.shapeFlag & 128) {
+ rawVNode.ssContent = vnode;
+ }
+ }
+ pendingCacheKey = key;
+ if (cachedVNode) {
+ vnode.el = cachedVNode.el;
+ vnode.component = cachedVNode.component;
+ if (vnode.transition) {
+ setTransitionHooks(vnode, vnode.transition);
+ }
+ vnode.shapeFlag |= 512;
+ keys.delete(key);
+ keys.add(key);
+ } else {
+ keys.add(key);
+ if (max && keys.size > parseInt(max, 10)) {
+ pruneCacheEntry(keys.values().next().value);
+ }
+ }
+ vnode.shapeFlag |= 256;
+ current = vnode;
+ return isSuspense(rawVNode.type) ? rawVNode : vnode;
+ };
+ }
+ };
+ const KeepAlive = KeepAliveImpl;
+ function matches(pattern, name) {
+ if (isArray(pattern)) {
+ return pattern.some((p) => matches(p, name));
+ } else if (isString(pattern)) {
+ return pattern.split(",").includes(name);
+ } else if (isRegExp(pattern)) {
+ pattern.lastIndex = 0;
+ return pattern.test(name);
+ }
+ return false;
+ }
+ function onActivated(hook, target) {
+ registerKeepAliveHook(hook, "a", target);
+ }
+ function onDeactivated(hook, target) {
+ registerKeepAliveHook(hook, "da", target);
+ }
+ function registerKeepAliveHook(hook, type, target = currentInstance) {
+ const wrappedHook = hook.__wdc || (hook.__wdc = () => {
+ let current = target;
+ while (current) {
+ if (current.isDeactivated) {
+ return;
+ }
+ current = current.parent;
+ }
+ return hook();
+ });
+ injectHook(type, wrappedHook, target);
+ if (target) {
+ let current = target.parent;
+ while (current && current.parent) {
+ if (isKeepAlive(current.parent.vnode)) {
+ injectToKeepAliveRoot(wrappedHook, type, target, current);
+ }
+ current = current.parent;
+ }
+ }
+ }
+ function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) {
+ const injected = injectHook(
+ type,
+ hook,
+ keepAliveRoot,
+ true
+ /* prepend */
+ );
+ onUnmounted(() => {
+ remove(keepAliveRoot[type], injected);
+ }, target);
+ }
+ function resetShapeFlag(vnode) {
+ vnode.shapeFlag &= -257;
+ vnode.shapeFlag &= -513;
+ }
+ function getInnerChild(vnode) {
+ return vnode.shapeFlag & 128 ? vnode.ssContent : vnode;
+ }
+
+ function injectHook(type, hook, target = currentInstance, prepend = false) {
+ if (target) {
+ const hooks = target[type] || (target[type] = []);
+ const wrappedHook = hook.__weh || (hook.__weh = (...args) => {
+ pauseTracking();
+ const reset = setCurrentInstance(target);
+ const res = callWithAsyncErrorHandling(hook, target, type, args);
+ reset();
+ resetTracking();
+ return res;
+ });
+ if (prepend) {
+ hooks.unshift(wrappedHook);
+ } else {
+ hooks.push(wrappedHook);
+ }
+ return wrappedHook;
+ } else {
+ const apiName = toHandlerKey(ErrorTypeStrings$1[type].replace(/ hook$/, ""));
+ warn$1(
+ `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup().` + (` If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` )
+ );
+ }
+ }
+ const createHook = (lifecycle) => (hook, target = currentInstance) => {
+ if (!isInSSRComponentSetup || lifecycle === "sp") {
+ injectHook(lifecycle, (...args) => hook(...args), target);
+ }
+ };
+ const onBeforeMount = createHook("bm");
+ const onMounted = createHook("m");
+ const onBeforeUpdate = createHook(
+ "bu"
+ );
+ const onUpdated = createHook("u");
+ const onBeforeUnmount = createHook(
+ "bum"
+ );
+ const onUnmounted = createHook("um");
+ const onServerPrefetch = createHook(
+ "sp"
+ );
+ const onRenderTriggered = createHook("rtg");
+ const onRenderTracked = createHook("rtc");
+ function onErrorCaptured(hook, target = currentInstance) {
+ injectHook("ec", hook, target);
+ }
+
+ const COMPONENTS = "components";
+ const DIRECTIVES = "directives";
+ function resolveComponent(name, maybeSelfReference) {
+ return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name;
+ }
+ const NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc");
+ function resolveDynamicComponent(component) {
+ if (isString(component)) {
+ return resolveAsset(COMPONENTS, component, false) || component;
+ } else {
+ return component || NULL_DYNAMIC_COMPONENT;
+ }
+ }
+ function resolveDirective(name) {
+ return resolveAsset(DIRECTIVES, name);
+ }
+ function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) {
+ const instance = currentRenderingInstance || currentInstance;
+ if (instance) {
+ const Component = instance.type;
+ if (type === COMPONENTS) {
+ const selfName = getComponentName(
+ Component,
+ false
+ );
+ if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) {
+ return Component;
+ }
+ }
+ const res = (
+ // local registration
+ // check instance[type] first which is resolved for options API
+ resolve(instance[type] || Component[type], name) || // global registration
+ resolve(instance.appContext[type], name)
+ );
+ if (!res && maybeSelfReference) {
+ return Component;
+ }
+ if (warnMissing && !res) {
+ const extra = type === COMPONENTS ? `
+If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``;
+ warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`);
+ }
+ return res;
+ } else {
+ warn$1(
+ `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().`
+ );
+ }
+ }
+ function resolve(registry, name) {
+ return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]);
+ }
+
+ function renderList(source, renderItem, cache, index) {
+ let ret;
+ const cached = cache && cache[index];
+ const sourceIsArray = isArray(source);
+ if (sourceIsArray || isString(source)) {
+ const sourceIsReactiveArray = sourceIsArray && isReactive(source);
+ let needsWrap = false;
+ let isReadonlySource = false;
+ if (sourceIsReactiveArray) {
+ needsWrap = !isShallow(source);
+ isReadonlySource = isReadonly(source);
+ source = shallowReadArray(source);
+ }
+ ret = new Array(source.length);
+ for (let i = 0, l = source.length; i < l; i++) {
+ ret[i] = renderItem(
+ needsWrap ? isReadonlySource ? toReadonly(toReactive(source[i])) : toReactive(source[i]) : source[i],
+ i,
+ void 0,
+ cached && cached[i]
+ );
+ }
+ } else if (typeof source === "number") {
+ if (!Number.isInteger(source)) {
+ warn$1(`The v-for range expect an integer value but got ${source}.`);
+ }
+ ret = new Array(source);
+ for (let i = 0; i < source; i++) {
+ ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]);
+ }
+ } else if (isObject(source)) {
+ if (source[Symbol.iterator]) {
+ ret = Array.from(
+ source,
+ (item, i) => renderItem(item, i, void 0, cached && cached[i])
+ );
+ } else {
+ const keys = Object.keys(source);
+ ret = new Array(keys.length);
+ for (let i = 0, l = keys.length; i < l; i++) {
+ const key = keys[i];
+ ret[i] = renderItem(source[key], key, i, cached && cached[i]);
+ }
+ }
+ } else {
+ ret = [];
+ }
+ if (cache) {
+ cache[index] = ret;
+ }
+ return ret;
+ }
+
+ function createSlots(slots, dynamicSlots) {
+ for (let i = 0; i < dynamicSlots.length; i++) {
+ const slot = dynamicSlots[i];
+ if (isArray(slot)) {
+ for (let j = 0; j < slot.length; j++) {
+ slots[slot[j].name] = slot[j].fn;
+ }
+ } else if (slot) {
+ slots[slot.name] = slot.key ? (...args) => {
+ const res = slot.fn(...args);
+ if (res) res.key = slot.key;
+ return res;
+ } : slot.fn;
+ }
+ }
+ return slots;
+ }
+
+ function renderSlot(slots, name, props = {}, fallback, noSlotted) {
+ if (currentRenderingInstance.ce || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.ce) {
+ const hasProps = Object.keys(props).length > 0;
+ if (name !== "default") props.name = name;
+ return openBlock(), createBlock(
+ Fragment,
+ null,
+ [createVNode("slot", props, fallback && fallback())],
+ hasProps ? -2 : 64
+ );
+ }
+ let slot = slots[name];
+ if (slot && slot.length > 1) {
+ warn$1(
+ `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.`
+ );
+ slot = () => [];
+ }
+ if (slot && slot._c) {
+ slot._d = false;
+ }
+ openBlock();
+ const validSlotContent = slot && ensureValidVNode(slot(props));
+ const slotKey = props.key || // slot content array of a dynamic conditional slot may have a branch
+ // key attached in the `createSlots` helper, respect that
+ validSlotContent && validSlotContent.key;
+ const rendered = createBlock(
+ Fragment,
+ {
+ key: (slotKey && !isSymbol(slotKey) ? slotKey : `_${name}`) + // #7256 force differentiate fallback content from actual content
+ (!validSlotContent && fallback ? "_fb" : "")
+ },
+ validSlotContent || (fallback ? fallback() : []),
+ validSlotContent && slots._ === 1 ? 64 : -2
+ );
+ if (!noSlotted && rendered.scopeId) {
+ rendered.slotScopeIds = [rendered.scopeId + "-s"];
+ }
+ if (slot && slot._c) {
+ slot._d = true;
+ }
+ return rendered;
+ }
+ function ensureValidVNode(vnodes) {
+ return vnodes.some((child) => {
+ if (!isVNode(child)) return true;
+ if (child.type === Comment) return false;
+ if (child.type === Fragment && !ensureValidVNode(child.children))
+ return false;
+ return true;
+ }) ? vnodes : null;
+ }
+
+ function toHandlers(obj, preserveCaseIfNecessary) {
+ const ret = {};
+ if (!isObject(obj)) {
+ warn$1(`v-on with no argument expects an object value.`);
+ return ret;
+ }
+ for (const key in obj) {
+ ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key];
+ }
+ return ret;
+ }
+
+ const getPublicInstance = (i) => {
+ if (!i) return null;
+ if (isStatefulComponent(i)) return getComponentPublicInstance(i);
+ return getPublicInstance(i.parent);
+ };
+ const publicPropertiesMap = (
+ // Move PURE marker to new line to workaround compiler discarding it
+ // due to type annotation
+ /* @__PURE__ */ extend(/* @__PURE__ */ Object.create(null), {
+ $: (i) => i,
+ $el: (i) => i.vnode.el,
+ $data: (i) => i.data,
+ $props: (i) => shallowReadonly(i.props) ,
+ $attrs: (i) => shallowReadonly(i.attrs) ,
+ $slots: (i) => shallowReadonly(i.slots) ,
+ $refs: (i) => shallowReadonly(i.refs) ,
+ $parent: (i) => getPublicInstance(i.parent),
+ $root: (i) => getPublicInstance(i.root),
+ $host: (i) => i.ce,
+ $emit: (i) => i.emit,
+ $options: (i) => resolveMergedOptions(i) ,
+ $forceUpdate: (i) => i.f || (i.f = () => {
+ queueJob(i.update);
+ }),
+ $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)),
+ $watch: (i) => instanceWatch.bind(i)
+ })
+ );
+ const isReservedPrefix = (key) => key === "_" || key === "$";
+ const hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key);
+ const PublicInstanceProxyHandlers = {
+ get({ _: instance }, key) {
+ if (key === "__v_skip") {
+ return true;
+ }
+ const { ctx, setupState, data, props, accessCache, type, appContext } = instance;
+ if (key === "__isVue") {
+ return true;
+ }
+ let normalizedProps;
+ if (key[0] !== "$") {
+ const n = accessCache[key];
+ if (n !== void 0) {
+ switch (n) {
+ case 1 /* SETUP */:
+ return setupState[key];
+ case 2 /* DATA */:
+ return data[key];
+ case 4 /* CONTEXT */:
+ return ctx[key];
+ case 3 /* PROPS */:
+ return props[key];
+ }
+ } else if (hasSetupBinding(setupState, key)) {
+ accessCache[key] = 1 /* SETUP */;
+ return setupState[key];
+ } else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
+ accessCache[key] = 2 /* DATA */;
+ return data[key];
+ } else if (
+ // only cache other properties when instance has declared (thus stable)
+ // props
+ (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key)
+ ) {
+ accessCache[key] = 3 /* PROPS */;
+ return props[key];
+ } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
+ accessCache[key] = 4 /* CONTEXT */;
+ return ctx[key];
+ } else if (shouldCacheAccess) {
+ accessCache[key] = 0 /* OTHER */;
+ }
+ }
+ const publicGetter = publicPropertiesMap[key];
+ let cssModule, globalProperties;
+ if (publicGetter) {
+ if (key === "$attrs") {
+ track(instance.attrs, "get", "");
+ markAttrsAccessed();
+ } else if (key === "$slots") {
+ track(instance, "get", key);
+ }
+ return publicGetter(instance);
+ } else if (
+ // css module (injected by vue-loader)
+ (cssModule = type.__cssModules) && (cssModule = cssModule[key])
+ ) {
+ return cssModule;
+ } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
+ accessCache[key] = 4 /* CONTEXT */;
+ return ctx[key];
+ } else if (
+ // global properties
+ globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key)
+ ) {
+ {
+ return globalProperties[key];
+ }
+ } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading
+ // to infinite warning loop
+ key.indexOf("__v") !== 0)) {
+ if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) {
+ warn$1(
+ `Property ${JSON.stringify(
+ key
+ )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.`
+ );
+ } else if (instance === currentRenderingInstance) {
+ warn$1(
+ `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.`
+ );
+ }
+ }
+ },
+ set({ _: instance }, key, value) {
+ const { data, setupState, ctx } = instance;
+ if (hasSetupBinding(setupState, key)) {
+ setupState[key] = value;
+ return true;
+ } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) {
+ warn$1(`Cannot mutate
+
+
+
+
+
+
+ {% include 'includes/loading_overlay.html' %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ current_user.username }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account Information
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+ {% for category, message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+ {% endif %}
+ {% endwith %}
+
+
+
+
+
+
+
+
+
Account Statistics
+
+
+
+ {{
+ current_user.recordings|length }}
+ Total Recordings
+
+
+
+
+ {% set completed_count =
+ current_user.recordings|selectattr('status', 'equalto',
+ 'COMPLETED')|list|length %}
+ {{ completed_count }}
+
+ Completed
+
+
+
+
+ {% set processing_count =
+ current_user.recordings|selectattr('status', 'in', ['PENDING',
+ 'PROCESSING', 'SUMMARIZING'])|list|length %}
+ {{ processing_count }}
+
+ Processing
+
+
+
+
+ {% set failed_count = current_user.recordings|selectattr('status',
+ 'equalto', 'FAILED')|list|length %}
+ {{ failed_count }}
+
+ Failed
+
+
+
+
+
+
+
User Details
+
+
+ Username
+ {{ current_user.username
+ }}
+
+
+ Email
+ {{ current_user.email
+ }}
+
+
+
+
+
+
+
+
+
+
+
Prompt Options
+
+
+
+
+
+
+
+
+ Event Extraction
+
+
+
+
+
+
+
+ Enable automatic event extraction from transcripts
+
+
+ When enabled, the AI will identify meetings, appointments, and deadlines
+ mentioned in your recordings and create downloadable calendar events.
+
+
+
+
+
+
+
+
+ Extracted events will appear in a new "Events" tab on recordings where calendar
+ items are detected.
+
+
+
+
+
+
+
Summary Generation Prompt
+
+
+
+ Your Custom Summary
+ Prompt
+
+
{{ current_user.summary_prompt or '' }}
+
+ This prompt will be used to
+ generate summaries for your transcriptions. It overrides the admin's default
+ prompt.
+
+
+
+
+
+
+ Current Default Prompt
+ (Used if you leave the above blank)
+
+
+
{{ default_summary_prompt_text }}
+
+
+
+
+
+
+
+ Tips for Writing Effective
+ Prompts
+
+
+
+ Be specific about the sections you
+ want in your summary
+ Use clear formatting instructions
+ (e.g., "Use bullet points", "Create numbered lists")
+ Specify if you want certain
+ information prioritized
+ The system will automatically provide
+ the transcript content to the AI
+ Your output language preference (if
+ set) will be applied automatically
+
+
+
+
+
+
+
+
+ Save
+ Settings
+
+
+
+
+
+
+
My Shared Transcripts
+
+
+
+
+
+
+
+
Speakers Management
+
+ Manage your saved speakers. These are automatically saved when you use speaker names in
+ your recordings.
+
+
+
+
+
+
+
+ Loading speakers...
+
+
+
+
+
+ Failed to
+ load speakers
+
+
+
+
+
+
No speakers saved yet
+
Speakers will appear here when you
+ use speaker names in your recordings
+
+
+
+
+
+
+
+
+
+
+
+ 0 speakers saved
+
+
+
+ Delete
+ All
+
+
+
+
+
+
+
Tag Management
+
+
+
+
Organize
+ your recordings with custom tags. Each tag can have its own summary prompt and
+ default ASR settings.
+
+ Create
+ Tag
+
+
+
+
+
+
+
+
+
+ Failed to load tags
+
+
+
+
+
+
尚未创建任何标签
+
创建您的第一个标签,以便整理录音文件
+
+
+
+
+
+
+
+
+
+
声纹管理
+
+
+
+ 同步至声纹平台
+ 添加
+
+
+
+
+
+ 名称
+ 声纹编码
+
+ 操作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ *
+ 声纹向量
+
+
+
+ 清空文件
+
+
+
+ 🎤 录音
+ 清空录音
+
+
+
+
请选择一段清晰的独白语音(2秒以上),不要包含背景噪音和其他人的声音
+
+ *
+ 名称
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Transcript Templates
+
+ Customize how transcripts are formatted for download and export.
+
+
+
+ Create Template
+
+
+
+
+
+
+
+
+
Available Templates
+
+
+
+
+
+ Create Default
+ Templates
+
+
+
+
+
+
+
+
+
+ Template Name
+
+
+
+
+ Description
+
+
+
+
+
Template
+
+
+
+
+
Available
+ Variables:
+
+ {{index}}
+ {{speaker}}
+ {{text}}
+ {{start_time}}
+ {{end_time}}
+
+
Filters: |upper for
+ uppercase, |srt for subtitle time format
+
+
+
+
+
+ Set as default
+ template
+
+
+
+
+
+ 保存
+
+
+ 取消
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
Select a template to edit or
+ create a new one
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ html_title }}
+
AI-Powered
+ Audio Transcription & Note-Taking
+
+
+
+
+ Version Loading...
+
+
+
+
+
+
+
+
+
+
+
System Configuration
+
+
+
+
+
Large Language Model
+
+
+ Model:
+ Loading...
+
+
+ Endpoint:
+ Loading...
+
+
+
+
+
+
Speech Recognition
+
+
+ Whisper
+ API:
+ Loading...
+
+
+ ASR
+ Enabled:
+ Loading...
+
+
+
+ ASR
+ Endpoint:
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Audio Transcription
+
Whisper API and custom ASR
+ support with high accuracy
+
+
+
+
+
+
+
+
+
AI Summarization
+
OpenRouter and Ollama
+ integration with custom prompts
+
+
+
+
+
+
+
+
+
Speaker Diarization
+
Identify and label
+ different speakers automatically
+
+
+
+
+
+
+
+
+
Interactive Chat
+
Chat with your transcriptions
+ using AI
+
+
+
+
+
+
+
+
+
Inquire Mode
+
Semantic search across all your
+ recordings
+
+
+
+
+
+
+
+
+
Sharing & Export
+
Share recordings and export to
+ various formats
+
+
+
+
+
+
+
+
+
+
+
+
Technology Stack
+
+
+
+
+
+
Python Flask
+
+ Backend
+
+
+
+
Vue.js
+
+ Frontend
+
+
+
+
SQLite
+
+ Database
+
+
+
+
Docker
+
+ Deployment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Change
+ Password
+ ×
+
+
+
+
+ Current Password
+
+
+
+
New Password
+
+
Password must be at least 8 characters long
+
+
+
+ Confirm New Password
+
+
+
+ 取消
+ Change Password
+
+
+
+
+
+
+
+
+
+
Manage
+ Speakers
+ ×
+
+
+
+
+ Manage your saved speakers. These are
+ automatically saved when you use speaker names in your recordings.
+
+
+
+
+
+
Loading
+ speakers...
+
+
+
+
+
+ Failed to load
+ speakers
+
+
+
+
+
+
No speakers saved
+ yet
+
+ Speakers will appear here when you use speaker names in your recordings
+
+
+
+
+
+
+
+
+
+
+ 0 speakers
+ saved
+
+
+
+ Delete All
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+
Delete All
+ Speakers
+
+
+ Are you sure you want to delete all saved
+ speakers? This action cannot be undone.
+
+
+
+ 取消
+
+
+ Delete All
+
+
+
+
+
+
+
+
+
+
Create Tag
+ ×
+
+
+
+
+
+
+
+ Tag Name *
+
+
+
+
+
Color
+
+
+ Choose
+ a color for easy identification
+
+
+
+ 标签范围{{current_user.is_admin}}
+
+ 全局
+ 个人
+
+
+
+
Custom Summary Prompt
+
+
Leave
+ blank to use your default summary prompt
+
+
+ {% if use_asr_endpoint %}
+
+
ASR Default Settings
+
+ These settings will be applied by default when using this tag with ASR transcription
+
+
+
+ Default Language
+
+
+
+
+
+ {% endif %}
+
+
+ 取消
+
+ Save Tag
+
+
+
+
+
+
+
+
+
+
+
+