feat: LLM 应用网页开发及内联建议功能实现
This commit is contained in:
+7
-29
@@ -6,44 +6,22 @@ from datetime import datetime
|
|||||||
import ollama
|
import ollama
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from prompts import get_vlm_ocr_prompt
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
||||||
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||||
|
|
||||||
# Timeouts in seconds
|
# Timeouts in seconds (10 minutes for large model loading)
|
||||||
COMPLETION_TIMEOUT = 30
|
COMPLETION_TIMEOUT = 600
|
||||||
OCR_TIMEOUT = 60
|
OCR_TIMEOUT = 120
|
||||||
CONVERT_TIMEOUT = 30
|
CONVERT_TIMEOUT = 60
|
||||||
|
|
||||||
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||||
logger = logging.getLogger("llm")
|
logger = logging.getLogger("llm")
|
||||||
|
|
||||||
VLM_OCR_CONTEXT_PROMPT = """You are an OCR and visual-context extractor for markdown writing assistance.
|
|
||||||
|
|
||||||
Your output will be embedded inside an HTML comment as hidden context for a text-completion model.
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Keep output compact: maximum 120 words.
|
|
||||||
- Use plain text only (no markdown code fences).
|
|
||||||
- Never output <!-- or -->.
|
|
||||||
- Do not invent unreadable text; mark uncertain characters with ?.
|
|
||||||
- Preserve original script for recognized text (do not forcibly translate).
|
|
||||||
|
|
||||||
Return exactly this format:
|
|
||||||
|
|
||||||
TEXT:
|
|
||||||
<exact transcription of visible text; use " | " for line breaks; write "(none)" if no readable text>
|
|
||||||
|
|
||||||
KEY_DETAILS:
|
|
||||||
- <3-5 short factual bullets about relevant objects/layout>
|
|
||||||
|
|
||||||
LANGUAGE:
|
|
||||||
<dominant language(s) in visible text, e.g. English / Chinese / Mixed>
|
|
||||||
|
|
||||||
SUMMARY:
|
|
||||||
<one short sentence, <= 20 words>"""
|
|
||||||
|
|
||||||
def _extract_message(response) -> tuple[str, str]:
|
def _extract_message(response) -> tuple[str, str]:
|
||||||
content = ""
|
content = ""
|
||||||
@@ -166,7 +144,7 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
|||||||
model=VLM_MODEL,
|
model=VLM_MODEL,
|
||||||
messages=[{
|
messages=[{
|
||||||
'role': 'user',
|
'role': 'user',
|
||||||
'content': VLM_OCR_CONTEXT_PROMPT,
|
'content': get_vlm_ocr_prompt(),
|
||||||
'images': [image_bytes]
|
'images': [image_bytes]
|
||||||
}],
|
}],
|
||||||
stream=False,
|
stream=False,
|
||||||
|
|||||||
+11
-2
@@ -26,6 +26,15 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("api")
|
logger = logging.getLogger("api")
|
||||||
|
|
||||||
|
_markitdown_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_markitdown():
|
||||||
|
global _markitdown_instance
|
||||||
|
if _markitdown_instance is None:
|
||||||
|
_markitdown_instance = markitdown.MarkItDown()
|
||||||
|
return _markitdown_instance
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
|
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
|
||||||
@@ -310,8 +319,8 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Convert using MarkItDown
|
# Convert using MarkItDown
|
||||||
md = markitdown.MarkItDown()
|
md = _get_markitdown()
|
||||||
result = md.convert(tmp_path)
|
result = await asyncio.to_thread(md.convert, tmp_path)
|
||||||
markdown_text = _sanitize_converted_markdown(result.text_content)
|
markdown_text = _sanitize_converted_markdown(result.text_content)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
+12
-316
@@ -2,6 +2,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import re
|
import re
|
||||||
from typing import Protocol, Tuple, runtime_checkable
|
from typing import Protocol, Tuple, runtime_checkable
|
||||||
|
|
||||||
|
from prompts import get_language_guidance_map, get_system_prompt_template, get_inline_examples
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class UserPreferences(Protocol):
|
class UserPreferences(Protocol):
|
||||||
@@ -224,339 +226,33 @@ def _canonical_language_id(language_id: str) -> str:
|
|||||||
_JS_LANGS = {"javascript", "typescript"}
|
_JS_LANGS = {"javascript", "typescript"}
|
||||||
_CODE_LANGS = {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}
|
_CODE_LANGS = {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}
|
||||||
|
|
||||||
_LANG_GUIDANCE = {
|
|
||||||
"mermaid": """
|
|
||||||
Language-specific guidance (mermaid):
|
|
||||||
- Output valid Mermaid syntax only.
|
|
||||||
- Prefer concise, syntactically correct diagram statements.
|
|
||||||
- Avoid prose unless the user prompt explicitly requires it.""",
|
|
||||||
"latex": """
|
|
||||||
Language-specific guidance (latex):
|
|
||||||
- Output LaTeX math content only when completing LaTeX.
|
|
||||||
- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:
|
|
||||||
- Output raw LaTeX lines only.
|
|
||||||
- Do not wrap with $ or $$.""",
|
|
||||||
"json": """
|
|
||||||
Language-specific guidance (json):
|
|
||||||
- Output strict JSON only (no comments, no trailing commas).
|
|
||||||
- Ensure valid quotes and braces.""",
|
|
||||||
"yaml": """
|
|
||||||
Language-specific guidance (yaml):
|
|
||||||
- Output valid YAML only.
|
|
||||||
- Use consistent indentation and avoid tabs.""",
|
|
||||||
"toml": """
|
|
||||||
Language-specific guidance (toml):
|
|
||||||
- Output valid TOML only.
|
|
||||||
- Keep key types consistent.""",
|
|
||||||
"ini": """
|
|
||||||
Language-specific guidance (ini):
|
|
||||||
- Output valid INI only.
|
|
||||||
- Keep section headers and key=value pairs consistent.""",
|
|
||||||
"sql": """
|
|
||||||
Language-specific guidance (sql):
|
|
||||||
- Output a single, valid SQL statement unless context requires multiple.
|
|
||||||
- Prefer ANSI SQL when dialect is unclear.""",
|
|
||||||
"bash": """
|
|
||||||
Language-specific guidance (bash):
|
|
||||||
- Output POSIX-compatible shell when possible.
|
|
||||||
- Avoid interactive prompts or destructive commands unless requested.""",
|
|
||||||
"powershell": """
|
|
||||||
Language-specific guidance (powershell):
|
|
||||||
- Output valid PowerShell commands.
|
|
||||||
- Avoid destructive commands unless explicitly requested.""",
|
|
||||||
"html": """
|
|
||||||
Language-specific guidance (html):
|
|
||||||
- Output valid HTML only.
|
|
||||||
- Keep markup minimal and well-formed.""",
|
|
||||||
"css": """
|
|
||||||
Language-specific guidance (css):
|
|
||||||
- Output valid CSS only.
|
|
||||||
- Use concise, readable selectors.""",
|
|
||||||
"diff": """
|
|
||||||
Language-specific guidance (diff):
|
|
||||||
- Output a unified diff only.
|
|
||||||
- Ensure @@ hunk headers and +/- lines are consistent.""",
|
|
||||||
"regex": """
|
|
||||||
Language-specific guidance (regex):
|
|
||||||
- Output the regex pattern only.
|
|
||||||
- Avoid delimiters unless explicitly requested.""",
|
|
||||||
"text": """
|
|
||||||
Language-specific guidance (text):
|
|
||||||
- Output plain text only.
|
|
||||||
- Avoid markdown formatting unless explicitly asked.""",
|
|
||||||
"xml": """
|
|
||||||
Language-specific guidance (xml):
|
|
||||||
- Output well-formed XML only.
|
|
||||||
- Ensure matching tags and proper escaping.""",
|
|
||||||
"dockerfile": """
|
|
||||||
Language-specific guidance (dockerfile):
|
|
||||||
- Output valid Dockerfile instructions only.
|
|
||||||
- Keep layers minimal and ordered logically.""",
|
|
||||||
"makefile": """
|
|
||||||
Language-specific guidance (makefile):
|
|
||||||
- Output valid Makefile syntax only.
|
|
||||||
- Use tabs for recipe lines.""",
|
|
||||||
}
|
|
||||||
|
|
||||||
_GENERIC_CODE = """
|
|
||||||
Language-specific guidance ({lang}):
|
|
||||||
- Output valid {lang} code.
|
|
||||||
- Avoid prose unless context clearly expects comments or docstrings."""
|
|
||||||
|
|
||||||
_JS_CODE = """
|
|
||||||
Language-specific guidance ({lang}):
|
|
||||||
- Output valid {lang} code.
|
|
||||||
- Prefer modern syntax and avoid prose unless comments are needed."""
|
|
||||||
|
|
||||||
|
|
||||||
def _language_guidance(language_id: str) -> str:
|
def _language_guidance(language_id: str) -> str:
|
||||||
canonical = _canonical_language_id(language_id)
|
canonical = _canonical_language_id(language_id)
|
||||||
if canonical == "markdown":
|
if canonical == "markdown":
|
||||||
return ""
|
return ""
|
||||||
guidance = _LANG_GUIDANCE.get(canonical)
|
guidance_map = get_language_guidance_map()
|
||||||
|
guidance = guidance_map.get(canonical)
|
||||||
if guidance:
|
if guidance:
|
||||||
return guidance
|
return guidance
|
||||||
if canonical in _JS_LANGS:
|
if canonical in _JS_LANGS:
|
||||||
return _JS_CODE.format(lang=canonical)
|
return guidance_map.get("_js_code", "").replace("{lang}", canonical)
|
||||||
if canonical in _CODE_LANGS:
|
if canonical in _CODE_LANGS:
|
||||||
return _GENERIC_CODE.format(lang=canonical)
|
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
|
||||||
return _GENERIC_CODE.format(lang=canonical)
|
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
|
||||||
|
|
||||||
|
|
||||||
def build_inline_system_prompt(language_id: str = "markdown") -> str:
|
def build_inline_system_prompt(language_id: str = "markdown") -> str:
|
||||||
safe_language_id = _canonical_language_id(language_id)
|
safe_language_id = _canonical_language_id(language_id)
|
||||||
language_guidance = _language_guidance(safe_language_id)
|
language_guidance = _language_guidance(safe_language_id)
|
||||||
|
template = get_system_prompt_template()
|
||||||
system_prompt = f"""You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions.
|
system_prompt = template.replace("{language_id}", safe_language_id)
|
||||||
|
|
||||||
Return only the insertion text that should be placed between PREFIX and SUFFIX.
|
|
||||||
|
|
||||||
CORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.
|
|
||||||
|
|
||||||
PRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)
|
|
||||||
- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?
|
|
||||||
- CURSOR_FENCE_LANGUAGE: What language is the current fence?
|
|
||||||
- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?
|
|
||||||
- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?
|
|
||||||
- MERMAID_CONTEXT: Is this a Mermaid diagram context?
|
|
||||||
|
|
||||||
PRIORITY 2: SPECIALIZED CONTENT RULES
|
|
||||||
|
|
||||||
2.1 Code Block Handling:
|
|
||||||
If CURSOR_IN_FENCED_CODE_BLOCK=true:
|
|
||||||
- You are inside a code fence
|
|
||||||
- Output code lines ONLY (no triple backticks)
|
|
||||||
- Use single \\n for code line separation
|
|
||||||
|
|
||||||
If CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:
|
|
||||||
- Wrap code in fenced block with language tag:
|
|
||||||
```{{language}}
|
|
||||||
code here
|
|
||||||
```
|
|
||||||
- Never use inline backticks for code snippets
|
|
||||||
|
|
||||||
2.2 Math Formatting (KaTeX):
|
|
||||||
- Inline math: wrap with $...$
|
|
||||||
- Block math: wrap with $$...$$
|
|
||||||
- Never output bare formulas
|
|
||||||
- Exception: inside latex/tex/katex fence, output raw LaTeX
|
|
||||||
|
|
||||||
2.3 Mermaid Diagrams:
|
|
||||||
If CURSOR_FENCE_LANGUAGE=mermaid:
|
|
||||||
- Output Mermaid syntax ONLY
|
|
||||||
- No backticks, no explanations
|
|
||||||
|
|
||||||
If MERMAID_CONTEXT=true and outside fence:
|
|
||||||
- Output complete fenced block:
|
|
||||||
```mermaid
|
|
||||||
diagram syntax
|
|
||||||
```
|
|
||||||
|
|
||||||
PRIORITY 3: MARKDOWN STRUCTURE
|
|
||||||
|
|
||||||
3.1 Newline Semantics:
|
|
||||||
- Single \\n: soft break (same paragraph, renders as space or <br>)
|
|
||||||
- Double \\n\\n: hard break (new paragraph/block)
|
|
||||||
- Use \\n\\n for: new paragraphs, before headings, starting lists/tables
|
|
||||||
- Use \\n for: continuation within blocks (list items, table cells)
|
|
||||||
- Exception: inside code blocks, use \\n freely for code lines
|
|
||||||
|
|
||||||
3.2 Boundary Management:
|
|
||||||
Check PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:
|
|
||||||
- If PREFIX lacks needed newline: start OUTPUT with \\n
|
|
||||||
- If SUFFIX lacks needed newline: end OUTPUT with \\n
|
|
||||||
- Common cases requiring leading \\n:
|
|
||||||
* Starting a list after "Steps:"
|
|
||||||
* Creating new paragraph after text
|
|
||||||
* Adding heading after paragraph
|
|
||||||
- Common cases requiring trailing \\n:
|
|
||||||
* Before new heading
|
|
||||||
* End of section
|
|
||||||
|
|
||||||
3.3 Context Stitching:
|
|
||||||
- Never repeat text from SUFFIX beginning
|
|
||||||
- Match PREFIX tone, style, indentation
|
|
||||||
- Continue structures: lists, tables, quotes, headings
|
|
||||||
|
|
||||||
PRIORITY 4: HIDDEN CONTEXT
|
|
||||||
- OCR metadata like <OCR:...> is hidden context
|
|
||||||
- Never copy OCR tags to output
|
|
||||||
- Use OCR content as semantic hint only
|
|
||||||
"""
|
|
||||||
|
|
||||||
if language_guidance:
|
if language_guidance:
|
||||||
system_prompt = f"{system_prompt.rstrip()}\\n{language_guidance.strip()}"
|
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
|
||||||
|
|
||||||
return system_prompt.strip()
|
return system_prompt.strip()
|
||||||
|
|
||||||
|
|
||||||
INLINE_EXAMPLES = """=== CATEGORY A: PROSE CONTINUATION ===
|
_INLINE_EXAMPLES = get_inline_examples()
|
||||||
|
|
||||||
[EX01] Simple prose continuation
|
|
||||||
<PREFIX>The quick brown fox </PREFIX>
|
|
||||||
<SUFFIX>jumps over the lazy dog.</SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
moved quietly and then
|
|
||||||
|
|
||||||
[EX02] Avoid repeating suffix
|
|
||||||
<PREFIX>Our launch plan starts with </PREFIX>
|
|
||||||
<SUFFIX>phase one, followed by phase two.</SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
careful internal testing before
|
|
||||||
WRONG: phase one starts with (repeats suffix)
|
|
||||||
|
|
||||||
=== CATEGORY B: MARKDOWN STRUCTURES ===
|
|
||||||
|
|
||||||
[EX03] Continue checklist
|
|
||||||
<PREFIX>## TODO
|
|
||||||
- [ ] Buy milk
|
|
||||||
- [ ] </PREFIX>
|
|
||||||
<SUFFIX></SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
Write release notes and share draft with team
|
|
||||||
|
|
||||||
[EX04] Start list after header (PREFIX lacks newline)
|
|
||||||
PREFIX_ENDS_WITH_NEWLINE=false
|
|
||||||
<PREFIX>Deployment steps:</PREFIX>
|
|
||||||
<SUFFIX></SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
|
|
||||||
- Build artifact
|
|
||||||
- Deploy service
|
|
||||||
|
|
||||||
[EX05] Continue table row
|
|
||||||
<PREFIX>| Name | Score |
|
|
||||||
| --- | --- |
|
|
||||||
| Alice | 92 |
|
|
||||||
| Bob | </PREFIX>
|
|
||||||
<SUFFIX></SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
88 |
|
|
||||||
|
|
||||||
[EX06] Start new paragraph
|
|
||||||
<PREFIX>First paragraph ends.</PREFIX>
|
|
||||||
<SUFFIX></SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
|
|
||||||
Second paragraph starts.
|
|
||||||
WRONG: Second paragraph starts. (missing leading \\n\\n)
|
|
||||||
|
|
||||||
[EX07] Add newline before heading
|
|
||||||
PREFIX_ENDS_WITH_NEWLINE=false
|
|
||||||
<PREFIX>End of previous section.</PREFIX>
|
|
||||||
<SUFFIX>## Next Heading</SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
|
|
||||||
WRONG: (would join with heading without separation)
|
|
||||||
|
|
||||||
=== CATEGORY C: CODE BLOCKS ===
|
|
||||||
|
|
||||||
[EX08] Outside fence: wrap code in fence
|
|
||||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
|
||||||
<PREFIX>Parse this JSON payload in Python:</PREFIX>
|
|
||||||
<SUFFIX></SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
```python
|
|
||||||
import json
|
|
||||||
data = json.loads(payload)
|
|
||||||
```
|
|
||||||
WRONG: import json\\ndata = json.loads(payload) (no fence)
|
|
||||||
|
|
||||||
[EX09] Inside fence: output code only
|
|
||||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
|
||||||
<PREFIX>```python
|
|
||||||
def add(a, b):
|
|
||||||
return </PREFIX>
|
|
||||||
<SUFFIX>
|
|
||||||
```</SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
a + b
|
|
||||||
WRONG: ```python\\nreturn a + b\\n``` (duplicate fences)
|
|
||||||
|
|
||||||
[EX10] Code inside fence uses single newline
|
|
||||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
|
||||||
<PREFIX>```python
|
|
||||||
def hello():</PREFIX>
|
|
||||||
<SUFFIX>
|
|
||||||
```</SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
print("Hello")
|
|
||||||
return True
|
|
||||||
(Note: single \\n between code lines, no markdown rules)
|
|
||||||
|
|
||||||
=== CATEGORY D: MATH ===
|
|
||||||
|
|
||||||
[EX11] Inline math
|
|
||||||
<PREFIX>The derivative of x^2 is </PREFIX>
|
|
||||||
<SUFFIX>.</SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
$2x$
|
|
||||||
WRONG: 2x (bare formula)
|
|
||||||
|
|
||||||
[EX12] Block math
|
|
||||||
<PREFIX>We can write the Gaussian integral as:</PREFIX>
|
|
||||||
<SUFFIX></SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
$$
|
|
||||||
\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
|
|
||||||
$$
|
|
||||||
WRONG: \\int... (bare formula without $$)
|
|
||||||
|
|
||||||
=== CATEGORY E: MERMAID ===
|
|
||||||
|
|
||||||
[EX13] Inside mermaid fence
|
|
||||||
CURSOR_FENCE_LANGUAGE=mermaid
|
|
||||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
|
||||||
<PREFIX>```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[Start] --> </PREFIX>
|
|
||||||
<SUFFIX>
|
|
||||||
```</SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
B{Valid?}
|
|
||||||
B -->|Yes| C[Done]
|
|
||||||
WRONG: ```mermaid\\nB{Valid?}... (duplicate fence)
|
|
||||||
|
|
||||||
[EX14] Outside fence with mermaid context
|
|
||||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
|
||||||
MERMAID_CONTEXT=true
|
|
||||||
<PREFIX>Please provide a simple release pipeline diagram.</PREFIX>
|
|
||||||
<SUFFIX></SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
Build --> Test --> Deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
=== CATEGORY F: OCR METADATA ===
|
|
||||||
|
|
||||||
[EX15] Use OCR as context, never output
|
|
||||||
<PREFIX> <OCR:equation y = mx + b>
|
|
||||||
The relationship is </PREFIX>
|
|
||||||
<SUFFIX>.</SUFFIX>
|
|
||||||
Expected OUTPUT:
|
|
||||||
$y = mx + b$
|
|
||||||
WRONG: <OCR:equation y = mx + b> (OCR tag in output)"""
|
|
||||||
|
|
||||||
|
|
||||||
def build_completion_prompts(
|
def build_completion_prompts(
|
||||||
@@ -636,7 +332,7 @@ Step 3: Choose newline type
|
|||||||
- Do not repeat text from SUFFIX beginning
|
- Do not repeat text from SUFFIX beginning
|
||||||
|
|
||||||
=== EXAMPLES BY CATEGORY ===
|
=== EXAMPLES BY CATEGORY ===
|
||||||
{INLINE_EXAMPLES}
|
{_INLINE_EXAMPLES}
|
||||||
|
|
||||||
=== NOW COMPLETE THE TASK ===
|
=== NOW COMPLETE THE TASK ===
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_PROMPTS_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
class PromptManager:
|
||||||
|
_instance = None
|
||||||
|
_data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._load_all()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def _load_all(self):
|
||||||
|
for json_file in _PROMPTS_DIR.glob("*.json"):
|
||||||
|
key = json_file.stem
|
||||||
|
with open(json_file, "r", encoding="utf-8") as f:
|
||||||
|
self._data[key] = json.load(f)
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
_prompts = PromptManager()
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_prompt_template() -> str:
|
||||||
|
return _prompts.get("system_prompt", {}).get("template", "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_guidance_map() -> dict[str, str]:
|
||||||
|
return _prompts.get("language_guidance", {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_inline_examples() -> str:
|
||||||
|
return _prompts.get("inline_examples", {}).get("content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_vlm_ocr_prompt() -> str:
|
||||||
|
return _prompts.get("vlm_ocr", {}).get("prompt", "")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"content": "=== CATEGORY A: PROSE CONTINUATION ===\n\n[EX01] Simple prose continuation\n<PREFIX>The quick brown fox </PREFIX>\n<SUFFIX>jumps over the lazy dog.</SUFFIX>\nExpected OUTPUT:\nmoved quietly and then\n\n[EX02] Avoid repeating suffix\n<PREFIX>Our launch plan starts with </PREFIX>\n<SUFFIX>phase one, followed by phase two.</SUFFIX>\nExpected OUTPUT:\ncareful internal testing before\nWRONG: phase one starts with (repeats suffix)\n\n=== CATEGORY B: MARKDOWN STRUCTURES ===\n\n[EX03] Continue checklist\n<PREFIX>## TODO\n- [ ] Buy milk\n- [ ] </PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\nWrite release notes and share draft with team\n\n[EX04] Start list after header (PREFIX lacks newline)\nPREFIX_ENDS_WITH_NEWLINE=false\n<PREFIX>Deployment steps:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\n- Build artifact\n- Deploy service\n\n[EX05] Continue table row\n<PREFIX>| Name | Score |\n| --- | --- |\n| Alice | 92 |\n| Bob | </PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n88 |\n\n[EX06] Start new paragraph\n<PREFIX>First paragraph ends.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\nSecond paragraph starts.\nWRONG: Second paragraph starts. (missing leading \\n\\n)\n\n[EX07] Add newline before heading\nPREFIX_ENDS_WITH_NEWLINE=false\n<PREFIX>End of previous section.</PREFIX>\n<SUFFIX>## Next Heading</SUFFIX>\nExpected OUTPUT:\n\nWRONG: (would join with heading without separation)\n\n=== CATEGORY C: CODE BLOCKS ===\n\n[EX08] Outside fence: wrap code in fence\nCURSOR_IN_FENCED_CODE_BLOCK=false\n<PREFIX>Parse this JSON payload in Python:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n```python\nimport json\ndata = json.loads(payload)\n```\nWRONG: import json\\ndata = json.loads(payload) (no fence)\n\n[EX09] Inside fence: output code only\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```python\ndef add(a, b):\nreturn </PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\na + b\nWRONG: ```python\\nreturn a + b\\n``` (duplicate fences)\n\n[EX10] Code inside fence uses single newline\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```python\ndef hello():</PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\nprint(\"Hello\")\nreturn True\n(Note: single \\n between code lines, no markdown rules)\n\n=== CATEGORY D: MATH ===\n\n[EX11] Inline math\n<PREFIX>The derivative of x^2 is </PREFIX>\n<SUFFIX>.</SUFFIX>\nExpected OUTPUT:\n$2x$\nWRONG: 2x (bare formula)\n\n[EX12] Block math\n<PREFIX>We can write the Gaussian integral as:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}\n$$\nWRONG: \\int... (bare formula without $$)\n\n=== CATEGORY E: MERMAID ===\n\n[EX13] Inside mermaid fence\nCURSOR_FENCE_LANGUAGE=mermaid\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```mermaid\nflowchart TD\nA[Start] --> </PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\nB{Valid?}\nB -->|Yes| C[Done]\nWRONG: ```mermaid\\nB{Valid?}... (duplicate fence)\n\n[EX14] Outside fence with mermaid context\nCURSOR_IN_FENCED_CODE_BLOCK=false\nMERMAID_CONTEXT=true\n<PREFIX>Please provide a simple release pipeline diagram.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n```mermaid\nflowchart LR\nBuild --> Test --> Deploy\n```\n\n=== CATEGORY F: OCR METADATA ===\n\n[EX15] Use OCR as context, never output\n<PREFIX> <OCR:equation y = mx + b>\nThe relationship is </PREFIX>\n<SUFFIX>.</SUFFIX>\nExpected OUTPUT:\n$y = mx + b$\nWRONG: <OCR:equation y = mx + b> (OCR tag in output)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"mermaid": "\nLanguage-specific guidance (mermaid):\n- Output valid Mermaid syntax only.\n- Prefer concise, syntactically correct diagram statements.\n- Avoid prose unless the user prompt explicitly requires it.",
|
||||||
|
"latex": "\nLanguage-specific guidance (latex):\n- Output LaTeX math content only when completing LaTeX.\n- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:\n- Output raw LaTeX lines only.\n- Do not wrap with $ or $$.",
|
||||||
|
"json": "\nLanguage-specific guidance (json):\n- Output strict JSON only (no comments, no trailing commas).\n- Ensure valid quotes and braces.",
|
||||||
|
"yaml": "\nLanguage-specific guidance (yaml):\n- Output valid YAML only.\n- Use consistent indentation and avoid tabs.",
|
||||||
|
"toml": "\nLanguage-specific guidance (toml):\n- Output valid TOML only.\n- Keep key types consistent.",
|
||||||
|
"ini": "\nLanguage-specific guidance (ini):\n- Output valid INI only.\n- Keep section headers and key=value pairs consistent.",
|
||||||
|
"sql": "\nLanguage-specific guidance (sql):\n- Output a single, valid SQL statement unless context requires multiple.\n- Prefer ANSI SQL when dialect is unclear.",
|
||||||
|
"bash": "\nLanguage-specific guidance (bash):\n- Output POSIX-compatible shell when possible.\n- Avoid interactive prompts or destructive commands unless requested.",
|
||||||
|
"powershell": "\nLanguage-specific guidance (powershell):\n- Output valid PowerShell commands.\n- Avoid destructive commands unless explicitly requested.",
|
||||||
|
"html": "\nLanguage-specific guidance (html):\n- Output valid HTML only.\n- Keep markup minimal and well-formed.",
|
||||||
|
"css": "\nLanguage-specific guidance (css):\n- Output valid CSS only.\n- Use concise, readable selectors.",
|
||||||
|
"diff": "\nLanguage-specific guidance (diff):\n- Output a unified diff only.\n- Ensure @@ hunk headers and +/- lines are consistent.",
|
||||||
|
"regex": "\nLanguage-specific guidance (regex):\n- Output the regex pattern only.\n- Avoid delimiters unless explicitly requested.",
|
||||||
|
"text": "\nLanguage-specific guidance (text):\n- Output plain text only.\n- Avoid markdown formatting unless explicitly asked.",
|
||||||
|
"xml": "\nLanguage-specific guidance (xml):\n- Output well-formed XML only.\n- Ensure matching tags and proper escaping.",
|
||||||
|
"dockerfile": "\nLanguage-specific guidance (dockerfile):\n- Output valid Dockerfile instructions only.\n- Keep layers minimal and ordered logically.",
|
||||||
|
"makefile": "\nLanguage-specific guidance (makefile):\n- Output valid Makefile syntax only.\n- Use tabs for recipe lines.",
|
||||||
|
"_generic_code": "\nLanguage-specific guidance ({lang}):\n- Output valid {lang} code.\n- Avoid prose unless context clearly expects comments or docstrings.",
|
||||||
|
"_js_code": "\nLanguage-specific guidance ({lang}):\n- Output valid {lang} code.\n- Prefer modern syntax and avoid prose unless comments are needed."
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"template": "You are an inline completion engine for a {language_id} editor with ghost-text suggestions.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nCORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.\n\nPRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)\n- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?\n- CURSOR_FENCE_LANGUAGE: What language is the current fence?\n- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?\n- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?\n- MERMAID_CONTEXT: Is this a Mermaid diagram context?\n\nPRIORITY 2: SPECIALIZED CONTENT RULES\n\n2.1 Code Block Handling:\nIf CURSOR_IN_FENCED_CODE_BLOCK=true:\n- You are inside a code fence\n- Output code lines ONLY (no triple backticks)\n- Use single \\n for code line separation\n\nIf CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:\n- Wrap code in fenced block with language tag:\n```{language}\ncode here\n```\n- Never use inline backticks for code snippets\n\n2.2 Math Formatting (KaTeX):\n- Inline math: wrap with $...$\n- Block math: wrap with $$...$$\n- Never output bare formulas\n- Exception: inside latex/tex/katex fence, output raw LaTeX\n\n2.3 Mermaid Diagrams:\nIf CURSOR_FENCE_LANGUAGE=mermaid:\n- Output Mermaid syntax ONLY\n- No backticks, no explanations\n\nIf MERMAID_CONTEXT=true and outside fence:\n- Output complete fenced block:\n```mermaid\ndiagram syntax\n```\n\nPRIORITY 3: MARKDOWN STRUCTURE\n\n3.1 Newline Semantics:\n- Single \\n: soft break (same paragraph, renders as space or <br>)\n- Double \\n\\n: hard break (new paragraph/block)\n- Use \\n\\n for: new paragraphs, before headings, starting lists/tables\n- Use \\n for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use \\n freely for code lines\n\n3.2 Boundary Management:\nCheck PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:\n- If PREFIX lacks needed newline: start OUTPUT with \\n\n- If SUFFIX lacks needed newline: end OUTPUT with \\n\n- Common cases requiring leading \\n:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring trailing \\n:\n* Before new heading\n* End of section\n\n3.3 Context Stitching:\n- Never repeat text from SUFFIX beginning\n- Match PREFIX tone, style, indentation\n- Continue structures: lists, tables, quotes, headings\n\nPRIORITY 4: HIDDEN CONTEXT\n- OCR metadata like <OCR:...> is hidden context\n- Never copy OCR tags to output\n- Use OCR content as semantic hint only"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"prompt": "You are an OCR and visual-context extractor for markdown writing assistance.\n\nYour output will be embedded inside an HTML comment as hidden context for a text-completion model.\n\nRequirements:\n- Keep output compact: maximum 120 words.\n- Use plain text only (no markdown code fences).\n- Never output <!-- or -->.\n- Do not invent unreadable text; mark uncertain characters with ?.\n- Preserve original script for recognized text (do not forcibly translate).\n\nReturn exactly this format:\n\nTEXT:\n<exact transcription of visible text; use \" | \" for line breaks; write \"(none)\" if no readable text>\n\nKEY_DETAILS:\n- <3-5 short factual bullets about relevant objects/layout>\n\nLANGUAGE:\n<dominant language(s) in visible text, e.g. English / Chinese / Mixed>\n\nSUMMARY:\n<one short sentence, <= 20 words>"
|
||||||
|
}
|
||||||
@@ -17,3 +17,4 @@ transformers
|
|||||||
soundfile
|
soundfile
|
||||||
numpy
|
numpy
|
||||||
accelerate
|
accelerate
|
||||||
|
librosa
|
||||||
|
|||||||
+587
-46
@@ -4,6 +4,9 @@ import base64
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Security
|
from fastapi import APIRouter, HTTPException, Security
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -12,84 +15,447 @@ import numpy as np
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("tts_asr")
|
logger = logging.getLogger("tts_asr")
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
TTS_ASR_DEVICE = os.environ.get("TTS_ASR_DEVICE", "auto")
|
||||||
|
TTS_ASR_WARMUP = os.environ.get("TTS_ASR_WARMUP", "true").lower() == "true"
|
||||||
|
TTS_ASR_WARMUP_TIMEOUT = int(os.environ.get("TTS_ASR_WARMUP_TIMEOUT", "120"))
|
||||||
|
TTS_ASR_IDLE_TIMEOUT = int(os.environ.get("TTS_ASR_IDLE_TIMEOUT", "0"))
|
||||||
|
|
||||||
|
# Warmup constants
|
||||||
|
TTS_WARMUP_TEXT = "你好,这是一个测试。"
|
||||||
|
ASR_WARMUP_AUDIO_SECONDS = 0.5
|
||||||
|
|
||||||
|
# Global state
|
||||||
_tts_pipeline = None
|
_tts_pipeline = None
|
||||||
_asr_pipeline = None
|
_asr_pipeline = None
|
||||||
_device = None
|
_device = None
|
||||||
|
_device_tested = False
|
||||||
|
_tts_last_used = 0.0
|
||||||
|
_asr_last_used = 0.0
|
||||||
|
_tts_loading = False
|
||||||
|
_asr_loading = False
|
||||||
|
_tts_lock = asyncio.Lock()
|
||||||
|
_asr_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _get_device():
|
def _test_device_capability(device_str: str) -> tuple[bool, str]:
|
||||||
global _device
|
"""
|
||||||
if _device is not None:
|
测试设备实际可用性
|
||||||
|
返回: (是否可用, 错误信息)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if device_str == "cpu":
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
if device_str == "mps":
|
||||||
|
if not hasattr(torch.backends, "mps") or not torch.backends.mps.is_available():
|
||||||
|
return False, "MPS 不可用"
|
||||||
|
if not torch.backends.mps.is_built():
|
||||||
|
return False, "MPS 未编译"
|
||||||
|
|
||||||
|
test_tensor = torch.randn(2, 2, device="mps")
|
||||||
|
_ = test_tensor @ test_tensor
|
||||||
|
del test_tensor
|
||||||
|
torch.mps.empty_cache()
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
if device_str.startswith("cuda"):
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
return False, "CUDA 不可用"
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
return False, f"未知设备类型: {device_str}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"设备测试失败: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_device() -> str:
|
||||||
|
"""
|
||||||
|
获取最佳计算设备,支持环境变量覆盖和降级策略
|
||||||
|
"""
|
||||||
|
global _device, _device_tested
|
||||||
|
|
||||||
|
if _device is not None and _device_tested:
|
||||||
return _device
|
return _device
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
if platform.system() == "Darwin" and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
device_preference = []
|
||||||
_device = "mps"
|
|
||||||
logger.info("[Device] 使用 MPS 加速")
|
if TTS_ASR_DEVICE == "cpu":
|
||||||
elif torch.cuda.is_available():
|
|
||||||
_device = "cuda"
|
|
||||||
logger.info("[Device] 使用 CUDA 加速")
|
|
||||||
else:
|
|
||||||
_device = "cpu"
|
_device = "cpu"
|
||||||
logger.info("[Device] 使用 CPU")
|
_device_tested = True
|
||||||
|
logger.info("[Device] 强制使用 CPU (环境变量)")
|
||||||
|
return _device
|
||||||
|
elif TTS_ASR_DEVICE in ("mps", "cuda", "auto"):
|
||||||
|
if TTS_ASR_DEVICE != "auto":
|
||||||
|
device_preference = [TTS_ASR_DEVICE, "cpu"]
|
||||||
|
else:
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
device_preference = ["mps", "cpu"]
|
||||||
|
else:
|
||||||
|
device_preference = ["cuda", "cpu"]
|
||||||
|
else:
|
||||||
|
device_preference = ["mps", "cuda", "cpu"]
|
||||||
|
|
||||||
|
for dev in device_preference:
|
||||||
|
ok, err = _test_device_capability(dev)
|
||||||
|
if ok:
|
||||||
|
_device = dev
|
||||||
|
_device_tested = True
|
||||||
|
logger.info("[Device] 使用 %s 加速", dev.upper() if dev != "cpu" else "CPU")
|
||||||
|
return _device
|
||||||
|
else:
|
||||||
|
logger.warning("[Device] %s 不可用: %s", dev.upper() if dev != "cpu" else "CPU", err)
|
||||||
|
|
||||||
|
_device = "cpu"
|
||||||
|
_device_tested = True
|
||||||
|
logger.info("[Device] 降级使用 CPU")
|
||||||
return _device
|
return _device
|
||||||
|
|
||||||
|
|
||||||
def _device_arg():
|
def _device_arg() -> str:
|
||||||
device = _get_device()
|
device = _get_device()
|
||||||
if device == "cuda":
|
if device == "cuda":
|
||||||
return "cuda:0"
|
return "cuda:0"
|
||||||
return device
|
return device
|
||||||
|
|
||||||
|
|
||||||
def _get_tts_pipeline():
|
def _get_torch_dtype():
|
||||||
global _tts_pipeline
|
device = _get_device()
|
||||||
if _tts_pipeline is not None:
|
import torch
|
||||||
return _tts_pipeline
|
return torch.float16 if device != "cpu" else torch.float32
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_cuda_cache():
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if _device and _device.startswith("cuda"):
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_mps_cache():
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if _device == "mps":
|
||||||
|
torch.mps.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_tts_pipeline_with_retry(max_retries: int = 2) -> bool:
|
||||||
|
"""
|
||||||
|
加载TTS管道,支持重试和降级
|
||||||
|
"""
|
||||||
|
global _tts_pipeline, _tts_loading
|
||||||
|
|
||||||
|
async with _tts_lock:
|
||||||
|
if _tts_pipeline is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if _tts_loading:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_tts_loading = True
|
||||||
|
|
||||||
|
try:
|
||||||
import torch
|
import torch
|
||||||
from transformers import pipeline
|
from transformers import pipeline
|
||||||
|
|
||||||
logger.info("[TTS] 加载 Kokoro-82M 模型...")
|
current_device = _get_device()
|
||||||
_tts_pipeline = pipeline(
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
device_to_use = _device_arg()
|
||||||
|
torch_dtype = _get_torch_dtype()
|
||||||
|
|
||||||
|
logger.info("[TTS] 加载 Kokoro-82M 模型 (尝试 %d/%d, 设备: %s)...",
|
||||||
|
attempt + 1, max_retries, device_to_use)
|
||||||
|
|
||||||
|
_tts_pipeline = await asyncio.to_thread(
|
||||||
|
lambda: pipeline(
|
||||||
"text-to-speech",
|
"text-to-speech",
|
||||||
model="hexgrad/Kokoro-82M",
|
model="hexgrad/Kokoro-82M",
|
||||||
trust_remote_code=True,
|
trust_remote_code=True,
|
||||||
device=_device_arg(),
|
device=device_to_use,
|
||||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
torch_dtype=torch_dtype,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("[TTS] Kokoro-82M 模型加载完成")
|
logger.info("[TTS] Kokoro-82M 模型加载完成")
|
||||||
return _tts_pipeline
|
return True
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
error_str = str(e)
|
||||||
|
if "MPS" in error_str or "mps" in error_str:
|
||||||
|
logger.warning("[TTS] MPS 推理失败,尝试降级到 CPU: %s", error_str)
|
||||||
|
global _device
|
||||||
|
_device = "cpu"
|
||||||
|
_clear_mps_cache()
|
||||||
|
continue
|
||||||
|
elif "CUDA" in error_str or "cuda" in error_str:
|
||||||
|
logger.warning("[TTS] CUDA 推理失败,尝试降级到 CPU: %s", error_str)
|
||||||
|
_device = "cpu"
|
||||||
|
_clear_cuda_cache()
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[TTS] 加载失败: %s", str(e))
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
return _tts_pipeline is not None
|
||||||
|
|
||||||
|
finally:
|
||||||
|
_tts_loading = False
|
||||||
|
|
||||||
|
|
||||||
def _get_asr_pipeline():
|
async def _load_asr_pipeline_with_retry(max_retries: int = 2) -> bool:
|
||||||
global _asr_pipeline
|
"""
|
||||||
|
加载ASR管道,支持重试和降级
|
||||||
|
"""
|
||||||
|
global _asr_pipeline, _asr_loading
|
||||||
|
|
||||||
|
async with _asr_lock:
|
||||||
if _asr_pipeline is not None:
|
if _asr_pipeline is not None:
|
||||||
return _asr_pipeline
|
return True
|
||||||
|
|
||||||
|
if _asr_loading:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_asr_loading = True
|
||||||
|
|
||||||
|
try:
|
||||||
import torch
|
import torch
|
||||||
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
||||||
|
|
||||||
logger.info("[ASR] 加载 Whisper large-v3-turbo 模型...")
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
device_to_use = _device_arg()
|
||||||
|
torch_dtype = _get_torch_dtype()
|
||||||
|
|
||||||
|
logger.info("[ASR] 加载 Whisper large-v3-turbo 模型 (尝试 %d/%d, 设备: %s)...",
|
||||||
|
attempt + 1, max_retries, device_to_use)
|
||||||
|
|
||||||
model_id = "openai/whisper-large-v3-turbo"
|
model_id = "openai/whisper-large-v3-turbo"
|
||||||
|
|
||||||
|
def load_model():
|
||||||
model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
||||||
model_id,
|
model_id,
|
||||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
torch_dtype=torch_dtype,
|
||||||
low_cpu_mem_usage=True,
|
low_cpu_mem_usage=True,
|
||||||
use_safetensors=True,
|
use_safetensors=True,
|
||||||
)
|
)
|
||||||
processor = AutoProcessor.from_pretrained(model_id)
|
processor = AutoProcessor.from_pretrained(model_id)
|
||||||
_asr_pipeline = pipeline(
|
return pipeline(
|
||||||
"automatic-speech-recognition",
|
"automatic-speech-recognition",
|
||||||
model=model,
|
model=model,
|
||||||
tokenizer=processor.tokenizer,
|
tokenizer=processor.tokenizer,
|
||||||
feature_extractor=processor.feature_extractor,
|
feature_extractor=processor.feature_extractor,
|
||||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
torch_dtype=torch_dtype,
|
||||||
device=_device_arg(),
|
device=device_to_use,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_asr_pipeline = await asyncio.to_thread(load_model)
|
||||||
logger.info("[ASR] Whisper large-v3-turbo 模型加载完成")
|
logger.info("[ASR] Whisper large-v3-turbo 模型加载完成")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
error_str = str(e)
|
||||||
|
if "MPS" in error_str or "mps" in error_str:
|
||||||
|
logger.warning("[ASR] MPS 推理失败,尝试降级到 CPU: %s", error_str)
|
||||||
|
global _device
|
||||||
|
_device = "cpu"
|
||||||
|
_clear_mps_cache()
|
||||||
|
continue
|
||||||
|
elif "CUDA" in error_str or "cuda" in error_str:
|
||||||
|
logger.warning("[ASR] CUDA 推理失败,尝试降级到 CPU: %s", error_str)
|
||||||
|
_device = "cpu"
|
||||||
|
_clear_cuda_cache()
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[ASR] 加载失败: %s", str(e))
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
return _asr_pipeline is not None
|
||||||
|
|
||||||
|
finally:
|
||||||
|
_asr_loading = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tts_pipeline():
|
||||||
|
"""同步获取TTS管道(已弃用,保留兼容性)"""
|
||||||
|
if _tts_pipeline is not None:
|
||||||
|
return _tts_pipeline
|
||||||
|
raise RuntimeError("TTS 管道未加载,请使用 _load_tts_pipeline_with_retry()")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_asr_pipeline():
|
||||||
|
"""同步获取ASR管道(已弃用,保留兼容性)"""
|
||||||
|
if _asr_pipeline is not None:
|
||||||
return _asr_pipeline
|
return _asr_pipeline
|
||||||
|
raise RuntimeError("ASR 管道未加载,请使用 _load_asr_pipeline_with_retry()")
|
||||||
|
|
||||||
|
|
||||||
|
async def _warmup_tts() -> bool:
|
||||||
|
"""
|
||||||
|
预热TTS模型,减少首次请求延迟
|
||||||
|
"""
|
||||||
|
global _tts_last_used
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("[TTS] 开始预热...")
|
||||||
|
|
||||||
|
if not await _load_tts_pipeline_with_retry():
|
||||||
|
logger.error("[TTS] 预热失败:无法加载管道")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tts = _tts_pipeline
|
||||||
|
if tts is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def warmup_inference():
|
||||||
|
try:
|
||||||
|
result = tts(TTS_WARMUP_TEXT, voice="af_bella")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
audio = result.get("audio")
|
||||||
|
if hasattr(audio, "cpu"):
|
||||||
|
_ = audio.cpu()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[TTS] 预热推理失败(可忽略): %s", str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
success = await asyncio.to_thread(warmup_inference)
|
||||||
|
_tts_last_used = time.time()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info("[TTS] 预热完成")
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[TTS] 预热异常: %s", str(e))
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _warmup_asr() -> bool:
|
||||||
|
"""
|
||||||
|
预热ASR模型,减少首次请求延迟
|
||||||
|
"""
|
||||||
|
global _asr_last_used
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("[ASR] 开始预热...")
|
||||||
|
|
||||||
|
if not await _load_asr_pipeline_with_retry():
|
||||||
|
logger.error("[ASR] 预热失败:无法加载管道")
|
||||||
|
return False
|
||||||
|
|
||||||
|
asr = _asr_pipeline
|
||||||
|
if asr is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
silence_samples = int(16000 * ASR_WARMUP_AUDIO_SECONDS)
|
||||||
|
silence_audio = np.zeros(silence_samples, dtype=np.float32)
|
||||||
|
|
||||||
|
def warmup_inference():
|
||||||
|
try:
|
||||||
|
result = asr(
|
||||||
|
silence_audio,
|
||||||
|
sampling_rate=16000,
|
||||||
|
generate_kwargs={"language": "zh", "task": "transcribe"},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[ASR] 预热推理失败(可忽略): %s", str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
success = await asyncio.to_thread(warmup_inference)
|
||||||
|
_asr_last_used = time.time()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info("[ASR] 预热完成")
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[ASR] 预热异常: %s", str(e))
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _warmup_all() -> tuple[bool, bool]:
|
||||||
|
"""
|
||||||
|
预热所有模型
|
||||||
|
返回: (TTS预热结果, ASR预热结果)
|
||||||
|
"""
|
||||||
|
logger.info("[Warmup] 开始预热所有模型 (超时: %d秒)", TTS_ASR_WARMUP_TIMEOUT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tts_task = asyncio.create_task(_warmup_tts())
|
||||||
|
asr_task = asyncio.create_task(_warmup_asr())
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[tts_task, asr_task],
|
||||||
|
timeout=TTS_ASR_WARMUP_TIMEOUT,
|
||||||
|
return_when=asyncio.ALL_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tts_result = tts_task.result() if tts_task in done else False
|
||||||
|
asr_result = asr_task.result() if asr_task in done else False
|
||||||
|
|
||||||
|
logger.info("[Warmup] 完成: TTS=%s, ASR=%s", tts_result, asr_result)
|
||||||
|
return tts_result, asr_result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[Warmup] 异常: %s", str(e))
|
||||||
|
traceback.print_exc()
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_and_unload_idle_models():
|
||||||
|
"""
|
||||||
|
检查并卸载空闲超过阈值的模型
|
||||||
|
"""
|
||||||
|
if TTS_ASR_IDLE_TIMEOUT <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
global _tts_pipeline, _asr_pipeline
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
if _tts_pipeline is not None:
|
||||||
|
idle_seconds = current_time - _tts_last_used
|
||||||
|
if idle_seconds > TTS_ASR_IDLE_TIMEOUT:
|
||||||
|
logger.info("[TTS] 空闲 %.0f 秒,卸载模型", idle_seconds)
|
||||||
|
_tts_pipeline = None
|
||||||
|
_clear_cuda_cache()
|
||||||
|
_clear_mps_cache()
|
||||||
|
|
||||||
|
if _asr_pipeline is not None:
|
||||||
|
idle_seconds = current_time - _asr_last_used
|
||||||
|
if idle_seconds > TTS_ASR_IDLE_TIMEOUT:
|
||||||
|
logger.info("[ASR] 空闲 %.0f 秒,卸载模型", idle_seconds)
|
||||||
|
_asr_pipeline = None
|
||||||
|
_clear_cuda_cache()
|
||||||
|
_clear_mps_cache()
|
||||||
|
|
||||||
|
|
||||||
def _save_audio_to_wav(audio_data: bytes, sample_rate: int = 16000) -> str:
|
def _save_audio_to_wav(audio_data: bytes, sample_rate: int = 16000) -> str:
|
||||||
@@ -105,14 +471,30 @@ def _save_audio_to_wav(audio_data: bytes, sample_rate: int = 16000) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
def _tts_sync(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[bytes, int]:
|
async def _tts_sync_with_retry(text: str, voice: str = "af_bella", rate: float = 1.0, max_retries: int = 2) -> tuple[bytes, int]:
|
||||||
tts = _get_tts_pipeline()
|
"""
|
||||||
|
TTS推理,支持重试和降级
|
||||||
|
"""
|
||||||
|
global _tts_last_used
|
||||||
|
|
||||||
|
_check_and_unload_idle_models()
|
||||||
|
|
||||||
|
if not await _load_tts_pipeline_with_retry():
|
||||||
|
raise RuntimeError("TTS 模型加载失败")
|
||||||
|
|
||||||
|
tts = _tts_pipeline
|
||||||
|
sample_rate = 24000
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
def inference():
|
||||||
result = tts(text, voice=voice)
|
result = tts(text, voice=voice)
|
||||||
audio = None
|
audio = None
|
||||||
sample_rate = 24000
|
sr = sample_rate
|
||||||
|
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
audio = result.get("audio")
|
audio = result.get("audio")
|
||||||
sample_rate = int(result.get("sampling_rate", sample_rate))
|
sr = int(result.get("sampling_rate", sr))
|
||||||
elif isinstance(result, (list, tuple)) and result:
|
elif isinstance(result, (list, tuple)) and result:
|
||||||
audio = result[0]
|
audio = result[0]
|
||||||
|
|
||||||
@@ -122,6 +504,13 @@ def _tts_sync(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[by
|
|||||||
if hasattr(audio, "cpu"):
|
if hasattr(audio, "cpu"):
|
||||||
audio = audio.cpu().numpy()
|
audio = audio.cpu().numpy()
|
||||||
|
|
||||||
|
if hasattr(audio, "numpy"):
|
||||||
|
audio = audio.numpy()
|
||||||
|
|
||||||
|
return audio, sr
|
||||||
|
|
||||||
|
audio, sample_rate = await asyncio.to_thread(inference)
|
||||||
|
|
||||||
duration_ms = int(len(audio) * 1000 / sample_rate)
|
duration_ms = int(len(audio) * 1000 / sample_rate)
|
||||||
|
|
||||||
if audio.dtype != np.int16:
|
if audio.dtype != np.int16:
|
||||||
@@ -139,23 +528,74 @@ def _tts_sync(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[by
|
|||||||
wf.setframerate(sample_rate)
|
wf.setframerate(sample_rate)
|
||||||
wf.writeframes(audio.tobytes())
|
wf.writeframes(audio.tobytes())
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
return f.read(), duration_ms
|
audio_bytes = f.read()
|
||||||
|
_tts_last_used = time.time()
|
||||||
|
return audio_bytes, duration_ms
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(output_path):
|
if os.path.exists(output_path):
|
||||||
os.unlink(output_path)
|
os.unlink(output_path)
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
error_str = str(e)
|
||||||
|
if "MPS" in error_str or "mps" in error_str:
|
||||||
|
logger.warning("[TTS] MPS 推理错误,尝试降级重试 (尝试 %d/%d): %s",
|
||||||
|
attempt + 1, max_retries, error_str)
|
||||||
|
global _device
|
||||||
|
_device = "cpu"
|
||||||
|
_clear_mps_cache()
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue
|
||||||
|
elif "CUDA" in error_str or "cuda" in error_str:
|
||||||
|
logger.warning("[TTS] CUDA 推理错误,尝试降级重试 (尝试 %d/%d): %s",
|
||||||
|
attempt + 1, max_retries, error_str)
|
||||||
|
_device = "cpu"
|
||||||
|
_clear_cuda_cache()
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[TTS] 推理失败: %s", str(e))
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
async def _text_to_speech(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[bytes, int]:
|
raise RuntimeError("TTS 推理失败")
|
||||||
return await asyncio.to_thread(_tts_sync, text, voice, rate)
|
|
||||||
|
|
||||||
|
|
||||||
def _asr_sync(audio_data: bytes, language: str = "zh") -> str:
|
async def _asr_sync_with_retry(audio_data: bytes, language: str = "zh", max_retries: int = 2) -> str:
|
||||||
|
"""
|
||||||
|
ASR推理,支持重试和降级
|
||||||
|
"""
|
||||||
|
global _asr_last_used
|
||||||
|
|
||||||
|
_check_and_unload_idle_models()
|
||||||
|
|
||||||
|
if not await _load_asr_pipeline_with_retry():
|
||||||
|
raise RuntimeError("ASR 模型加载失败")
|
||||||
|
|
||||||
|
audio_path = _save_audio_to_wav(audio_data)
|
||||||
|
|
||||||
|
try:
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|
||||||
asr = _get_asr_pipeline()
|
audio_array, sample_rate = await asyncio.to_thread(lambda: sf.read(audio_path))
|
||||||
audio_path = _save_audio_to_wav(audio_data)
|
|
||||||
|
if len(audio_array.shape) > 1:
|
||||||
|
audio_array = np.mean(audio_array, axis=1)
|
||||||
|
|
||||||
|
if sample_rate != 16000:
|
||||||
|
import librosa
|
||||||
|
audio_array = await asyncio.to_thread(
|
||||||
|
lambda: librosa.resample(audio_array, orig_sr=sample_rate, target_sr=16000)
|
||||||
|
)
|
||||||
|
sample_rate = 16000
|
||||||
|
|
||||||
|
audio_array = audio_array.astype(np.float32)
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
audio_array, sample_rate = sf.read(audio_path)
|
def inference():
|
||||||
|
asr = _asr_pipeline
|
||||||
result = asr(
|
result = asr(
|
||||||
audio_array,
|
audio_array,
|
||||||
sampling_rate=sample_rate,
|
sampling_rate=sample_rate,
|
||||||
@@ -164,15 +604,60 @@ def _asr_sync(audio_data: bytes, language: str = "zh") -> str:
|
|||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
return result.get("text", "").strip()
|
return result.get("text", "").strip()
|
||||||
return str(result).strip()
|
return str(result).strip()
|
||||||
|
|
||||||
|
text = await asyncio.to_thread(inference)
|
||||||
|
_asr_last_used = time.time()
|
||||||
|
return text
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
error_str = str(e)
|
||||||
|
if "MPS" in error_str or "mps" in error_str:
|
||||||
|
logger.warning("[ASR] MPS 推理错误,尝试降级重试 (尝试 %d/%d): %s",
|
||||||
|
attempt + 1, max_retries, error_str)
|
||||||
|
global _device
|
||||||
|
_device = "cpu"
|
||||||
|
_clear_mps_cache()
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue
|
||||||
|
elif "CUDA" in error_str or "cuda" in error_str:
|
||||||
|
logger.warning("[ASR] CUDA 推理错误,尝试降级重试 (尝试 %d/%d): %s",
|
||||||
|
attempt + 1, max_retries, error_str)
|
||||||
|
_device = "cpu"
|
||||||
|
_clear_cuda_cache()
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[ASR] 推理失败: %s", str(e))
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
raise RuntimeError("ASR 推理失败")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(audio_path):
|
if os.path.exists(audio_path):
|
||||||
os.unlink(audio_path)
|
os.unlink(audio_path)
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy sync wrappers (for compatibility)
|
||||||
|
def _tts_sync(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[bytes, int]:
|
||||||
|
raise RuntimeError("请使用 _tts_sync_with_retry()")
|
||||||
|
|
||||||
|
|
||||||
|
def _asr_sync(audio_data: bytes, language: str = "zh") -> str:
|
||||||
|
raise RuntimeError("请使用 _asr_sync_with_retry()")
|
||||||
|
|
||||||
|
|
||||||
|
async def _text_to_speech(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[bytes, int]:
|
||||||
|
return await _tts_sync_with_retry(text, voice, rate)
|
||||||
|
|
||||||
|
|
||||||
async def _speech_to_text(audio_data: bytes, language: str = "zh") -> str:
|
async def _speech_to_text(audio_data: bytes, language: str = "zh") -> str:
|
||||||
return await asyncio.to_thread(_asr_sync, audio_data, language)
|
return await _asr_sync_with_retry(audio_data, language)
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response models
|
||||||
class TTSRequest(BaseModel):
|
class TTSRequest(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
voice: str = "af_bella"
|
voice: str = "af_bella"
|
||||||
@@ -196,21 +681,58 @@ class ASRResponse(BaseModel):
|
|||||||
language: str
|
language: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModelStatus(BaseModel):
|
||||||
|
tts_loaded: bool
|
||||||
|
asr_loaded: bool
|
||||||
|
device: str
|
||||||
|
tts_last_used: Optional[float] = None
|
||||||
|
asr_last_used: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
def get_api_key(api_key: str):
|
def get_api_key(api_key: str):
|
||||||
import main
|
import main
|
||||||
|
|
||||||
API_KEY = main.API_KEY
|
API_KEY = main.API_KEY
|
||||||
if api_key != API_KEY:
|
if api_key != API_KEY:
|
||||||
raise HTTPException(status_code=403, detail="API Key 无效")
|
raise HTTPException(status_code=403, detail="API Key 无效")
|
||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=ModelStatus)
|
||||||
|
async def get_status(api_key: str = Security(get_api_key)):
|
||||||
|
"""
|
||||||
|
获取模型状态
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
return ModelStatus(
|
||||||
|
tts_loaded=_tts_pipeline is not None,
|
||||||
|
asr_loaded=_asr_pipeline is not None,
|
||||||
|
device=_get_device(),
|
||||||
|
tts_last_used=_tts_last_used if _tts_last_used > 0 else None,
|
||||||
|
asr_last_used=_asr_last_used if _asr_last_used > 0 else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/warmup")
|
||||||
|
async def warmup_models(api_key: str = Security(get_api_key)):
|
||||||
|
"""
|
||||||
|
手动触发模型预热
|
||||||
|
"""
|
||||||
|
tts_result, asr_result = await _warmup_all()
|
||||||
|
return {
|
||||||
|
"tts_warmup": tts_result,
|
||||||
|
"asr_warmup": asr_result,
|
||||||
|
"device": _get_device(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tts", response_model=TTSResponse)
|
@router.post("/tts", response_model=TTSResponse)
|
||||||
async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
|
async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
|
||||||
request_id = str(hash(req.text))[:8]
|
request_id = str(hash(req.text))[:8]
|
||||||
try:
|
try:
|
||||||
logger.info("[TTS][%s] text_chars=%d voice=%s format=%s", request_id, len(req.text), req.voice, req.format)
|
logger.info("[TTS][%s] text_chars=%d voice=%s format=%s",
|
||||||
|
request_id, len(req.text), req.voice, req.format)
|
||||||
audio_data, duration_ms = await _text_to_speech(req.text, req.voice, req.rate)
|
audio_data, duration_ms = await _text_to_speech(req.text, req.voice, req.rate)
|
||||||
|
|
||||||
if req.format.lower() == "mp3":
|
if req.format.lower() == "mp3":
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -222,7 +744,9 @@ async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
|
|||||||
output_path = tmp_out.name
|
output_path = tmp_out.name
|
||||||
try:
|
try:
|
||||||
cmd = ["ffmpeg", "-i", input_path, "-acodec", "libmp3lame", "-ab", "128k", output_path]
|
cmd = ["ffmpeg", "-i", input_path, "-acodec", "libmp3lame", "-ab", "128k", output_path]
|
||||||
result = await asyncio.to_thread(lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=30))
|
result = await asyncio.to_thread(
|
||||||
|
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f"MP3 转换失败: {result.stderr}")
|
raise RuntimeError(f"MP3 转换失败: {result.stderr}")
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
@@ -231,8 +755,14 @@ async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
|
|||||||
for path in [input_path, output_path]:
|
for path in [input_path, output_path]:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
|
|
||||||
logger.info("[TTS][%s] success duration_ms=%d", request_id, duration_ms)
|
logger.info("[TTS][%s] success duration_ms=%d", request_id, duration_ms)
|
||||||
return TTSResponse(audio_base64=base64.b64encode(audio_data).decode(), format=req.format, duration_ms=duration_ms)
|
return TTSResponse(
|
||||||
|
audio_base64=base64.b64encode(audio_data).decode(),
|
||||||
|
format=req.format,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[TTS] failed: %s", e)
|
logger.exception("[TTS] failed: %s", e)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -242,15 +772,26 @@ async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
|
|||||||
async def speech_to_text(req: ASRRequest, api_key: str = Security(get_api_key)):
|
async def speech_to_text(req: ASRRequest, api_key: str = Security(get_api_key)):
|
||||||
request_id = str(hash(req.audio_base64))[:8]
|
request_id = str(hash(req.audio_base64))[:8]
|
||||||
try:
|
try:
|
||||||
logger.info("[ASR][%s] audio_base64_chars=%d language=%s", request_id, len(req.audio_base64), req.language)
|
logger.info("[ASR][%s] audio_base64_chars=%d language=%s",
|
||||||
|
request_id, len(req.audio_base64), req.language)
|
||||||
audio_data = base64.b64decode(req.audio_base64)
|
audio_data = base64.b64decode(req.audio_base64)
|
||||||
text = await _speech_to_text(audio_data, req.language[:2])
|
text = await _speech_to_text(audio_data, req.language[:2])
|
||||||
logger.info("[ASR][%s] success text_chars=%d", request_id, len(text))
|
logger.info("[ASR][%s] success text_chars=%d", request_id, len(text))
|
||||||
return ASRResponse(text=text, language=req.language)
|
return ASRResponse(text=text, language=req.language)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[ASR] failed: %s", e)
|
logger.exception("[ASR] failed: %s", e)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
def register_tts_asr_routes(app):
|
def register_tts_asr_routes(app):
|
||||||
|
"""
|
||||||
|
注册TTS/ASR路由并可选执行预热
|
||||||
|
"""
|
||||||
app.include_router(router, prefix="/v1/tts-asr")
|
app.include_router(router, prefix="/v1/tts-asr")
|
||||||
|
|
||||||
|
if TTS_ASR_WARMUP:
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def warmup_on_startup():
|
||||||
|
logger.info("[Startup] 开始后台预热...")
|
||||||
|
asyncio.create_task(_warmup_all())
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doc-card__editor :deep(.ProseMirror) {
|
.doc-card__editor :deep(.ProseMirror) {
|
||||||
min-height: 80px;
|
min-height: 0;
|
||||||
padding: 10px 12px 12px !important;
|
padding: 10px 12px 12px !important;
|
||||||
font-size: 13px !important;
|
font-size: 13px !important;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|||||||
@@ -251,7 +251,6 @@ const docUploadButtonTitle = computed(() => {
|
|||||||
|
|
||||||
let crepe = null
|
let crepe = null
|
||||||
let markdownSyncTimer = null
|
let markdownSyncTimer = null
|
||||||
let rootResizeObserver = null
|
|
||||||
let editorCopyHandler = null
|
let editorCopyHandler = null
|
||||||
const objectUrls = new Set()
|
const objectUrls = new Set()
|
||||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||||
@@ -437,16 +436,9 @@ const clearCurrentSuggestion = (view) => {
|
|||||||
const clearCurrentGhost = () => {
|
const clearCurrentGhost = () => {
|
||||||
if (!crepe) return
|
if (!crepe) return
|
||||||
crepe.editor.action((ctx) => {
|
crepe.editor.action((ctx) => {
|
||||||
const view = ctx.get(editorViewCtx)
|
const view = ctx.get(editorViewCtx)
|
||||||
clearGhostSuggestion(view)
|
clearGhostSuggestion(view)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const updateEditorTailSpace = () => {
|
|
||||||
if (!root.value) return
|
|
||||||
const viewportHeight = root.value.clientHeight
|
|
||||||
const tailSpace = Math.max(viewportHeight - 32, 160)
|
|
||||||
root.value.style.setProperty('--editor-tail-space', `${tailSpace}px`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateHistoryState = (view) => {
|
const updateHistoryState = (view) => {
|
||||||
@@ -655,18 +647,11 @@ onMounted(async () => {
|
|||||||
if (!e.target.closest('.image-btn-wrapper')) {
|
if (!e.target.closest('.image-btn-wrapper')) {
|
||||||
showImageDropdown.value = false
|
showImageDropdown.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!root.value) throw new Error('root.value is null')
|
if (!root.value) throw new Error('root.value is null')
|
||||||
updateEditorTailSpace()
|
|
||||||
if (typeof ResizeObserver !== 'undefined') {
|
|
||||||
rootResizeObserver = new ResizeObserver(() => {
|
|
||||||
updateEditorTailSpace()
|
|
||||||
})
|
|
||||||
rootResizeObserver.observe(root.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
crepe = new Crepe({
|
crepe = new Crepe({
|
||||||
root: root.value,
|
root: root.value,
|
||||||
defaultValue: transformSpecialDocBlocksToLegacy(initialMarkdown.value || ''),
|
defaultValue: transformSpecialDocBlocksToLegacy(initialMarkdown.value || ''),
|
||||||
features: {
|
features: {
|
||||||
@@ -731,6 +716,15 @@ onMounted(async () => {
|
|||||||
|
|
||||||
await crepe.create()
|
await crepe.create()
|
||||||
|
|
||||||
|
crepe.editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx)
|
||||||
|
const { doc } = view.state
|
||||||
|
const endPos = doc.content.size
|
||||||
|
const tr = view.state.tr.setSelection(Selection.near(doc.resolve(endPos), 1))
|
||||||
|
view.dispatch(tr)
|
||||||
|
view.focus()
|
||||||
|
})
|
||||||
|
|
||||||
crepe.on((listener) => {
|
crepe.on((listener) => {
|
||||||
listener.updated((ctx, doc) => {
|
listener.updated((ctx, doc) => {
|
||||||
const view = ctx.get(editorViewCtx)
|
const view = ctx.get(editorViewCtx)
|
||||||
@@ -973,12 +967,7 @@ const insertMultipleDocBlocks = (blocks) => {
|
|||||||
|
|
||||||
blocks.forEach((block, index) => {
|
blocks.forEach((block, index) => {
|
||||||
const maxPos = tr.doc.content.size
|
const maxPos = tr.doc.content.size
|
||||||
|
|
||||||
if (index > 0) {
|
|
||||||
const insertPos = Math.min(currentPos, maxPos)
|
const insertPos = Math.min(currentPos, maxPos)
|
||||||
tr = tr.insertText('\n', insertPos, insertPos)
|
|
||||||
currentPos = insertPos + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockNode = docBlockType.create({
|
const blockNode = docBlockType.create({
|
||||||
docType: block.docType,
|
docType: block.docType,
|
||||||
@@ -988,9 +977,8 @@ const insertMultipleDocBlocks = (blocks) => {
|
|||||||
collapsed: Boolean(block.collapsed),
|
collapsed: Boolean(block.collapsed),
|
||||||
})
|
})
|
||||||
|
|
||||||
const insertBlockPos = Math.min(currentPos, tr.doc.content.size)
|
tr = tr.replaceRangeWith(insertPos, insertPos, blockNode)
|
||||||
tr = tr.replaceRangeWith(insertBlockPos, insertBlockPos, blockNode)
|
currentPos = insertPos + blockNode.nodeSize
|
||||||
currentPos = insertBlockPos + blockNode.nodeSize
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const finalPos = Math.min(currentPos, tr.doc.content.size)
|
const finalPos = Math.min(currentPos, tr.doc.content.size)
|
||||||
@@ -1133,16 +1121,11 @@ const insertImageFromUrl = () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (markdownSyncTimer) {
|
if (markdownSyncTimer) {
|
||||||
clearTimeout(markdownSyncTimer)
|
clearTimeout(markdownSyncTimer)
|
||||||
markdownSyncTimer = null
|
markdownSyncTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rootResizeObserver) {
|
for (const url of Array.from(objectUrls)) {
|
||||||
rootResizeObserver.disconnect()
|
|
||||||
rootResizeObserver = null
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const url of Array.from(objectUrls)) {
|
|
||||||
revokeObjectUrl(url)
|
revokeObjectUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1169,8 +1152,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.history-buttons {
|
.history-buttons {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(16px + env(safe-area-inset-top));
|
bottom: 20px;
|
||||||
right: calc(16px + env(safe-area-inset-right));
|
left: 80px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
@@ -1520,7 +1503,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor {
|
.milkdown-editor {
|
||||||
--editor-tail-space: calc(100vh - 32px);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
@@ -1552,7 +1534,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.milkdown-editor :deep(.ProseMirror) {
|
.milkdown-editor :deep(.ProseMirror) {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 0 var(--editor-tail-space) 0 !important;
|
padding: 10px 0 24px 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor :deep(.ProseMirror img) {
|
.milkdown-editor :deep(.ProseMirror img) {
|
||||||
|
|||||||
@@ -312,13 +312,15 @@ const t = (key) => store.t[key]
|
|||||||
.settings-panel {
|
.settings-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: auto;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 350px;
|
width: 350px;
|
||||||
|
max-height: 100vh;
|
||||||
background: var(--panel-bg);
|
background: var(--panel-bg);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border-right: 1px solid var(--panel-border);
|
border-right: 1px solid var(--panel-border);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
box-shadow: var(--panel-shadow);
|
box-shadow: var(--panel-shadow);
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
@@ -349,8 +351,11 @@ const t = (key) => store.t[key]
|
|||||||
/* Mobile Fullscreen */
|
/* Mobile Fullscreen */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
|
top: 10vh;
|
||||||
|
max-height: 80vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +389,7 @@ const t = (key) => store.t[key]
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
|
|||||||
Reference in New Issue
Block a user