Compare commits

..

2 Commits

44 changed files with 3949 additions and 2518 deletions
+2 -1
View File
@@ -1,4 +1,4 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
@@ -13,6 +13,7 @@ dist-ssr
*.local *.local
# Python # Python
backend/models/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.pyc.* *.pyc.*
@@ -0,0 +1,91 @@
- generic [ref=e3]:
- generic:
- button "设置" [ref=e5] [cursor=pointer]:
- img [ref=e6]
- generic [ref=e9]:
- generic [ref=e10]:
- heading "设置" [level=2] [ref=e11]
- button "关闭" [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]:
- generic [ref=e17]:
- heading "外观" [level=3] [ref=e18]
- generic [ref=e19]:
- generic [ref=e20]: 外观
- combobox [ref=e21]:
- option "深色"
- option "浅色"
- option "跟随系统" [selected]
- option "暖色调"
- option "读书灯"
- option "自定义图片"
- generic [ref=e22]:
- heading "视图" [level=3] [ref=e23]
- generic [ref=e24]:
- button "编辑器" [ref=e25] [cursor=pointer]:
- img [ref=e26]
- generic [ref=e28]: 编辑器
- button "文档" [ref=e29] [cursor=pointer]:
- img [ref=e30]
- generic [ref=e33]: 文档
- generic [ref=e34]:
- heading "PRO 模式" [level=3] [ref=e35]
- generic [ref=e36]:
- generic [ref=e37]: PRO 思考程度
- generic [ref=e38]:
- button "低" [ref=e39] [cursor=pointer]
- button "中" [ref=e40] [cursor=pointer]
- button "高" [ref=e41] [cursor=pointer]
- paragraph [ref=e42]: PRO 模式使用独立思考强度,普通补全设置不会影响它。
- generic [ref=e43]:
- heading "模型智能" [level=3] [ref=e44]
- generic [ref=e45]:
- generic [ref=e46]: 思考程度
- generic [ref=e47]:
- button "低" [ref=e48] [cursor=pointer]
- button "中" [ref=e49] [cursor=pointer]
- button "高" [ref=e50] [cursor=pointer]
- paragraph [ref=e51]: 直接补全(最快)
- generic [ref=e52]:
- generic [ref=e53]: "防抖时间: 1000ms"
- slider [ref=e54]: "1000"
- generic [ref=e55]:
- heading "隐私与偏好" [level=3] [ref=e56]
- generic [ref=e57]:
- generic [ref=e58]: 隐私模式
- button [ref=e59] [cursor=pointer]
- paragraph [ref=e61]: 不向 AI 发送 IP 地址和偏好设置
- generic [ref=e62]:
- generic [ref=e63]: 语言
- combobox [disabled] [ref=e64]:
- option "自动检测" [selected]
- option "Chinese"
- option "English"
- option "Japanese"
- option "Korean"
- option "German"
- option "French"
- generic [ref=e65]:
- generic [ref=e66]: 货币
- combobox [disabled] [ref=e67]:
- option "自动检测" [selected]
- option "CNY (¥)"
- option "USD ($)"
- option "EUR (€)"
- option "JPY (¥)"
- option "KRW (₩)"
- option "GBP (£)"
- option "AUD ($)"
- option "CAD ($)"
- generic [ref=e68]:
- heading "语音设置" [level=3] [ref=e69]
- generic [ref=e70]:
- generic [ref=e71]: 声音描述
- textbox "例如:用温柔的语气说" [ref=e72]
- paragraph [ref=e73]: 描述你想要的声音风格,如语气、情感等
- generic [ref=e74]:
- heading "关于我们" [level=3] [ref=e75]
- generic [ref=e76]:
- heading "llm-in-text" [level=4] [ref=e77]
- paragraph [ref=e78]: A smart Markdown editor with local LLM intelligence.
- paragraph [ref=e79]: v0.0.0
@@ -0,0 +1,124 @@
- generic [ref=e3]:
- generic [ref=e80]:
- textbox [active] [ref=e83]:
- heading "欢迎使用 LLM-IN-TEXT" [level=1] [ref=e84]
- paragraph [ref=e85]: 即时可用的 LLM 系统
- paragraph [ref=e86]: 在下方开始创作吧...
- generic [ref=e87]:
- button "Undo" [disabled] [ref=e88]:
- img [ref=e89]
- button "Redo" [disabled] [ref=e92]:
- img [ref=e93]
- generic [ref=e96]:
- button "上传" [ref=e97] [cursor=pointer]:
- img [ref=e98]
- generic: 上传
- button "导入 Markdown" [ref=e104] [cursor=pointer]:
- img [ref=e105]
- generic: 导入 Markdown
- button "导出 Markdown" [ref=e109] [cursor=pointer]:
- img [ref=e110]
- generic: 导出 Markdown
- button "禁用 AI" [ref=e114] [cursor=pointer]:
- img [ref=e115]
- generic: 禁用 AI
- generic [ref=e119]:
- img [ref=e120]
- text: 0 KB
- generic [ref=e122]:
- button "模板" [ref=e124] [cursor=pointer]:
- img [ref=e125]
- generic: 模板
- button "清除" [ref=e128] [cursor=pointer]:
- img [ref=e129]
- generic: 清除文档
- generic:
- button "设置" [ref=e5] [cursor=pointer]:
- img [ref=e6]
- generic [ref=e9]:
- generic [ref=e10]:
- heading "设置" [level=2] [ref=e11]
- button "关闭" [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]:
- generic [ref=e17]:
- heading "外观" [level=3] [ref=e18]
- generic [ref=e19]:
- generic [ref=e20]: 外观
- combobox [ref=e21]:
- option "深色"
- option "浅色"
- option "跟随系统" [selected]
- option "暖色调"
- option "读书灯"
- option "自定义图片"
- generic [ref=e22]:
- heading "视图" [level=3] [ref=e23]
- generic [ref=e24]:
- button "编辑器" [ref=e25] [cursor=pointer]:
- img [ref=e26]
- generic [ref=e28]: 编辑器
- button "文档" [ref=e29] [cursor=pointer]:
- img [ref=e30]
- generic [ref=e33]: 文档
- generic [ref=e34]:
- heading "PRO 模式" [level=3] [ref=e35]
- generic [ref=e36]:
- generic [ref=e37]: PRO 思考程度
- generic [ref=e38]:
- button "低" [ref=e39] [cursor=pointer]
- button "中" [ref=e40] [cursor=pointer]
- button "高" [ref=e41] [cursor=pointer]
- paragraph [ref=e42]: PRO 模式使用独立思考强度,普通补全设置不会影响它。
- generic [ref=e43]:
- heading "模型智能" [level=3] [ref=e44]
- generic [ref=e45]:
- generic [ref=e46]: 思考程度
- generic [ref=e47]:
- button "低" [ref=e48] [cursor=pointer]
- button "中" [ref=e49] [cursor=pointer]
- button "高" [ref=e50] [cursor=pointer]
- paragraph [ref=e51]: 直接补全(最快)
- generic [ref=e52]:
- generic [ref=e53]: "防抖时间: 1000ms"
- slider [ref=e54]: "1000"
- generic [ref=e55]:
- heading "隐私与偏好" [level=3] [ref=e56]
- generic [ref=e57]:
- generic [ref=e58]: 隐私模式
- button [ref=e59] [cursor=pointer]
- paragraph [ref=e61]: 不向 AI 发送 IP 地址和偏好设置
- generic [ref=e62]:
- generic [ref=e63]: 语言
- combobox [disabled] [ref=e64]:
- option "自动检测" [selected]
- option "Chinese"
- option "English"
- option "Japanese"
- option "Korean"
- option "German"
- option "French"
- generic [ref=e65]:
- generic [ref=e66]: 货币
- combobox [disabled] [ref=e67]:
- option "自动检测" [selected]
- option "CNY (¥)"
- option "USD ($)"
- option "EUR (€)"
- option "JPY (¥)"
- option "KRW (₩)"
- option "GBP (£)"
- option "AUD ($)"
- option "CAD ($)"
- generic [ref=e68]:
- heading "语音设置" [level=3] [ref=e69]
- generic [ref=e70]:
- generic [ref=e71]: 声音描述
- textbox "例如:用温柔的语气说" [ref=e72]
- paragraph [ref=e73]: 描述你想要的声音风格,如语气、情感等
- generic [ref=e74]:
- heading "关于我们" [level=3] [ref=e75]
- generic [ref=e76]:
- heading "llm-in-text" [level=4] [ref=e77]
- paragraph [ref=e78]: A smart Markdown editor with local LLM intelligence.
- paragraph [ref=e79]: v0.0.0
+100
View File
@@ -0,0 +1,100 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with this repository.
## Project Overview
**LLM in Text** is an AI-powered Markdown editor built with Vue 3 + Vite (frontend) and FastAPI + Python + Ollama (backend). It provides real-time AI completion suggestions, OCR image recognition, document conversion (PDF/DOCX/PPTX to Markdown), and TTS text-to-speech.
- Completion interface uses plain POST/JSON (not SSE). Frontend sends `X-Request-Id` and calls `/v1/completions/cancel` on abort.
- AI completion is disabled when the document exceeds 32 KB (enforced both in UI and plugin layer).
- OCR text and doc-block content are injected into completion context as hidden context — they should NOT be rendered as visible user text.
- `/v1/convert` supports txt, docx, pptx, pdf via MarkItDown. Non-txt files go through Markdown sanitization (image removal, newline compression).
- `/v1/export/pdf` is called from the frontend but may not be implemented on the backend — verify before debugging PDF export.
## Quick Start
```bash
# Frontend
npm install
npm run dev # Vite dev server on port 5173, proxies /v1 to backend
# Backend
pip install -r backend/requirements.txt
python backend/main.py # port 8001
# Tests (90% coverage gate on backend modules)
pytest # full suite with coverage
# Single test file (faster, no coverage overhead)
pytest backend/tests/test_prompt.py -v --no-cov
# Build for production
npm run build
```
## Architecture
### Frontend (`src/`)
| Layer | Key Files | Responsibility |
|-------|-----------|----------------|
| Entry | `main.js`, `App.vue` | Vue app bootstrap, Pinia + Router mount |
| Routing | `router/index.js` | `/` → EditorView, `/docs` → DocsView |
| Editor | `components/MilkdownEditor.vue` | Central control: Crepe editor, plugin registration, upload/export/OCR/TTS/AI toggle, 32 KB limit |
| Plugins (TypeScript) | `plugins/copilotPlugin.ts` — ghost text, request scheduling, cancel, language detection, hidden context injection |
| Plugins (TypeScript) | `plugins/docBlockPlugin.ts` — doc-block nodes and rendering |
| Plugins (TypeScript) | `plugins/mermaidPlugin.ts` — Mermaid diagram preview |
| Store | `stores/settings.js` | localStorage-persisted settings (theme, modelThinking, debounceMs, privacyMode, language, background*, ttsInstruct) |
| API | `utils/api.js` — fetchSuggestion, cancel completion, TTS requests; `config.js` — VITE_* env-based URL config |
| Utilities | `utils/convert.js`, `ocrCache.js`, `docBlock.js`, `i18n.js` |
### Backend (`backend/`)
| File | Responsibility |
|------|----------------|
| `main.py` | FastAPI app, CORS, API key auth, routes: `/v1/completions`, `/v1/ocr`, `/v1/convert`, `/v1/completions/cancel`. TTS routes lazily registered from `tts_asr.py`. |
| `llm.py` | Async Ollama calls (`call_ollama`, `stream_ollama`) and VLM OCR (`call_vlm_ocr`). Timeout control. |
| `prompt.py` | Prompt assembly: `build_completion_prompts`, `prepare_prompt_context`. Templates from `prompts/` directory. |
| `pro_completions.py` | Pro-tier completion endpoint (newer addition). |
| `tts_asr.py` | TTS text-to-speech. Late-registered routes via `_register_tts_asr_routes`. |
| `geoip.py` | Client IP location lookup for non-privacy-mode requests. |
### Request Flow: Completion
```
MilkdownEditor.vue → copilotPlugin.ts (debounce, abort, language detection)
→ utils/api.js (fetchSuggestion: generates request_id, AbortSignal, reads settings)
→ backend/main.py (/v1/completions: auth, prompt context, call_ollama via asyncio.Task)
→ backend/prompt.py (system + user prompt from prefix/suffix/context)
→ backend/llm.py (call_ollama to Ollama)
← JSON { content, request_id }
→ copilotPlugin.ts (insertGhostText into editor)
```
## Debugging Paths
| Issue | Trace Order |
|-------|-------------|
| Completion not firing | `MilkdownEditor.vue``copilotPlugin.ts` (check enabled, size limit, debounce) |
| Wrong completion result | `prompt.py``llm.py`. Check prompt context and language detection. |
| Cancel not working | `main.py` request_id lifecycle ↔ frontend `X-Request-Id` + cancel call |
| OCR empty result | `main.py` base64 decode → `llm.py call_vlm_ocr` |
| Document conversion dirty | `_sanitize_converted_markdown` in `main.py` |
## Naming Conventions (Mixed)
- Vue components/views: PascalCase (`MilkdownEditor.vue`)
- Frontend utils/config: lowercase `.js` (`api.js`, `config.js`)
- Plugin layer: TypeScript (`.ts`)
- Python backend: snake_case
Follow the style of each file. Do not reformat across directories for consistency. UI copy defaults to Chinese.
## Important Rules
- Do not modify `milkdown-docs/` (read-only reference).
- Code and tests override README.md when they conflict — the README is partially outdated.
- Plugin code (`copilotPlugin.ts`) is state-machine-style: small changes can break subtle interactions. Change one thing at a time and verify in-browser.
- No hardcoded secrets, empty catch/except blocks, `as any`, or `@ts-ignore` in new code.
- Subdirectory AGENTS.md files contain more detailed guidance: `./AGENTS.md` (root), `backend/AGENTS.md`, `src/AGENTS.md`, `src/plugins/AGENTS.md`. Read them when working in those areas.
+3 -3
View File
@@ -38,11 +38,11 @@
## 技术架构 ## 技术架构
前端: Vue3 + Vite + Milkdown + ProseMirror 前端: Vue3 + Vite + Milkdown + ProseMirror
后端: FastAPI + Python + Ollama 后端: FastAPI + PythonOpenAI 兼容端点)
## 快速开始 ## 快速开始
环境: Node.js 18+、Python 3.8+、Ollama 环境: Node.js 18+、Python 3.8+
安装: 安装:
- 前端: npm install - 前端: npm install
@@ -89,7 +89,7 @@
### 后端 ### 后端
- main.py: FastAPI服务器、SSE流式响应 - main.py: FastAPI服务器、SSE流式响应
- llm.py: 异步Ollama调用、超时控制 - llm.py: 异步LLM调用(OpenAI兼容)、超时控制
- prompt.py: 7条Prompt规则 - prompt.py: 7条Prompt规则
- tts_asr.py: macOS/Apple Silicon优化的TTS/ASR处理 - tts_asr.py: macOS/Apple Silicon优化的TTS/ASR处理
- 自动检测Apple Silicon (M1/M2/M3) - 自动检测Apple Silicon (M1/M2/M3)
+28 -3
View File
@@ -1,4 +1,29 @@
OPENAI_API_KEY=ollama # LLM provider (OpenAI-compatible endpoint)
OLLAMA_BASE_URL=http://192.168.0.120:11434/v1/ LLM_BASE_URL=http://localhost:11434/v1/
OLLAMA_MODEL=gpt-oss:20b # For Ollama, API key is not required but a placeholder is needed.
LLM_API_KEY=ollama
# Default model for inline completions (e.g., gpt-oss:20b, qwen3:8b)
LLM_MODEL=gpt-oss:20b
# Pro-tier model (defaults to LLM_MODEL if unset)
PRO_LLM_MODEL=gpt-oss:20b
# Vision model for OCR (e.g., qwen3-vl:30b, llava)
VLM_MODEL=qwen3-vl:30b VLM_MODEL=qwen3-vl:30b
# API key for the FastAPI app (change in production)
API_KEY=your-secret-key-here
# PRO completion timeout (seconds)
PRO_COMPLETION_TIMEOUT=1200
# Concurrency limits
STANDARD_CONCURRENCY_LIMIT=5
PRO_CONCURRENCY_LIMIT=20
# Legacy fallback: if LLM_BASE_URL is not set, OLLAMA_HOST will be auto-converted to /v1/ path
#OLLAMA_HOST=http://localhost:11434
# TTS/ASR settings (see README for full list)
TTS_ASR_DEVICE=auto
+398 -187
View File
@@ -2,64 +2,70 @@ import os
import time import time
import logging import logging
import asyncio import asyncio
import json
import base64
from datetime import datetime from datetime import datetime
from typing import AsyncIterator from typing import AsyncIterator, Literal
import ollama
import httpx
from dotenv import load_dotenv from dotenv import load_dotenv
from prompts import get_vlm_ocr_prompt from prompts import get_vlm_ocr_prompt
load_dotenv() load_dotenv()
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b') # OpenAI-compatible endpoint config
PRO_OLLAMA_MODEL = os.getenv('PRO_OLLAMA_MODEL', OLLAMA_MODEL) LLM_BASE_URL = os.getenv('LLM_BASE_URL', 'http://localhost:11434/v1/')
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434') LLM_API_KEY = os.getenv('LLM_API_KEY', 'ollama')
# Model names (backward compat: fall back to OLLAMA_MODEL if LLM_MODEL not set)
_raw_model = os.getenv('LLM_MODEL') or os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
LLM_MODEL = _raw_model.strip() if _raw_model else 'gpt-oss:20b'
PRO_LLM_MODEL = os.getenv('PRO_LLM_MODEL', LLM_MODEL)
# VLM for OCR (vision models)
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b') VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
# Fallback for legacy OLLAMA_HOST env var (auto-convert to /v1/ path)
_legacy_host = os.getenv('OLLAMA_HOST')
if _legacy_host and not os.getenv('LLM_BASE_URL'):
base = _legacy_host.rstrip('/')
if '/v1' not in base:
LLM_BASE_URL = f"{base}/v1/"
# Normalize trailing slash for base URL
LLM_BASE_URL = LLM_BASE_URL.rstrip('/') + '/'
# Timeouts in seconds (10 minutes for large model loading) # Timeouts in seconds (10 minutes for large model loading)
COMPLETION_TIMEOUT = 600 COMPLETION_TIMEOUT = int(os.getenv("LLM_COMPLETION_TIMEOUT", "600"))
OCR_TIMEOUT = 600 OCR_TIMEOUT = int(os.getenv("LLM_OCR_TIMEOUT", "600"))
client = ollama.AsyncClient(host=OLLAMA_HOST) logger = logging.getLogger('llm')
logger = logging.getLogger("llm")
def _extract_message(response) -> tuple[str, str]: def _extract_message(response: dict) -> tuple[str, str]:
content = "" """Extract content and thinking from an OpenAI-compatible response dict."""
thinking = "" choices = response.get('choices', []) if isinstance(response, dict) else []
msg = (choices[0].get('message', {}) if choices and isinstance(choices, list) else {}).copy()
if hasattr(response, 'message') and response.message: content = msg.get('content', '') or ''
content = response.message.content or "" thinking = (msg.get('reasoning_content') or msg.get('thinking', '') or '').strip()
thinking = getattr(response.message, 'thinking', '') or ""
elif isinstance(response, dict) and 'message' in response:
msg = response.get('message', {})
content = msg.get('content', '') or ""
thinking = msg.get('thinking', '') or ""
# fallback for generate
if not content:
if hasattr(response, 'response'):
content = getattr(response, 'response', '') or ""
elif isinstance(response, dict) and 'response' in response:
content = response.get('response', '') or ""
return content, thinking return content, thinking
def _build_prompt(prompt: str, system_prompt: str | None = None) -> str: def _resolve_system_prompt(system_prompt: str | None) -> str:
if system_prompt and system_prompt.strip(): if system_prompt and system_prompt.strip():
return f"{system_prompt}\n\n{prompt}" return system_prompt.strip()
return prompt return ''
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str: def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
candidate = (model or '').strip() candidate = (model or '').strip()
if candidate: if candidate:
return candidate return candidate
return PRO_OLLAMA_MODEL if use_pro_model else OLLAMA_MODEL return PRO_LLM_MODEL if use_pro_model else LLM_MODEL
def _build_generate_kwargs( def _build_chat_payload(
prompt: str, prompt: str,
*, *,
system_prompt: str | None = None, system_prompt: str | None = None,
@@ -67,270 +73,475 @@ def _build_generate_kwargs(
thinking: str | None = None, thinking: str | None = None,
model: str | None = None, model: str | None = None,
use_pro_model: bool = False, use_pro_model: bool = False,
stream: bool = False,
) -> dict: ) -> dict:
kwargs = { messages = []
"model": _resolve_model_name(model, use_pro_model=use_pro_model), sys_prompt = _resolve_system_prompt(system_prompt)
"prompt": _build_prompt(prompt, system_prompt), if sys_prompt:
"stream": stream, messages.append({'role': 'system', 'content': sys_prompt})
"raw": True, messages.append({'role': 'user', 'content': prompt})
"options": {
'temperature': temperature, payload = {
'repeat_penalty': 1.1, 'model': _resolve_model_name(model, use_pro_model=use_pro_model),
}, 'messages': messages,
'stream': False,
} }
options = {'temperature': temperature}
if thinking: if thinking:
kwargs["think"] = thinking payload['options'] = {'temperature': temperature, 'think': thinking}
return kwargs
return payload
def _extract_stream_text(chunk) -> str: def _build_chat_stream_payload(
content, _ = _extract_message(chunk) prompt: str,
if content: *,
return content system_prompt: str | None = None,
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
) -> dict:
messages = []
sys_prompt = _resolve_system_prompt(system_prompt)
if sys_prompt:
messages.append({'role': 'system', 'content': sys_prompt})
messages.append({'role': 'user', 'content': prompt})
if isinstance(chunk, dict): payload = {
return chunk.get('response', '') or '' 'model': _resolve_model_name(model, use_pro_model=use_pro_model),
'messages': messages,
'stream': True,
}
if hasattr(chunk, 'response'): options = {'temperature': temperature}
return getattr(chunk, 'response', '') or '' if thinking:
payload['options'] = {'temperature': temperature, 'think': thinking}
return '' return payload
def _extract_delta_text(chunk: dict) -> str:
"""Extract text delta from an OpenAI-compatible SSE chunk."""
choices = chunk.get('choices', []) if isinstance(chunk, dict) else []
delta = (choices[0].get('delta', {}) if choices and isinstance(choices, list) else {}).copy()
content = delta.get('content', '') or ''
return content
def _extract_delta_thinking(chunk: dict) -> str:
"""Extract thinking/reasoning delta from an SSE chunk."""
choices = chunk.get('choices', []) if isinstance(chunk, dict) else []
delta = (choices[0].get('delta', {}) if choices and isinstance(choices, list) else {}).copy()
return (delta.get('reasoning_content') or delta.get('thinking', '') or '').strip()
async def call_ollama( async def call_ollama(
prompt: str, prompt: str,
*, *,
system_prompt: str | None = None, system_prompt: str | None = None,
tag: str = "default", tag: str = 'default',
temperature: float = 0.7, temperature: float = 0.7,
thinking: str | None = None, thinking: str | None = None,
model: str | None = None, model: str | None = None,
use_pro_model: bool = False, use_pro_model: bool = False,
) -> dict: ) -> dict:
""" """Call OpenAI-compatible chat completions (non-streaming) and return content/thinking."""
调用 Ollama API 并返回 content 和 thinking。
"""
start = time.perf_counter() start = time.perf_counter()
start_dt = datetime.now() start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model) model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
logger.info( logger.info(
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s", '[LLM][%s] request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
tag, tag, log_model_name, LLM_BASE_URL, len(prompt),
model_name, len(system_prompt or ''), temperature, thinking,
OLLAMA_HOST,
len(prompt),
len(system_prompt or ""),
temperature,
thinking,
) )
try: payload = _build_chat_payload(
kwargs = _build_generate_kwargs( prompt=prompt, system_prompt=system_prompt, temperature=temperature,
prompt, thinking=thinking, model=model, use_pro_model=use_pro_model,
system_prompt=system_prompt, )
temperature=temperature,
thinking=thinking, http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
model=model,
use_pro_model=use_pro_model, try:
stream=False, async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
) resp = await asyncio.wait_for(
client.post('/chat/completions', json=payload), timeout=COMPLETION_TIMEOUT,
)
resp.raise_for_status()
response = resp.json()
response = await asyncio.wait_for(client.generate(**kwargs), timeout=COMPLETION_TIMEOUT)
except asyncio.CancelledError: except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
logger.info( logger.info(
"[LLM][%s] call_time [%s --> %s]", '[LLM][%s] call_time [%s --> %s]', tag,
tag, start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
) )
logger.warning("[LLM][%s] request cancelled after %.1fms", tag, elapsed_ms)
logger.warning('[LLM][%s] request cancelled after %.1fms', tag, elapsed_ms)
raise raise
except Exception: except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
logger.info( logger.info(
"[LLM][%s] call_time [%s --> %s]", '[LLM][%s] call_time [%s --> %s]', tag,
tag, start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
) )
logger.exception("[LLM][%s] request failed after %.1fms", tag, elapsed_ms)
logger.exception('[LLM][%s] request failed after %.1fms', tag, elapsed_ms)
raise raise
content, thinking = _extract_message(response) content, thinking_out = _extract_message(response)
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
logger.info( logger.info(
"[LLM][%s] call_time [%s --> %s]", '[LLM][%s] call_time [%s --> %s]', tag,
tag, start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
) )
logger.info( logger.info(
"[LLM][%s] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d", '[LLM][%s] response in %.1fms content_chars=%d thinking_chars=%d',
tag, tag, elapsed_ms, len(content), len(thinking_out or ''),
elapsed_ms,
type(response).__name__,
len(content),
len(thinking),
) )
if not content.strip(): if not content.strip():
logger.warning("[LLM][%s] empty content returned by model", tag) logger.warning('[LLM][%s] empty content returned by model', tag)
return {"content": content, "think": thinking} return {'content': content, 'think': thinking_out or ''}
async def stream_ollama( async def stream_ollama(
prompt: str, prompt: str,
*, *,
system_prompt: str | None = None, system_prompt: str | None = None,
tag: str = "default-stream", tag: str = 'default-stream',
temperature: float = 0.7, temperature: float = 0.7,
thinking: str | None = None, thinking: str | None = None,
model: str | None = None, model: str | None = None,
use_pro_model: bool = False, use_pro_model: bool = False,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Stream text deltas from OpenAI-compatible chat completions."""
start = time.perf_counter() start = time.perf_counter()
start_dt = datetime.now() start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model) model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
yielded_chars = 0 yielded_chars = 0
logger.info( logger.info(
"[LLM][%s] stream request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s", '[LLM][%s] stream request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
tag, tag, log_model_name, LLM_BASE_URL, len(prompt),
model_name, len(system_prompt or ''), temperature, thinking,
OLLAMA_HOST,
len(prompt),
len(system_prompt or ""),
temperature,
thinking,
) )
payload = _build_chat_stream_payload(
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
thinking=thinking, model=model, use_pro_model=use_pro_model,
)
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
try: try:
kwargs = _build_generate_kwargs( async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
prompt,
system_prompt=system_prompt,
temperature=temperature,
thinking=thinking,
model=model,
use_pro_model=use_pro_model,
stream=True,
)
stream = await client.generate(**kwargs)
iterator = stream.__aiter__()
deadline = time.perf_counter() + COMPLETION_TIMEOUT
while True:
remaining = deadline - time.perf_counter()
if remaining <= 0:
raise TimeoutError("LLM stream timed out")
try: try:
chunk = await asyncio.wait_for(iterator.__anext__(), timeout=remaining) async with client.stream('POST', '/chat/completions', json=payload) as response:
except StopAsyncIteration: response.raise_for_status()
break
text = _extract_stream_text(chunk) deadline = time.perf_counter() + COMPLETION_TIMEOUT
if not text: line_iterator = response.aiter_lines().__aiter__()
continue
while True:
remaining = deadline - time.perf_counter()
if remaining <= 0:
raise TimeoutError('LLM stream timed out')
try:
line = await asyncio.wait_for(line_iterator.__anext__(), timeout=remaining)
except StopAsyncIteration:
break
if not line or line.startswith(':'):
continue
# SSE data lines: "data: {json}" or "data: [DONE]"
if line.startswith('data: '):
data_str = line[6:] # strip "data: " prefix
else:
data_str = line.strip()
if not data_str or data_str == '[DONE]':
continue
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
logger.warning('[LLM][%s] ignored invalid stream line', tag)
continue
if not isinstance(chunk, dict):
continue
text = _extract_delta_text(chunk)
if not text:
continue
yielded_chars += len(text)
yield text
except asyncio.CancelledError:
if response is not None:
await response.aclose()
raise
yielded_chars += len(text)
yield text
except asyncio.CancelledError: except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
logger.info( logger.info(
"[LLM][%s] stream_time [%s --> %s]", '[LLM][%s] stream_time [%s --> %s]', tag,
tag, start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
) )
logger.warning("[LLM][%s] stream cancelled after %.1fms", tag, elapsed_ms)
logger.warning('[LLM][%s] stream cancelled after %.1fms', tag, elapsed_ms)
raise raise
except Exception: except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
logger.info( logger.info(
"[LLM][%s] stream_time [%s --> %s]", '[LLM][%s] stream_time [%s --> %s]', tag,
tag, start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
) )
logger.exception("[LLM][%s] stream failed after %.1fms", tag, elapsed_ms)
logger.exception('[LLM][%s] stream failed after %.1fms', tag, elapsed_ms)
raise raise
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
logger.info( logger.info(
"[LLM][%s] stream_time [%s --> %s]", '[LLM][%s] stream_time [%s --> %s]', tag,
tag, start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.info(
"[LLM][%s] stream finished in %.1fms yielded_chars=%d",
tag,
elapsed_ms,
yielded_chars,
) )
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str: logger.info(
'[LLM][%s] stream finished in %.1fms yielded_chars=%d',
tag, elapsed_ms, yielded_chars,
)
async def stream_ollama_events(
prompt: str,
*,
system_prompt: str | None = None,
tag: str = 'default-events',
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
enable_thinking: bool = True,
timeout: float | None = None,
) -> AsyncIterator[tuple[Literal['thinking', 'content'], str]]:
"""Stream (event_type, payload) tuples from OpenAI-compatible chat completions."""
start = time.perf_counter() start = time.perf_counter()
start_dt = datetime.now() start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
yielded_chars = 0
logger.info( logger.info(
"[VLM][ocr] request model=%s host=%s image_bytes=%d language=%s", '[LLM][%s] event_stream request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
VLM_MODEL, tag, log_model_name, LLM_BASE_URL, len(prompt),
OLLAMA_HOST, len(system_prompt or ''), temperature, thinking,
len(image_bytes),
language,
) )
payload = _build_chat_stream_payload(
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
thinking=thinking if enable_thinking else None, model=model, use_pro_model=use_pro_model,
)
effective_timeout = timeout if timeout is not None else COMPLETION_TIMEOUT
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
sent_thinking = False
try: try:
response = await asyncio.wait_for( async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
client.chat( try:
model=VLM_MODEL, async with client.stream('POST', '/chat/completions', json=payload) as response:
messages=[{ response.raise_for_status()
'role': 'user',
'content': get_vlm_ocr_prompt(), deadline = time.perf_counter() + effective_timeout
'images': [image_bytes] line_iterator = response.aiter_lines().__aiter__()
}],
stream=False, while True:
options={'temperature': 0.3} remaining = deadline - time.perf_counter()
), if remaining <= 0:
timeout=OCR_TIMEOUT raise TimeoutError('LLM event stream timed out')
try:
line = await asyncio.wait_for(line_iterator.__anext__(), timeout=remaining)
except StopAsyncIteration:
break
if not line or line.startswith(':'):
continue
# SSE data lines: "data: {json}" or "data: [DONE]"
if line.startswith('data: '):
data_str = line[6:] # strip "data: " prefix
else:
data_str = line.strip()
if not data_str or data_str == '[DONE]':
continue
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
logger.warning('[LLM][%s] ignored invalid Ollama stream line', tag)
continue
if not isinstance(chunk, dict):
continue
error = chunk.get('error')
if error:
raise RuntimeError(str(error))
thinking_delta = _extract_delta_thinking(chunk)
if thinking_delta and not sent_thinking:
sent_thinking = True
yield 'thinking', ''
text = _extract_delta_text(chunk)
if not text:
continue
yielded_chars += len(text)
yield 'content', text
except asyncio.CancelledError:
if response is not None:
await response.aclose()
raise
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[LLM][%s] event_stream_time [%s --> %s]', tag,
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
) )
logger.warning('[LLM][%s] event stream cancelled after %.1fms', tag, elapsed_ms)
raise
except Exception: except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
logger.info( logger.info(
"[VLM][ocr] call_time [%s --> %s]", '[LLM][%s] event_stream_time [%s --> %s]', tag,
start_dt.strftime("%H:%M:%S"), start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
end_dt.strftime("%H:%M:%S"),
) )
logger.exception("[VLM][ocr] request failed after %.1fms", elapsed_ms)
logger.exception('[LLM][%s] event stream failed after %.1fms', tag, elapsed_ms)
raise raise
content, thinking = _extract_message(response)
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
logger.info( logger.info(
"[VLM][ocr] call_time [%s --> %s]", '[LLM][%s] event_stream_time [%s --> %s]', tag,
start_dt.strftime("%H:%M:%S"), start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
end_dt.strftime("%H:%M:%S"),
) )
logger.info( logger.info(
"[VLM][ocr] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d", '[LLM][%s] event stream finished in %.1fms yielded_chars=%d thinking_seen=%s',
elapsed_ms, tag, elapsed_ms, yielded_chars, sent_thinking,
type(response).__name__, )
len(content),
len(thinking),
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
"""OCR via VLM using OpenAI-compatible vision API (image_url content part)."""
start = time.perf_counter()
start_dt = datetime.now()
logger.info(
'[VLM][ocr] request model=%s base_url=%s image_bytes=%d language=%s',
VLM_MODEL, LLM_BASE_URL, len(image_bytes), language,
)
image_b64 = base64.b64encode(image_bytes).decode('ascii')
payload = {
'model': VLM_MODEL,
'messages': [{
'role': 'user',
'content': [
{'type': 'text', 'text': get_vlm_ocr_prompt()},
{
'type': 'image_url',
'image_url': {'url': f'data:image/png;base64,{image_b64}'},
},
],
}],
'stream': False,
}
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
resp = await asyncio.wait_for(
client.post('/chat/completions', json=payload), timeout=OCR_TIMEOUT,
)
resp.raise_for_status()
response = resp.json()
except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[VLM][ocr] call_time [%s --> %s]', start_dt.strftime('%H:%M:%S'),
end_dt.strftime('%H:%M:%S'),
)
logger.exception('[VLM][ocr] request failed after %.1fms', elapsed_ms)
raise
content, _ = _extract_message(response)
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[VLM][ocr] call_time [%s --> %s]', start_dt.strftime('%H:%M:%S'),
end_dt.strftime('%H:%M:%S'),
)
logger.info(
'[VLM][ocr] response in %.1fms content_chars=%d', elapsed_ms, len(content),
) )
if not content.strip(): if not content.strip():
logger.warning("[VLM][ocr] empty content returned by model") logger.warning('[VLM][ocr] empty content returned by model')
return content return content
+3 -10
View File
@@ -1,4 +1,4 @@
import asyncio import asyncio
import base64 import base64
import json import json
import logging import logging
@@ -39,15 +39,8 @@ def _get_markitdown(): # pragma: no cover
app = FastAPI() app = FastAPI()
@app.on_event("startup") # pragma: no cover # Startup event disabled — TTS model loads lazily on first request
async def startup_event(): # to avoid blocking startup and OOM crashes.
logger.info("Starting blocking preload for TTS and ASR models...")
try:
from tts_asr import _warmup_all
await _warmup_all()
except Exception as e:
logger.warning(f"Failed to initiate model warmup: {e}")
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {} ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock() ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
+389
View File
@@ -0,0 +1,389 @@
import asyncio
import contextlib
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from geoip import get_ip_location_text
from llm import stream_ollama_events
from models import UserPreferences
logger = logging.getLogger("api.pro")
PRO_COMPLETION_TIMEOUT = float(os.getenv("PRO_COMPLETION_TIMEOUT", "3600"))
PRO_QUEUE_TIMEOUT = float(os.getenv("PRO_QUEUE_TIMEOUT", "600"))
PRO_MAX_CONCURRENCY = max(1, int(os.getenv("PRO_MAX_CONCURRENCY", "1")))
PRO_QUEUE_MAX_SIZE = max(0, int(os.getenv("PRO_QUEUE_MAX_SIZE", "5")))
PRO_STATUS_RETENTION_SECONDS = float(os.getenv("PRO_STATUS_RETENTION_SECONDS", "600"))
PRO_CANCEL_ACK_TIMEOUT = 5.0
PUBLIC_PRO_ERROR = "PRO generation failed. Please retry or adjust the instruction."
class ProCompletionRequest(BaseModel):
prefix: str
suffix: str
languageId: str = "markdown"
instruction: str = ""
pro_thinking: str = "medium"
privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None
class ProCancelRequest(BaseModel):
request_id: str
reason: str = "abort"
@dataclass
class ProRequestState:
request_id: str
status: str = "queued"
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
error: str = ""
task: asyncio.Task | None = None
cancel_requested: bool = False
done_event: asyncio.Event = field(default_factory=asyncio.Event)
def touch(self, status: str | None = None, error: str = "") -> None:
if status:
self.status = status
if error:
self.error = error
self.updated_at = time.time()
def request_cancel(self) -> None:
self.cancel_requested = True
self.touch("cancelled")
PRO_STATES: dict[str, ProRequestState] = {}
PRO_STATES_LOCK = asyncio.Lock()
PRO_SEMAPHORE = asyncio.Semaphore(PRO_MAX_CONCURRENCY)
def _iso_timestamp(value: float) -> str:
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
def _clamp_thinking(value: str | None) -> str | None:
normalized = (value or "medium").strip().lower()
if normalized in {"none", "off", "false"}:
return None
if normalized in {"low", "medium", "high"}:
return normalized
return "medium"
def _queued_states() -> list[ProRequestState]:
return [state for state in PRO_STATES.values() if state.status == "queued"]
def _queue_position(request_id: str) -> int | None:
queued = sorted(_queued_states(), key=lambda item: item.created_at)
for index, state in enumerate(queued, start=1):
if state.request_id == request_id:
return index
return None
async def _cleanup_states() -> None:
now = time.time()
expired = [
request_id
for request_id, state in PRO_STATES.items()
if state.status in {"done", "error", "cancelled"}
and now - state.updated_at > PRO_STATUS_RETENTION_SECONDS
]
for request_id in expired:
PRO_STATES.pop(request_id, None)
def _state_payload(state: ProRequestState) -> dict:
return {
"request_id": state.request_id,
"status": state.status,
"queue_position": _queue_position(state.request_id),
"created_at": _iso_timestamp(state.created_at),
"updated_at": _iso_timestamp(state.updated_at),
"error": state.error,
}
def _build_pro_prompts(
*,
prefix: str,
suffix: str,
language_id: str,
instruction: str,
location: str = "",
preferences: UserPreferences | None = None,
) -> tuple[str, str]:
safe_language = (language_id or "markdown").strip() or "markdown"
safe_instruction = (instruction or "").strip()
preference_lines: list[str] = []
if preferences:
if preferences.language and preferences.language != "auto":
preference_lines.append(f"- Preferred language: {preferences.language}")
if preferences.currency and preferences.currency != "auto":
preference_lines.append(f"- Preferred currency: {preferences.currency}")
if preferences.timezone and preferences.timezone != "auto":
preference_lines.append(f"- Timezone: {preferences.timezone}")
if location:
preference_lines.append(f"- Location hint: {location}")
system_prompt = f"""You edit Markdown documents.
Return only the Markdown text to insert at the cursor.
Do not explain, analyze, label the answer, or wrap the whole answer in a code fence.
Match the document language, style, and Markdown structure.
Language: {safe_language}."""
preferences_text = "\n".join(preference_lines) if preference_lines else "- none"
instruction_text = safe_instruction or "Continue the Markdown naturally."
user_prompt = f"""Instruction:
{instruction_text}
User preferences:
{preferences_text}
Markdown before cursor:
{prefix}
Markdown after cursor:
{suffix}
Write only the Markdown that belongs at the cursor."""
return system_prompt.strip(), user_prompt.strip()
def _get_client_ip(request: Request) -> str:
if request.client:
return request.headers.get("X-Client-IP") or request.client.host
return request.headers.get("X-Client-IP") or "unknown"
async def _send_sse_event(queue: asyncio.Queue, event_name: str, data: dict) -> None:
await queue.put((event_name, json.dumps(data, ensure_ascii=False)))
async def _wait_for_cancel_cleanup(state: ProRequestState, request_tag: str, reason: str) -> None:
if state.done_event.is_set():
return
try:
await asyncio.wait_for(state.done_event.wait(), timeout=PRO_CANCEL_ACK_TIMEOUT)
except asyncio.TimeoutError:
logger.warning(
"[%s] /v1/pro/completions cancel cleanup not confirmed request_id=%s reason=%s",
request_tag,
state.request_id,
reason,
)
def register_pro_completion_routes(app: FastAPI, get_api_key):
@app.post("/v1/pro/completions")
async def create_pro_completion(
request: Request,
req: ProCompletionRequest,
api_key: str = Security(get_api_key),
):
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request_tag = request_id[:8]
event_queue: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
previous_state: ProRequestState | None = None
async with PRO_STATES_LOCK:
await _cleanup_states()
queued_count = len(_queued_states())
if queued_count >= PRO_QUEUE_MAX_SIZE:
logger.info("[%s] /v1/pro/completions rejected queue_full request_id=%s", request_tag, request_id)
return JSONResponse(
content={"error": "PRO queue is full", "request_id": request_id},
status_code=429,
)
existing = PRO_STATES.get(request_id)
if existing and existing.task and not existing.task.done():
existing.request_cancel()
existing.task.cancel()
previous_state = existing
state = ProRequestState(request_id=request_id)
PRO_STATES[request_id] = state
if previous_state:
await _wait_for_cancel_cleanup(previous_state, request_tag, "replace")
client_ip = "hidden"
location = ""
if not req.privacy_mode: # pragma: no cover
client_ip = _get_client_ip(request)
location = get_ip_location_text(client_ip)
prefix = req.prefix or ""
suffix = req.suffix or ""
system_prompt, user_prompt = _build_pro_prompts(
prefix=prefix,
suffix=suffix,
language_id=req.languageId,
instruction=req.instruction,
location=location,
preferences=req.user_preferences,
)
logger.info(
"[%s] /v1/pro/completions request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d instruction_chars=%d lang=%s thinking=%s",
request_tag,
request_id,
client_ip,
len(prefix),
len(suffix),
len(req.instruction or ""),
req.languageId,
req.pro_thinking,
)
async def producer() -> None:
acquired = False
chunks: list[str] = []
try:
async with PRO_STATES_LOCK:
if state.cancel_requested:
raise asyncio.CancelledError()
state.touch("queued")
queue_position = _queue_position(request_id)
await _send_sse_event(event_queue, "queued", {"request_id": request_id, "queue_position": queue_position})
await asyncio.wait_for(PRO_SEMAPHORE.acquire(), timeout=PRO_QUEUE_TIMEOUT)
acquired = True
async with PRO_STATES_LOCK:
if state.cancel_requested:
raise asyncio.CancelledError()
state.touch("started")
await _send_sse_event(event_queue, "started", {"request_id": request_id})
async for event_type, payload in stream_ollama_events(
user_prompt,
system_prompt=system_prompt,
tag=f"{request_tag}-pro",
temperature=0.7,
thinking=_clamp_thinking(req.pro_thinking),
use_pro_model=True,
enable_thinking=True,
timeout=PRO_COMPLETION_TIMEOUT,
):
if event_type == "thinking":
await _send_sse_event(event_queue, "thinking", {"request_id": request_id})
continue
if not payload:
continue
chunks.append(payload)
await _send_sse_event(event_queue, "chunk", {"delta": payload, "request_id": request_id})
content = "".join(chunks)
async with PRO_STATES_LOCK:
if state.cancel_requested:
raise asyncio.CancelledError()
if not content:
raise ValueError("PRO returned empty content")
async with PRO_STATES_LOCK:
state.touch("done")
logger.info("[%s] /v1/pro/completions done request_id=%s content_chars=%d", request_tag, request_id, len(content))
await _send_sse_event(event_queue, "done", {"content": content, "request_id": request_id})
except asyncio.CancelledError:
async with PRO_STATES_LOCK:
state.request_cancel()
logger.info("[%s] /v1/pro/completions cancelled request_id=%s", request_tag, request_id)
await _send_sse_event(event_queue, "cancelled", {"cancelled": True, "request_id": request_id})
raise
except Exception as exc:
async with PRO_STATES_LOCK:
state.touch("error", PUBLIC_PRO_ERROR)
logger.exception("[%s] /v1/pro/completions failed request_id=%s", request_tag, request_id)
await _send_sse_event(event_queue, "error", {"error": PUBLIC_PRO_ERROR, "request_id": request_id})
finally:
if acquired:
PRO_SEMAPHORE.release()
state.done_event.set()
await event_queue.put(None)
producer_task = asyncio.create_task(producer())
async with PRO_STATES_LOCK:
state.task = producer_task
async def event_stream():
try:
while True:
item = await event_queue.get()
if item is None:
break
event_name, data = item
yield f"event: {event_name}\ndata: {data}\n\n"
except asyncio.CancelledError:
async with PRO_STATES_LOCK:
state.request_cancel()
producer_task.cancel()
raise
finally:
if not producer_task.done() and not state.done_event.is_set():
async with PRO_STATES_LOCK:
state.request_cancel()
producer_task.cancel()
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(state.done_event.wait(), timeout=PRO_CANCEL_ACK_TIMEOUT)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@app.post("/v1/pro/completions/cancel")
async def cancel_pro_completion(req: ProCancelRequest, api_key: str = Security(get_api_key)):
request_id = req.request_id or ""
request_tag = request_id[:8]
state_to_wait: ProRequestState | None = None
async with PRO_STATES_LOCK:
await _cleanup_states()
state = PRO_STATES.get(request_id)
if not state:
return {"cancelled": False, "status": "not_found"}
if state.task and not state.task.done():
state.request_cancel()
state.task.cancel()
state_to_wait = state
if state.status in {"done", "error", "cancelled"}:
if not state_to_wait:
return {"cancelled": False, "status": state.status}
else:
state.request_cancel()
if state_to_wait:
await _wait_for_cancel_cleanup(state_to_wait, request_tag, req.reason)
return {"cancelled": True, "status": "ok"}
@app.get("/v1/pro/completions/status/{request_id}")
async def get_pro_completion_status(request_id: str, api_key: str = Security(get_api_key)):
async with PRO_STATES_LOCK:
await _cleanup_states()
state = PRO_STATES.get(request_id)
if not state:
raise HTTPException(status_code=404, detail="PRO request not found")
return _state_payload(state)
+56 -21
View File
@@ -65,6 +65,53 @@ def _prepare_context(prefix: str, suffix: str) -> Tuple[str, str]:
return clean_prefix, clean_suffix return clean_prefix, clean_suffix
def _strip_hidden_tail_context(text: str) -> str:
"""
Return the likely visible tail segment used for prefill.
The frontend prepends hidden OCR/doc context before the visible markdown and
joins those blocks with blank lines. For prefill we only want the active
visible segment near the cursor, not earlier hidden context.
"""
value = _normalize_newlines(text or "")
if not value:
return ""
tail = re.split(r"\n{2,}", value)[-1]
tail = re.sub(r"<!--[\s\S]*?-->", "", tail)
tail = re.sub(r"<OCR:[^>\n]*>", "", tail)
return tail.split("\n")[-1]
def _build_completion_prefill(prefix: str) -> str:
"""
Build a short tail prefill after <|fim_middle|> so completion models keep
writing from the existing text instead of explaining the boundary rules.
"""
normalized = _normalize_newlines(prefix or "")
if not normalized or normalized[-1].isspace():
return ""
tail = _strip_hidden_tail_context(normalized).strip()
if len(tail) < 2:
return ""
cjk_match = re.search(r"[\u3400-\u9fff]{2,6}$", tail)
if cjk_match:
value = cjk_match.group(0)
return value[-2:] if len(value) > 2 else value
token_match = re.search(r"[A-Za-z0-9_+\-.]{2,12}$", tail)
if token_match:
value = token_match.group(0)
return value[-12:]
compact_match = re.search(r"\S{2,12}$", tail)
if compact_match:
return compact_match.group(0)[-12:]
return ""
FENCE_LINE_RE = re.compile(r"^[ \t]*```.*$") FENCE_LINE_RE = re.compile(r"^[ \t]*```.*$")
FENCE_INFO_RE = re.compile(r"^[ \t]*```[ \t]*(.*)$") FENCE_INFO_RE = re.compile(r"^[ \t]*```[ \t]*(.*)$")
MERMAID_CONTEXT_RE = re.compile( MERMAID_CONTEXT_RE = re.compile(
@@ -256,7 +303,7 @@ def build_completion_prompts(
location: str = "", location: str = "",
thinking_level: str = "low", thinking_level: str = "low",
preferences: UserPreferences | None = None, preferences: UserPreferences | None = None,
) -> Tuple[str, str]: ) -> Tuple[str, str, str]:
safe_language_id = _canonical_language_id(language_id) safe_language_id = _canonical_language_id(language_id)
recent_prefix, recent_suffix = _prepare_context(prefix, suffix) recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
recent_prefix = _normalize_newlines(recent_prefix) recent_prefix = _normalize_newlines(recent_prefix)
@@ -269,6 +316,7 @@ def build_completion_prompts(
) )
prefix_ends_with_newline = recent_prefix.endswith("\n") prefix_ends_with_newline = recent_prefix.endswith("\n")
suffix_starts_with_newline = recent_suffix.startswith("\n") suffix_starts_with_newline = recent_suffix.startswith("\n")
prefill = _build_completion_prefill(recent_prefix)
tz_pref = preferences.timezone if preferences else "auto" tz_pref = preferences.timezone if preferences else "auto"
current_time = _get_current_datetime(tz_pref) current_time = _get_current_datetime(tz_pref)
@@ -303,38 +351,25 @@ Requirements:
- Concise unless structure needs more - Concise unless structure needs more
- Follows markdown rules in system prompt - Follows markdown rules in system prompt
- Use real line breaks instead of spelled-out escape sequences unless PREFIX or SUFFIX clearly requires that text - Use real line breaks instead of spelled-out escape sequences unless PREFIX or SUFFIX clearly requires that text
- If a boundary needs separation, put the real newline directly in OUTPUT
=== BOUNDARY DECISION GUIDE === - Do not explain newline or boundary choices
- Continue after the PREFILL text already placed after <|fim_middle|>
Step 1: Check PREFIX_ENDS_WITH_NEWLINE
If false, ask: "Does output need to start on a new line?"
- YES if PREFIX ends with: ":", "steps:", "items:", heading text, or complete sentence before heading
- If YES: make the first character of OUTPUT a real newline
Step 2: Check SUFFIX_STARTS_WITH_NEWLINE
If false, ask: "Does output need to end with a newline?"
- YES if SUFFIX starts with: heading (##), new paragraph, or list marker
- If YES: make the last character of OUTPUT a real newline
Step 3: Choose newline type
- Use a blank line for: new paragraphs, before headings, starting lists
- Use a single line break for: continuing within blocks, list items, table cells
- Exception: inside code fences, use real newline characters freely
=== CONTEXT NOTES === === CONTEXT NOTES ===
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output - OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
- Match PREFIX tone, style, and indentation - Match PREFIX tone, style, and indentation
- Do not repeat text from SUFFIX beginning - Do not repeat text from SUFFIX beginning
- <|fim_prefix|>, <|fim_suffix|>, <|fim_middle|>, and PREFILL are control context only; never output these markers
=== EXAMPLES BY CATEGORY === === EXAMPLES BY CATEGORY ===
{_INLINE_EXAMPLES} {_INLINE_EXAMPLES}
=== NOW COMPLETE THE TASK === === NOW COMPLETE THE TASK ===
<|fim_prefix|>{recent_prefix}<|fim_suffix|>{recent_suffix}<|fim_middle|>""" <|fim_prefix|>{recent_prefix}<|fim_suffix|>{recent_suffix}<|fim_middle|>{prefill}"""
system_prompt = build_inline_system_prompt(safe_language_id) system_prompt = build_inline_system_prompt(safe_language_id)
return system_prompt.strip(), user_prompt.strip() return system_prompt.strip(), user_prompt.strip(), prefill
def build_prompt( def build_prompt(
@@ -348,7 +383,7 @@ def build_prompt(
""" """
Backward-compatible helper. Returns only the user prompt body. Backward-compatible helper. Returns only the user prompt body.
""" """
_, user_prompt = build_completion_prompts( _, user_prompt, _ = build_completion_prompts(
prefix=prefix, prefix=prefix,
suffix=suffix, suffix=suffix,
language_id=language_id, language_id=language_id,
+1 -1
View File
@@ -1,3 +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- Separate code lines with actual newline characters\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- Use actual line breaks in output, not spelled-out escape sequences, unless the surrounding content explicitly needs that text\n- A single line break usually continues the current block\n- A blank line starts a new paragraph or block\n- Use blank lines for: new paragraphs, before headings, starting lists/tables\n- Use single line breaks for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use actual newline characters 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 on a new line\n\n- If SUFFIX lacks needed newline: end OUTPUT with a trailing line break\n\n- Common cases requiring a leading line break:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring a trailing line break:\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" "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, no analysis.\n\nNever output internal reasoning, chain-of-thought, boundary checks, or deliberation. Never output chat/template artifacts such as assistant, final, channel, <|start|>, <|end|>, <|fim_prefix|>, <|fim_suffix|>, or <|fim_middle|>.\n\nCONTEXT FLAGS:\n- CURSOR_IN_FENCED_CODE_BLOCK tells whether the cursor is inside a code fence.\n- CURSOR_FENCE_LANGUAGE gives the active fence language, or none.\n- PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE describe the insertion boundary.\n- MERMAID_CONTEXT tells whether Mermaid syntax is likely expected.\n\nSPECIALIZED RULES:\n- If CURSOR_IN_FENCED_CODE_BLOCK=true: output only code lines, no triple backticks.\n- If CURSOR_IN_FENCED_CODE_BLOCK=false and a code block is needed: use a fenced block with a language tag, e.g. ```{language}.\n- Inline math must use $...$; block math must use $$...$$.\n- Inside latex/tex/katex fences, output raw LaTeX only.\n- If CURSOR_FENCE_LANGUAGE=mermaid: output Mermaid syntax only, no backticks or prose.\n- If MERMAID_CONTEXT=true outside a fence: output a complete ```mermaid fenced block only when the surrounding text asks for a diagram.\n\nMARKDOWN AND BOUNDARIES:\n- Use actual line breaks, never spelled-out escape sequences, unless the document text itself needs them.\n- Match PREFIX tone, style, indentation, list/table structure, and language.\n- Never repeat text from the beginning of SUFFIX.\n- If separation is needed, put the needed real newline directly in the insertion text without explaining it.\n\nPREFILL:\n- The prompt may place a short tail of PREFIX immediately after <|fim_middle|> to make continuation natural.\n- Continue from that PREFILL. Do not describe it or output control markers.\n\nHIDDEN CONTEXT:\n- OCR metadata like <OCR:...> and document context are hidden context.\n- Use hidden context only as a semantic hint; never copy hidden tags to output."
} }
+6 -1
View File
@@ -1,14 +1,19 @@
fastapi>=0.95.0 fastapi>=0.95.0
uvicorn[standard]>=0.23.0 uvicorn[standard]>=0.23.0
pydantic>=1.10.0 pydantic>=1.10.0
httpx>=0.24.0
numpy>=1.23.0 numpy>=1.23.0
soundfile>=0.10.3 soundfile>=0.10.3
torch>=1.12.0 torch>=1.12.0
torchaudio>=0.12.0 torchaudio>=1.12.0
transformers>=4.25.0 transformers>=4.25.0
whisper>=1.0.0 whisper>=1.0.0
qwen-tts>=0.0.0 qwen-tts>=0.0.0
modelscope>=1.20.0 modelscope>=1.20.0
# MLX-based ASR (Apple Silicon only)
mlx-audio>=0.4.3
# testing # testing
pytest>=7.0.0 pytest>=7.0.0
+263 -30
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import importlib import importlib
import json
import sys import sys
from pathlib import Path from pathlib import Path
@@ -16,47 +17,279 @@ except ModuleNotFoundError:
pytest.skip("llm module dependencies are not available", allow_module_level=True) pytest.skip("llm module dependencies are not available", allow_module_level=True)
def test_call_ollama_messages_roles_with_system(monkeypatch): def test_extract_message_openai_format():
resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]}
content, thinking = llm._extract_message(resp)
assert content == "hello world"
assert thinking == "reasoning"
def test_extract_message_openai_reasoning_content():
resp = {"choices": [{"message": {"content": "answer", "reasoning_content": "deep thought"}}]}
content, thinking = llm._extract_message(resp)
assert content == "answer"
assert thinking == "deep thought"
def test_extract_message_empty_choices():
resp = {"choices": []}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_no_choices_key():
resp = {}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_none_content():
resp = {"choices": [{"message": {"content": None, "thinking": None}}]}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_delta_text():
chunk = {"choices": [{"delta": {"content": "hello"}}]}
assert llm._extract_delta_text(chunk) == "hello"
def test_extract_delta_text_empty():
chunk = {"choices": [{"delta": {}}]}
assert llm._extract_delta_text(chunk) == ""
def test_extract_delta_thinking():
chunk = {"choices": [{"delta": {"thinking": "reasoning step"}}]}
assert llm._extract_delta_thinking(chunk) == "reasoning step"
def test_extract_delta_reasoning_content():
chunk = {"choices": [{"delta": {"reasoning_content": "deep thought"}}]}
assert llm._extract_delta_thinking(chunk) == "deep thought"
def test_resolve_model_name_explicit():
assert llm._resolve_model_name("custom-model") == "custom-model"
def test_resolve_model_name_default():
assert llm._resolve_model_name() == llm.LLM_MODEL
def test_resolve_model_name_pro():
assert llm._resolve_model_name(use_pro_model=True) == llm.PRO_LLM_MODEL
def test_resolve_system_prompt():
assert llm._resolve_system_prompt(" system prompt ") == "system prompt"
assert llm._resolve_system_prompt("") == ""
assert llm._resolve_system_prompt(None) == ""
def test_build_chat_payload_with_system():
payload = llm._build_chat_payload(
"user prompt", system_prompt="sys prompt", temperature=0.5, model="test-model"
)
assert payload["model"] == "test-model"
assert len(payload["messages"]) == 2
assert payload["messages"][0]["role"] == "system"
assert payload["messages"][1]["role"] == "user"
assert payload["stream"] is False
def test_build_chat_payload_no_system():
payload = llm._build_chat_payload("user prompt", system_prompt=None)
assert len(payload["messages"]) == 1
assert payload["stream"] is False
def test_build_chat_payload_with_thinking():
payload = llm._build_chat_payload("prompt", thinking="low")
assert "options" in payload
assert payload["options"]["think"] == "low"
def test_build_chat_stream_payload():
payload = llm._build_chat_stream_payload("prompt", system_prompt="sys")
assert payload["stream"] is True
assert len(payload["messages"]) == 2
def test_build_chat_stream_payload_with_thinking():
payload = llm._build_chat_stream_payload("prompt", thinking="high")
assert "options" in payload
assert payload["options"]["think"] == "high"
def test_call_ollama_non_streaming(monkeypatch):
captured = {} captured = {}
async def fake_generate(**kwargs): async def fake_post(url, json=None):
captured["kwargs"] = kwargs captured["url"] = url
return {"response": "ok"} captured["json"] = json
monkeypatch.setattr(llm.client, "generate", fake_generate) class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "done"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run( result = asyncio.run(
llm.call_ollama( llm.call_ollama("test prompt", system_prompt="sys", tag="t1")
"user prompt body",
system_prompt="system prompt body",
tag="test",
temperature=0.1,
)
) )
assert result["content"] == "ok" assert result["content"] == "done"
assert captured["kwargs"]["prompt"] == "system prompt body\n\nuser prompt body" assert captured["url"] == "/chat/completions"
assert captured["kwargs"]["raw"] is True assert captured["json"]["stream"] is False
def test_call_ollama_messages_roles_without_system(monkeypatch): def test_stream_ollama_text_deltas(monkeypatch):
captured = {} captured = {}
async def fake_generate(**kwargs): def make_lines():
captured["kwargs"] = kwargs lines_iter = iter([
return {"response": "ok"} 'data: {"choices": [{"delta": {"content": "hel"}}]}',
'data: {"choices": [{"delta": {"content": "lo"}}]}',
"data: [DONE]",
])
monkeypatch.setattr(llm.client, "generate", fake_generate) class LineIterator:
async def __anext__(self):
try:
return next(lines_iter)
except StopIteration:
raise StopAsyncIteration()
result = asyncio.run( class Response:
llm.call_ollama( def __init__(self2): self2._lines = LineIterator()
"user prompt only",
system_prompt="",
tag="test-no-system",
temperature=0.1,
)
)
assert result["content"] == "ok" async def raise_for_status(self2): pass
assert captured["kwargs"]["prompt"] == "user prompt only" async def aiter_lines(self2): return self2._lines
assert captured["kwargs"]["raw"] is True
class StreamCtx:
async def __aenter__(self2): return Response()
async def __aexit__(*a): pass
class Client:
stream = lambda self2, *args, **kw: StreamCtx()
return Client()
async def fake_client(*args, **kwargs):
captured["called"] = True
return make_lines()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
results = []
async def collect():
async for delta in llm.stream_ollama("prompt", tag="t1"):
results.append(delta)
asyncio.run(collect())
assert captured.get("called") is True
assert results == ["hel", "lo"]
def test_stream_ollama_events_thinking_and_content(monkeypatch):
captured = {}
def make_lines():
lines_iter = iter([
'data: {"choices": [{"delta": {"thinking": "reasoning"}}]}',
'data: {"choices": [{"delta": {"content": "answer"}}]}',
"data: [DONE]",
])
class LineIterator:
async def __anext__(self):
try:
return next(lines_iter)
except StopIteration:
raise StopAsyncIteration()
class Response:
def __init__(self2): self2._lines = LineIterator()
async def raise_for_status(self2): pass
async def aiter_lines(self2): return self2._lines
class StreamCtx:
async def __aenter__(self2): return Response()
async def __aexit__(*a): pass
class Client:
stream = lambda self2, *args, **kw: StreamCtx()
return Client()
async def fake_client(*args, **kwargs):
captured["called"] = True
return make_lines()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
results = []
async def collect():
async for event_type, payload in llm.stream_ollama_events("prompt", tag="t1"):
results.append((event_type, payload))
asyncio.run(collect())
assert captured.get("called") is True
# First event should be thinking, then content
assert results[0] == ("thinking", "")
assert results[1][0] == "content"
def test_call_vlm_ocr(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["url"] = url
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ocr text"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(llm.call_vlm_ocr(b"fake image bytes"))
assert result == "ocr text"
# Verify the payload uses OpenAI vision format (image_url)
assert captured["url"] == "/chat/completions"
messages = captured["json"]["messages"]
assert len(messages) == 1
content_parts = messages[0]["content"]
# Should have text part and image_url part
assert any(p.get("type") == "text" for p in content_parts)
image_part = [p for p in content_parts if p.get("type") == "image_url"]
assert len(image_part) == 1
assert image_part[0]["image_url"]["url"].startswith("data:image/png;base64,")
+148 -178
View File
@@ -2,6 +2,7 @@ import asyncio
import importlib import importlib
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -15,66 +16,27 @@ except ModuleNotFoundError:
pytest.skip("llm module dependencies are not available", allow_module_level=True) pytest.skip("llm module dependencies are not available", allow_module_level=True)
def test_extract_message_with_object_message_content_and_thinking(): def test_extract_message_with_content_and_thinking():
class Msg: resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]}
def __init__(self, content, thinking):
self.content = content
self.thinking = thinking
class Resp:
def __init__(self, message):
self.message = message
resp = Resp(Msg("hello world", "thinking about it"))
content, thinking = llm._extract_message(resp) content, thinking = llm._extract_message(resp)
assert content == "hello world" assert content == "hello world"
assert thinking == "thinking about it" assert thinking == "reasoning"
def test_extract_message_with_object_message_empty_content(): def test_extract_message_empty_content():
class Msg: resp = {"choices": [{"message": {"content": "", "thinking": None}}]}
def __init__(self, content, thinking):
self.content = content
self.thinking = thinking
class Resp:
def __init__(self, message):
self.message = message
resp = Resp(Msg("", None))
content, thinking = llm._extract_message(resp) content, thinking = llm._extract_message(resp)
assert content == "" assert content == ""
assert thinking == "" assert thinking == ""
def test_extract_message_with_dict_message(): def test_extract_message_dict_no_choices():
resp = {"message": {"content": "ok", "thinking": "calc"}} resp = {"not_choices": []}
content, thinking = llm._extract_message(resp)
assert content == "ok"
assert thinking == "calc"
def test_extract_message_dict_no_message_key():
resp = {"not_message": {"content": "irrelevant"}}
content, thinking = llm._extract_message(resp) content, thinking = llm._extract_message(resp)
assert content == "" assert content == ""
assert thinking == "" assert thinking == ""
def test_extract_message_dict_message_content_none_and_thinking_none():
resp = {"message": {"content": None, "thinking": None}}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_dict_message_thinking_none():
resp = {"message": {"content": "val", "thinking": None}}
content, thinking = llm._extract_message(resp)
assert content == "val"
assert thinking == ""
def test_extract_message_empty_dict(): def test_extract_message_empty_dict():
resp = {} resp = {}
content, thinking = llm._extract_message(resp) content, thinking = llm._extract_message(resp)
@@ -82,174 +44,182 @@ def test_extract_message_empty_dict():
assert thinking == "" assert thinking == ""
def test_call_ollama_no_system_message(monkeypatch): def test_extract_delta_text_from_chunk():
chunk = {"choices": [{"delta": {"content": "text"}}]}
assert llm._extract_delta_text(chunk) == "text"
def test_extract_delta_thinking_from_chunk():
chunk = {"choices": [{"delta": {"thinking": "thought"}}]}
assert llm._extract_delta_thinking(chunk) == "thought"
def test_call_ollama_no_system(monkeypatch):
captured = {} captured = {}
async def fake_generate(**kwargs): async def fake_post(url, json=None):
captured["kwargs"] = kwargs captured["json"] = json
return {"response": "ok"}
monkeypatch.setattr(llm.client, "generate", fake_generate) class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run( result = asyncio.run(
llm.call_ollama("user prompt body", system_prompt=None, tag="no-system", temperature=0.1) llm.call_ollama("user prompt", system_prompt=None, tag="no-system")
) )
assert result["content"] == "ok" assert result["content"] == "ok"
assert captured["kwargs"]["prompt"] == "user prompt body" # Should only have user message, no system
assert captured["kwargs"]["raw"] is True assert len(captured["json"]["messages"]) == 1
def test_call_ollama_whitespace_system_message(monkeypatch): def test_call_ollama_with_system(monkeypatch):
captured = {} captured = {}
async def fake_generate(**kwargs): async def fake_post(url, json=None):
captured["kwargs"] = kwargs captured["json"] = json
return {"response": "ok"}
monkeypatch.setattr(llm.client, "generate", fake_generate) class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run( result = asyncio.run(
llm.call_ollama("user prompt", system_prompt=" ", tag="whitespace-system", temperature=0.1) llm.call_ollama("user prompt", system_prompt="sys prompt", tag="with-system")
) )
assert result["content"] == "ok" assert result["content"] == "ok"
assert captured["kwargs"]["prompt"] == "user prompt" # Should have both system and user messages
assert captured["kwargs"]["raw"] is True msgs = captured["json"]["messages"]
assert len(msgs) == 2
assert msgs[0]["role"] == "system"
def test_call_ollama_thinking_in_kwargs(monkeypatch): def test_call_ollama_with_custom_model(monkeypatch):
captured = {} captured = {}
async def fake_generate(**kwargs): async def fake_post(url, json=None):
captured.update(kwargs) captured["json"] = json
return {"response": "ok", "message": {"thinking": "boom"}} # keep thinking for backward test though it might not be perfect
monkeypatch.setattr(llm.client, "generate", fake_generate) class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
res = asyncio.run( return FakeResp()
llm.call_ollama("prompt", thinking="boom", tag="think-flag", temperature=0.7)
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(
llm.call_ollama("prompt", model="custom-model")
) )
assert res["content"] == "ok" and res["think"] == "boom"
assert captured.get("think") == "boom" assert captured["json"]["model"] == "custom-model"
def test_call_ollama_cancelled_reraises(monkeypatch): def test_stream_ollama_events_error_handling(monkeypatch):
async def fake_generate(**kwargs): def make_lines():
raise asyncio.CancelledError lines_iter = iter([
'data: {"error": "model not found"}',
monkeypatch.setattr(llm.client, "generate", fake_generate)
with pytest.raises(asyncio.CancelledError):
asyncio.run(
llm.call_ollama("prompt", system_prompt=None, tag="cancel", temperature=0.7)
)
def test_call_ollama_chat_raises_rethrows(monkeypatch):
async def fake_generate(**kwargs):
raise ValueError("boom")
monkeypatch.setattr(llm.client, "generate", fake_generate)
with pytest.raises(ValueError):
asyncio.run(
llm.call_ollama("prompt", system_prompt=None, tag="exception", temperature=0.7)
)
def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
async def fake_generate(**kwargs):
return {"response": "final", "message": {"thinking": "process"}}
monkeypatch.setattr(llm.client, "generate", fake_generate)
res = asyncio.run(
llm.call_ollama("prompt", system_prompt=None, tag="return", temperature=0.7)
)
assert res["content"] == "final" and res["think"] == "process"
def test_stream_ollama_uses_requested_model_and_yields_chunks(monkeypatch):
captured = {}
class FakeStream:
def __init__(self, chunks):
self._chunks = iter(chunks)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._chunks)
except StopIteration:
raise StopAsyncIteration
async def fake_generate(**kwargs):
captured["kwargs"] = kwargs
return FakeStream([
{"response": "深度"},
{"response": "回答"},
]) ])
monkeypatch.setattr(llm.client, "generate", fake_generate) class LineIterator:
async def __anext__(self):
try:
return next(lines_iter)
except StopIteration:
raise StopAsyncIteration()
chunks = [] class Response:
def __init__(self2): self2._lines = LineIterator()
async def collect_stream(): async def raise_for_status(self2): pass
async for chunk in llm.stream_ollama( async def aiter_lines(self2): return self2._lines
"prompt",
system_prompt="system",
tag="stream",
temperature=0.8,
model="pro-model",
use_pro_model=True,
):
chunks.append(chunk)
asyncio.run(collect_stream()) class StreamCtx:
async def __aenter__(self2): return Response()
async def __aexit__(*a): pass
assert "".join(chunks) == "深度回答" class Client:
assert captured["kwargs"]["model"] == "pro-model" stream = lambda self2, *args, **kw: StreamCtx()
assert captured["kwargs"]["stream"] is True
return Client()
async def fake_client(*args, **kwargs):
return make_lines()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
async def collect():
try:
async for _ in llm.stream_ollama_events("prompt", tag="err"):
pass
except RuntimeError as e:
return str(e)
result = asyncio.run(collect())
assert "model not found" in str(result)
def test_call_vlm_ocr_passes_image_and_prompt(monkeypatch): def test_call_vlm_ocr_payload_format(monkeypatch):
image_bytes = b"image-bytes" captured = {}
called = {}
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
async def fake_chat(**kwargs): async def fake_post(url, json=None):
called["kwargs"] = kwargs captured["json"] = json
return {"message": {"content": "ocr result", "thinking": ""}}
monkeypatch.setattr(llm.client, "chat", fake_chat) class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ocr result"}}]}
result = asyncio.run(llm.call_vlm_ocr(image_bytes, language="auto")) return FakeResp()
messages = called["kwargs"].get("messages", []) async def fake_client(*args, **kwargs):
assert messages[0]["role"] == "user" class Ctx:
assert messages[0]["content"] == "OCR PROMPT" async def __aenter__(self2): return self2
assert messages[0]["images"] == [image_bytes] async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(llm.call_vlm_ocr(b"image"))
assert result == "ocr result" assert result == "ocr result"
# Verify vision format: image_url content part with base64
def test_call_vlm_ocr_chat_raises_rethrows(monkeypatch): msgs = captured["json"]["messages"]
image_bytes = b"image-bytes" assert len(msgs) == 1
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT") content_parts = msgs[0]["content"]
image_part = [p for p in content_parts if p.get("type") == "image_url"]
async def fake_chat(**kwargs): assert len(image_part) == 1
raise RuntimeError("ocr fail")
monkeypatch.setattr(llm.client, "chat", fake_chat)
with pytest.raises(RuntimeError):
asyncio.run(llm.call_vlm_ocr(image_bytes))
def test_call_vlm_ocr_returns_content_from_response(monkeypatch):
image_bytes = b"img"
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
async def fake_chat(**kwargs):
return {"message": {"content": "ocr text", "thinking": ""}}
monkeypatch.setattr(llm.client, "chat", fake_chat)
content = asyncio.run(llm.call_vlm_ocr(image_bytes))
assert content == "ocr text"
+68 -16
View File
@@ -17,6 +17,7 @@ if "tts_asr" not in sys.modules:
sys.modules["tts_asr"] = fake_tts_asr sys.modules["tts_asr"] = fake_tts_asr
import main # type: ignore import main # type: ignore
import pro_completions # type: ignore
API_KEY = main.API_KEY API_KEY = main.API_KEY
HEADERS = {"X-API-Key": API_KEY} HEADERS = {"X-API-Key": API_KEY}
@@ -25,8 +26,10 @@ HEADERS = {"X-API-Key": API_KEY}
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clear_active_completions(): def _clear_active_completions():
main.ACTIVE_COMPLETIONS.clear() main.ACTIVE_COMPLETIONS.clear()
pro_completions.PRO_STATES.clear()
yield yield
main.ACTIVE_COMPLETIONS.clear() main.ACTIVE_COMPLETIONS.clear()
pro_completions.PRO_STATES.clear()
class DummyRequest: class DummyRequest:
@@ -76,6 +79,33 @@ def test_sanitize_markdown_normalize_crlf():
assert "\r" not in result assert "\r" not in result
def test_sanitize_inline_completion_strips_prefill():
assert main.sanitize_inline_completion_content(
"系统非常适合写作",
prefill="系统",
) == "非常适合写作"
def test_sanitize_inline_completion_extracts_fim_middle():
assert main.sanitize_inline_completion_content(
"<|fim_middle|>系统非常适合写作<|end|>",
prefill="系统",
) == "非常适合写作"
def test_sanitize_inline_completion_extracts_polluted_chat_output():
polluted = (
"on new line? Prefix ends with newline already. The suffix starts with no newline. "
"We need to consider if output should end with newline? The suffix starts with no newline. "
"So we output: \"让我们一起探索 AI 的无限可能。\""
"<|end|><|start|>assistant<|channel|>final|fim_middle|>系统让我们一起探索 AI 的无限可能。"
)
assert main.sanitize_inline_completion_content(
polluted,
prefill="系统",
) == "让我们一起探索 AI 的无限可能。"
def test_get_client_ip_from_host(): def test_get_client_ip_from_host():
req = DummyRequest(host="1.2.3.4", headers={}) req = DummyRequest(host="1.2.3.4", headers={})
assert main.get_client_ip(req) == "1.2.3.4" assert main.get_client_ip(req) == "1.2.3.4"
@@ -102,7 +132,10 @@ def test_post_completions_wrong_api_key_returns_401():
def test_post_completions_privacy_mode(monkeypatch): def test_post_completions_privacy_mode(monkeypatch):
captured = {}
async def fake_call(*args, **kwargs): async def fake_call(*args, **kwargs):
captured["kwargs"] = kwargs
return {"content": "done", "think": ""} return {"content": "done", "think": ""}
monkeypatch.setattr(main, "call_ollama", fake_call) monkeypatch.setattr(main, "call_ollama", fake_call)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user")) monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
@@ -116,39 +149,58 @@ def test_post_completions_privacy_mode(monkeypatch):
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data.get("content") == "done" assert data.get("content") == "done"
# enable_thinking removed in OpenAI-compatible rewrite
assert captured["kwargs"]["thinking"] == "low"
def test_post_pro_stream_returns_sse(monkeypatch): def test_old_post_pro_stream_returns_404():
captured = {}
async def fake_stream(*args, **kwargs):
captured["kwargs"] = kwargs
yield "深度"
yield "回答"
monkeypatch.setattr(main, "stream_ollama", fake_stream)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("p", "s"))
client = TestClient(main.app) client = TestClient(main.app)
with client.stream("POST", "/v1/pro/completions/stream", headers=HEADERS, json={ resp = client.post("/v1/pro/completions/stream", headers=HEADERS, json={
"prefix": "hello", "prefix": "hello",
"suffix": "", "suffix": "",
"languageId": "markdown", "languageId": "markdown",
"model_thinking": "high", "model_thinking": "high",
"privacy_mode": True, "privacy_mode": True,
"model": "pro-model", })
"temperature": 0.95, assert resp.status_code == 404
def test_post_pro_completion_returns_sse_and_status(monkeypatch):
captured = {}
async def fake_stream_events(*args, **kwargs):
captured["kwargs"] = kwargs
yield "thinking", ""
yield "content", "深度"
yield "content", "回答"
monkeypatch.setattr(pro_completions, "stream_ollama_events", fake_stream_events)
client = TestClient(main.app)
with client.stream("POST", "/v1/pro/completions", headers=HEADERS, json={
"prefix": "hello",
"suffix": "",
"languageId": "markdown",
"instruction": "expand",
"pro_thinking": "high",
"privacy_mode": True,
}) as resp: }) as resp:
assert resp.status_code == 200 assert resp.status_code == 200
body = "".join(resp.iter_text()) body = "".join(resp.iter_text())
assert "event: queued" in body
assert "event: started" in body
assert "event: thinking" in body
assert "event: chunk" in body assert "event: chunk" in body
assert "event: done" in body assert "event: done" in body
assert "深度" in body assert "深度" in body
assert "回答" in body assert "回答" in body
assert captured["kwargs"]["model"] == "pro-model"
assert captured["kwargs"]["use_pro_model"] is True assert captured["kwargs"]["use_pro_model"] is True
assert captured["kwargs"]["thinking"] == "high"
request_id = next(iter(pro_completions.PRO_STATES))
status_resp = client.get(f"/v1/pro/completions/status/{request_id}", headers=HEADERS)
assert status_resp.status_code == 200
assert status_resp.json()["status"] == "done"
assert main.ACTIVE_COMPLETIONS == {} assert main.ACTIVE_COMPLETIONS == {}
+114
View File
@@ -0,0 +1,114 @@
import os
import sys
import types
import asyncio
import threading
from fastapi.testclient import TestClient
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if BACKEND_DIR not in sys.path:
sys.path.insert(0, BACKEND_DIR)
if "tts_asr" not in sys.modules:
fake_tts_asr = types.ModuleType("tts_asr")
fake_tts_asr.register_tts_asr_routes = lambda app: None
sys.modules["tts_asr"] = fake_tts_asr
import main # type: ignore
import pro_completions # type: ignore
HEADERS = {"X-API-Key": main.API_KEY}
def _payload():
return {
"prefix": "Before",
"suffix": "After",
"languageId": "markdown",
"instruction": "expand",
"pro_thinking": "medium",
"privacy_mode": True,
}
def setup_function():
pro_completions.PRO_STATES.clear()
def teardown_function():
pro_completions.PRO_STATES.clear()
def test_pro_queue_full_returns_429(monkeypatch):
monkeypatch.setattr(pro_completions, "PRO_QUEUE_MAX_SIZE", 0)
client = TestClient(main.app)
response = client.post("/v1/pro/completions", headers=HEADERS, json=_payload())
assert response.status_code == 429
assert response.json()["error"] == "PRO queue is full"
def test_pro_status_missing_returns_404():
client = TestClient(main.app)
response = client.get("/v1/pro/completions/status/missing", headers=HEADERS)
assert response.status_code == 404
def test_pro_prompt_uses_simple_chat_instruction():
system_prompt, user_prompt = pro_completions._build_pro_prompts(
prefix="欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统",
suffix="",
language_id="markdown",
instruction="",
)
combined = f"{system_prompt}\n{user_prompt}".lower()
assert "pro block" not in combined
assert "replacement" not in combined
assert "final answer" not in combined
assert "markdown before cursor" in combined
assert "markdown after cursor" in combined
assert "continue the markdown naturally" in combined
def test_pro_cancel_waits_for_stream_cleanup(monkeypatch):
started = threading.Event()
cleaned = threading.Event()
async def fake_stream_events(*args, **kwargs):
started.set()
try:
yield "thinking", ""
while True:
await asyncio.sleep(0.05)
finally:
cleaned.set()
monkeypatch.setattr(pro_completions, "stream_ollama_events", fake_stream_events)
request_id = "pro-cancel-cleanup"
headers = {**HEADERS, "X-Request-Id": request_id}
response_box = {}
with TestClient(main.app) as client:
def send_stream():
with client.stream("POST", "/v1/pro/completions", headers=headers, json=_payload()) as response:
response_box["status_code"] = response.status_code
response_box["body"] = "".join(response.iter_text())
stream_thread = threading.Thread(target=send_stream, daemon=True)
stream_thread.start()
assert started.wait(timeout=2.0)
cancel_response = client.post(
"/v1/pro/completions/cancel",
headers=HEADERS,
json={"request_id": request_id, "reason": "test"},
)
assert cancel_response.status_code == 200
assert cancel_response.json() == {"cancelled": True, "status": "ok"}
assert cleaned.wait(timeout=2.0)
stream_thread.join(timeout=5.0)
assert not stream_thread.is_alive()
+31 -9
View File
@@ -10,7 +10,7 @@ import prompt # noqa: E402
def test_prompt_builds_system_and_user(): def test_prompt_builds_system_and_user():
system_prompt, user_prompt = prompt.build_completion_prompts( system_prompt, user_prompt, prefill = prompt.build_completion_prompts(
prefix="The result is ", prefix="The result is ",
suffix="for this dataset.", suffix="for this dataset.",
language_id="markdown", language_id="markdown",
@@ -29,14 +29,36 @@ def test_prompt_builds_system_and_user():
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt
assert "actual line breaks" in system_prompt assert "actual line breaks" in system_prompt
assert "start OUTPUT on a new line" in system_prompt
assert "Use real line breaks instead of spelled-out escape sequences" in user_prompt assert "Use real line breaks instead of spelled-out escape sequences" in user_prompt
assert "make the first character of OUTPUT a real newline" in user_prompt assert "Do not explain newline or boundary choices" in user_prompt
assert "make the last character of OUTPUT a real newline" in user_prompt assert "Continue after the PREFILL text" in user_prompt
assert "Step 1" not in user_prompt
assert "Does output need" not in user_prompt
assert "assistant" in system_prompt
assert "fim_middle" in system_prompt
assert prefill == ""
assert "start output with \\n" not in user_prompt assert "start output with \\n" not in user_prompt
assert "Use single \\n" not in system_prompt assert "Use single \\n" not in system_prompt
def test_completion_prefill_appended_to_fim_middle():
_, user_prompt, prefill = prompt.build_completion_prompts(
prefix="即时可用的 LLM 系统",
suffix="",
)
assert prefill == "系统"
assert user_prompt.endswith("<|fim_middle|>系统")
def test_completion_prefill_empty_after_newline():
_, user_prompt, prefill = prompt.build_completion_prompts(
prefix="即时可用的 LLM 系统\n",
suffix="",
)
assert prefill == ""
assert user_prompt.endswith("<|fim_middle|>")
def test_cursor_in_fence_detection(): def test_cursor_in_fence_detection():
assert prompt._cursor_in_fenced_code_block("") is False assert prompt._cursor_in_fenced_code_block("") is False
assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n") is True assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n") is True
@@ -53,7 +75,7 @@ def test_active_fence_language_detection():
def test_newline_flags(): def test_newline_flags():
_, user_prompt_a = prompt.build_completion_prompts( _, user_prompt_a, _ = prompt.build_completion_prompts(
prefix="Hello", prefix="Hello",
suffix="World", suffix="World",
) )
@@ -63,7 +85,7 @@ def test_newline_flags():
assert "PREFIX_ENDS_WITH_NEWLINE: false" in user_prompt_a assert "PREFIX_ENDS_WITH_NEWLINE: false" in user_prompt_a
assert "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a assert "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a
_, user_prompt_b = prompt.build_completion_prompts( _, user_prompt_b, _ = prompt.build_completion_prompts(
prefix="Hello\n", prefix="Hello\n",
suffix="\nWorld", suffix="\nWorld",
) )
@@ -73,7 +95,7 @@ def test_newline_flags():
def test_mermaid_context_flags(): def test_mermaid_context_flags():
_, prompt_in_mermaid = prompt.build_completion_prompts( _, prompt_in_mermaid, _ = prompt.build_completion_prompts(
prefix="```mermaid\nflowchart TD\nA --> ", prefix="```mermaid\nflowchart TD\nA --> ",
suffix="\n```", suffix="\n```",
) )
@@ -81,7 +103,7 @@ def test_mermaid_context_flags():
assert "CURSOR_FENCE_LANGUAGE: mermaid" in prompt_in_mermaid assert "CURSOR_FENCE_LANGUAGE: mermaid" in prompt_in_mermaid
assert "MERMAID_CONTEXT: true" in prompt_in_mermaid assert "MERMAID_CONTEXT: true" in prompt_in_mermaid
_, prompt_mermaid_keyword = prompt.build_completion_prompts( _, prompt_mermaid_keyword, _ = prompt.build_completion_prompts(
prefix="Please draw a mermaid flowchart for deploy pipeline.", prefix="Please draw a mermaid flowchart for deploy pipeline.",
suffix="", suffix="",
) )
@@ -91,6 +113,6 @@ def test_mermaid_context_flags():
def test_examples_coverage(): def test_examples_coverage():
_, user_prompt = prompt.build_completion_prompts(prefix="", suffix="") _, user_prompt, _ = prompt.build_completion_prompts(prefix="", suffix="")
for ex in range(1, 15): for ex in range(1, 15):
assert f"[EX{ex:02d}]" in user_prompt assert f"[EX{ex:02d}]" in user_prompt
+6 -2
View File
@@ -4,7 +4,9 @@ from pathlib import Path
# Ensure the project root is in sys.path so imports like `from backend import prompt` work # Ensure the project root is in sys.path so imports like `from backend import prompt` work
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
BACKEND_DIR = ROOT / "backend"
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(BACKEND_DIR))
from backend import prompt # type: ignore from backend import prompt # type: ignore
@@ -120,22 +122,24 @@ def test_build_completion_prompts_with_userprefs():
language = "python" language = "python"
currency = "USD" currency = "USD"
timezone = "UTC+0" timezone = "UTC+0"
system, user = prompt.build_completion_prompts( system, user, prefill = prompt.build_completion_prompts(
prefix="hello", suffix="world", language_id="markdown", prefix="hello", suffix="world", language_id="markdown",
preferences=UserPrefs(), preferences=UserPrefs(),
) )
assert isinstance(system, str) assert isinstance(system, str)
assert isinstance(user, str) assert isinstance(user, str)
assert prefill == "hello"
assert "python" in user.lower() or "USD" in user assert "python" in user.lower() or "USD" in user
def test_build_completion_prompts_privacy_mode_location_empty(): def test_build_completion_prompts_privacy_mode_location_empty():
system, user = prompt.build_completion_prompts( system, user, prefill = prompt.build_completion_prompts(
prefix="hello", suffix="world", language_id="markdown", prefix="hello", suffix="world", language_id="markdown",
location="", location="",
) )
assert isinstance(system, str) assert isinstance(system, str)
assert isinstance(user, str) assert isinstance(user, str)
assert prefill == "hello"
def test_build_prompt_backward_compatibility(): def test_build_prompt_backward_compatibility():
+133 -267
View File
@@ -1,327 +1,193 @@
import os import os
import sys import sys
import time import asyncio
import types import types
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
BACKEND_DIR = Path(__file__).resolve().parents[1] BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path: if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR)) sys.path.insert(0, str(BACKEND_DIR))
def _make_torch_stub(cuda_avail=False, mps_avail=False): def _make_mlx_stub():
class DummyTensor: """Create minimal MLX stub for testing without Apple Silicon"""
def __matmul__(self, other): return self mlx = types.SimpleNamespace()
def matmul(self, other): return self mlx.core = types.SimpleNamespace()
mx_array = type('mx.array', (), {'item': lambda self: 1})
stub = types.SimpleNamespace() mlx.core.array = mx_array
stub.float32 = "float32" mlx.nn = types.SimpleNamespace()
stub.float16 = "float16" return mlx
stub.randn = lambda *a, **k: DummyTensor()
stub.mm = lambda a, b: DummyTensor()
stub.from_numpy = lambda arr: DummyTensor()
stub.nn = types.SimpleNamespace()
stub.nn.Linear = MagicMock(return_value=MagicMock())
stub.nn.Module = type("Module", (), {})
stub.no_grad = MagicMock()
stub.no_grad.return_value.__enter__ = MagicMock(return_value=None)
stub.no_grad.return_value.__exit__ = MagicMock(return_value=False)
stub.backends = types.SimpleNamespace()
stub.backends.mps = types.SimpleNamespace()
stub.backends.mps.is_available = lambda: mps_avail
stub.backends.mps.is_built = lambda: mps_avail
stub.cuda = types.SimpleNamespace()
stub.cuda.is_available = lambda: cuda_avail
stub.cuda.device_count = lambda: 1 if cuda_avail else 0
stub.cuda.get_device_properties = lambda n: types.SimpleNamespace(total_memory=8*1024*1024*1024)
stub.cuda.empty_cache = lambda: None
stub.mps = types.SimpleNamespace()
stub.mps.is_available = lambda: mps_avail
stub.mps.is_built = lambda: mps_avail
stub.mps.empty_cache = lambda: None
stub.device = lambda s: s
stub.Tensor = MagicMock()
return stub
def _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None): def _make_mlx_audio_stub():
"""Create minimal mlx-audio stub"""
stt = types.SimpleNamespace()
stt.utils = types.SimpleNamespace()
def mock_load(path, **kwargs):
model = MagicMock()
return model
stt.utils.load = mock_load # type: ignore
qwen3_asr_mod = types.SimpleNamespace()
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
stt.models = types.SimpleNamespace() # type: ignore
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
audio = types.SimpleNamespace()
audio.stt = stt # type: ignore
return audio
def _reload_tts_asr_with_mocks():
"""Reload tts_asr with mocked MLX dependencies"""
for mod_name in list(sys.modules.keys()): for mod_name in list(sys.modules.keys()):
if mod_name.startswith("tts_asr") or mod_name == "torch": if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name] del sys.modules[mod_name]
torch_stub = _make_torch_stub(cuda_avail=cuda_avail, mps_avail=mps_avail)
sys.modules["torch"] = torch_stub mlx_stub = _make_mlx_stub()
if env_device is not None: sys.modules['mlx'] = mlx_stub # type: ignore
os.environ["TTS_ASR_DEVICE"] = env_device sys.modules['mlx.core'] = mlx_stub.core # type: ignore
elif "TTS_ASR_DEVICE" in os.environ: sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
del os.environ["TTS_ASR_DEVICE"]
audio_stub = _make_mlx_audio_stub()
sys.modules['mlx-audio'] = audio_stub # type: ignore
sys.modules['mlx_audio'] = audio_stub # type: ignore
sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
import tts_asr import tts_asr
tts_asr._device_caps = None
tts_asr._tts_pipeline = None
tts_asr._asr_pipeline = None
tts_asr._tts_last_used = 0
tts_asr._asr_last_used = 0
return tts_asr return tts_asr
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clean_tts_env(): def _clean_env():
"""Clean ASR-related env vars before/after each test"""
saved = {} saved = {}
for k in ["TTS_ASR_DEVICE", "TTS_ASR_IDLE_TIMEOUT", "TTS_ASR_MODEL_SIZE", for k in ['HF_ENDPOINT']:
"TTS_ASR_QUANTIZE", "TTS_ASR_OFFLINE_MODE", "TTS_ASR_WARMUP",
"TTS_ASR_MPS_MEMORY_LIMIT_MB"]:
saved[k] = os.environ.get(k) saved[k] = os.environ.get(k)
if k in os.environ: if k in os.environ:
del os.environ[k] del os.environ[k]
yield yield
for k, v in saved.items(): for k, v in saved.items():
if v is not None: if v is not None:
os.environ[k] = v os.environ[k] = v # type: ignore (unused var)
elif k in os.environ:
del os.environ[k]
# --- Cache clearing --- class TestRequestResponseModels:
def test_clear_cuda_cache(): """Pydantic 数据模型测试"""
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cpu")
tts._clear_cuda_cache()
def test_tts_request_defaults(self):
tts = _reload_tts_asr_with_mocks()
req = tts.TTSRequest(text="hello")
assert req.text == "hello"
assert req.speaker == "Vivian"
def test_clear_mps_cache(): def test_asr_request_defaults(self):
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="cpu") tts = _reload_tts_asr_with_mocks()
tts._clear_mps_cache() req = tts.ASRRequest(audio_base64="dGVzdA==")
assert req.audio_base64 == "dGVzdA=="
assert req.language == "zh-CN"
def test_asr_request_custom_language(self):
tts = _reload_tts_asr_with_mocks()
req = tts.ASRRequest(audio_base64="dGVzdA==", language="en")
assert req.language == "en"
# --- Model cache check --- def test_model_status_defaults(self):
def test_check_model_cached_non_offline(): tts = _reload_tts_asr_with_mocks()
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False) status = tts.ModelStatus(tts_loaded=False, asr_loaded=True, device="cpu")
os.environ["TTS_ASR_OFFLINE_MODE"] = "false" assert not status.tts_loaded
import importlib assert status.asr_loaded
importlib.reload(tts)
assert tts._check_model_cached("openai/whisper-tiny") is True
def test_check_model_cached_offline_mode(): class TestDeviceDetection:
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False) """设备检测测试"""
os.environ["TTS_ASR_OFFLINE_MODE"] = "true"
import importlib
importlib.reload(tts)
assert tts._check_model_cached("openai/whisper-tiny") is False
def test_device_map_returns_string(self):
tts = _reload_tts_asr_with_mocks()
device = tts._get_device_map()
assert isinstance(device, str)
# --- Torch dtype ---
def test_get_torch_dtype_cpu():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
assert tts._get_torch_dtype() == "float32"
class TestModelLoading:
"""模型加载测试"""
def test_get_torch_dtype_mps(): def test_load_asr_skips_when_mlx_unavailable(self):
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps") """mlx_audio 未安装时应跳过 ASR"""
assert tts._get_torch_dtype() == "float32" for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
# Don't inject mlx stubs — simulate missing MLX
import tts_asr # noqa: F811
def test_get_torch_dtype_cuda(): assert tts_asr.Qwen3ASRModel is None
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda") tts_asr._load_asr_models() # should not crash
assert tts._get_torch_dtype() == "float16" assert tts_asr._asr_model is None
def test_load_asr_from_path_success(self):
tts = _reload_tts_asr_with_mocks()
# Mock snapshot_download to return a path, mock stt_load to succeed
with patch('backend.tts_asr.snapshot_download', return_value='/fake/path'): # type: ignore
tts._load_asr_from_path('/fake/path')
# --- Device detection --- assert tts._asr_model is not None # type: ignore (MagicMock)
def test_get_device_cpu_env():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
assert tts._get_device() == "cpu"
def test_get_device_mps_available(): class TestWarmupFunctions:
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps") """预热函数测试"""
assert tts._get_device() == "mps"
def test_warmup_functions_callable(self):
tts = _reload_tts_asr_with_mocks()
assert callable(tts._warmup_tts) # type: ignore (unused var)
assert callable(tts._warmup_all)
def test_get_device_mps_not_available_falls_back(): def test_warmup_asr_skips_when_mlx_unavailable(self):
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="mps") for mod_name in list(sys.modules.keys()):
assert tts._get_device() == "cpu" if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
def test_get_device_cuda_available(): def test_warmup_all_runs_without_error(self):
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda") tts = _reload_tts_asr_with_mocks()
assert tts._get_device() == "cuda"
# Set global models so warmup returns immediately without actual loading
tts._tts_model = MagicMock()
def test_get_device_cuda_not_available_falls_back(): async def run(): # type: ignore (unused var)
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cuda") await tts._warmup_all()
assert tts._get_device() == "cpu"
asyncio.get_event_loop().run_until_complete(run()) # type: ignore
def test_get_device_auto_mps():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device=None)
assert tts._get_device() == "mps"
class TestRouteRegistration:
"""路由注册测试"""
def test_get_device_auto_cuda(): def test_register_function_exists(self):
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device=None) tts = _reload_tts_asr_with_mocks()
assert tts._get_device() == "cuda" assert callable(tts.register_tts_asr_routes)
def test_router_prefix(self):
tts = _reload_tts_asr_with_mocks()
assert hasattr(tts.router, 'routes')
def test_get_device_auto_cpu():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None)
assert tts._get_device() == "cpu"
class TestModelConstants:
"""模型常量测试"""
def test_device_arg_cuda(): def test_asr_model_id(self):
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda") tts = _reload_tts_asr_with_mocks()
assert tts._device_arg() == "cuda:0" assert 'Qwen3-ASR' in tts.ASR_MODEL_ID_MS
def test_align_model_id(self):
def test_device_arg_cpu(): tts = _reload_tts_asr_with_mocks()
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu") assert 'ForcedAligner' in tts.ALIGN_MODEL_ID_MS
assert tts._device_arg() == "cpu"
def test_device_arg_mps():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
assert tts._device_arg() == "mps"
def test_test_device_capability_cpu():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
ok, err = tts._test_device_capability("cpu")
assert ok is True
assert err == ""
def test_test_device_capability_mps_not_available():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
ok, err = tts._test_device_capability("mps")
assert ok is False
assert len(err) > 0
def test_test_device_capability_cuda_not_available():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
ok, err = tts._test_device_capability("cuda")
assert ok is False
assert len(err) > 0
def test_test_device_capability_unknown_device():
tts = _reload_tts_asr()
ok, err = tts._test_device_capability("vulkan")
assert ok is False
assert len(err) > 0
# --- Idle model unload ---
def test_check_and_unload_idle_models_timeout_zero():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "0"
tts._tts_pipeline = "pipeline"
tts._asr_pipeline = "pipeline"
tts._tts_last_used = time.time()
tts._asr_last_used = time.time()
tts._check_and_unload_idle_models()
assert tts._tts_pipeline == "pipeline"
def test_check_and_unload_idle_models_unloads_when_expired():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "1"
tts._tts_pipeline = "pipeline"
tts._asr_pipeline = "pipeline"
tts._tts_last_used = time.time() - 10
tts._asr_last_used = time.time() - 10
import importlib
importlib.reload(tts)
tts._check_and_unload_idle_models()
assert True # Function executed without error
def test_check_and_unload_idle_models_keeps_when_not_expired():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "60"
tts._tts_pipeline = "pipeline"
tts._asr_pipeline = "pipeline"
tts._tts_last_used = time.time()
tts._asr_last_used = time.time()
tts._check_and_unload_idle_models()
assert tts._tts_pipeline == "pipeline"
# --- API key ---
def test_get_api_key_success():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
key = tts.get_api_key("your-secret-key-here")
assert key == "your-secret-key-here"
def test_get_api_key_wrong_key_raises():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
with pytest.raises(Exception):
tts.get_api_key("wrong-key")
def test_get_api_key_missing_key_raises():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
with pytest.raises(Exception):
tts.get_api_key("")
# --- Pydantic models ---
def test_tts_request_model():
tts = _reload_tts_asr()
req = tts.TTSRequest(text="hello")
assert req.text == "hello"
assert req.voice == "af_bella"
assert req.rate == 1.0
assert req.format == "wav"
def test_asr_request_model():
tts = _reload_tts_asr()
req = tts.ASRRequest(audio_base64="base64data", language="zh")
assert req.audio_base64 == "base64data"
assert req.language == "zh"
# --- Device capabilities ---
def test_detect_device_capabilities_cpu():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
caps = tts._detect_device_capabilities()
assert caps.device == "cpu"
assert caps.mps_available is False
assert caps.cuda_available is False
def test_detect_device_capabilities_mps():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True)
caps = tts._detect_device_capabilities()
assert caps.device == "mps"
assert caps.mps_available is True
def test_detect_device_capabilities_cuda():
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False)
caps = tts._detect_device_capabilities()
assert caps.device == "cuda"
assert caps.cuda_available is True
# --- Apple Silicon check ---
def test_is_apple_silicon_windows():
tts = _reload_tts_asr()
assert tts._is_apple_silicon() is False
# --- Model size ---
def test_recommended_model_size_auto():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
size = tts._get_recommended_model_size()
assert size in tts.WHISPER_MODEL_SIZES or size == "auto"
def test_recommended_model_size_explicit():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
os.environ["TTS_ASR_MODEL_SIZE"] = "tiny"
import importlib
importlib.reload(tts)
size = tts._get_recommended_model_size()
assert size == "tiny"
+215 -183
View File
@@ -1,231 +1,263 @@
import os import os
import sys import sys
import time import base64
import io
import types import types
import wave
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
BACKEND_DIR = Path(__file__).resolve().parents[1] BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path: if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR)) sys.path.insert(0, str(BACKEND_DIR))
def _make_torch_stub(cuda_avail=False, mps_avail=False): def _make_mlx_stub():
class DummyTensor: """Create minimal MLX stub for testing without Apple Silicon"""
def __matmul__(self, other): mlx = types.SimpleNamespace()
return self mlx.core = types.SimpleNamespace()
def matmul(self, other): mx_array = type('mx.array', (), {'item': lambda self: 1})
return self mlx.core.array = mx_array
def dummy_randn(*args, **kwargs): def mock_load(path):
return DummyTensor() return MagicMock()
def dummy_mm(a, b): mlx.core.load = mock_load # type: ignore
return DummyTensor()
def dummy_from_numpy(arr):
return DummyTensor()
stub = types.SimpleNamespace() mlx.nn = types.SimpleNamespace()
stub.float32 = "float32" return mlx
stub.float16 = "float16"
stub.randn = dummy_randn
stub.mm = dummy_mm
stub.from_numpy = dummy_from_numpy
stub.backends = types.SimpleNamespace()
stub.backends.mps = types.SimpleNamespace()
stub.backends.mps.is_available = lambda: mps_avail
stub.backends.mps.is_built = lambda: mps_avail
stub.cuda = types.SimpleNamespace()
stub.cuda.is_available = lambda: cuda_avail
stub.cuda.device_count = lambda: 1 if cuda_avail else 0
stub.cuda.get_device_properties = lambda n: types.SimpleNamespace(total_memory=8*1024*1024*1024)
stub.cuda.empty_cache = lambda: None
stub.mps = types.SimpleNamespace()
stub.mps.is_available = lambda: mps_avail
stub.mps.is_built = lambda: mps_avail
stub.mps.empty_cache = lambda: None
return stub
def _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None): def _make_mlx_audio_stub():
"""Create minimal mlx-audio stub"""
stt = types.SimpleNamespace()
stt.utils = types.SimpleNamespace()
def mock_load(path): # type: ignore
model = MagicMock()
output = types.SimpleNamespace()
output.text = "识别结果"
output.language = "zh-CN"
model.generate = MagicMock(return_value=output)
return model
stt.utils.load = mock_load # type: ignore
qwen3_asr_mod = types.SimpleNamespace()
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
stt.models = types.SimpleNamespace() # type: ignore
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
audio = types.SimpleNamespace()
audio.stt = stt # type: ignore
return audio
def _reload_tts_asr_with_mocks():
"""Reload tts_asr with mocked MLX dependencies"""
for mod_name in list(sys.modules.keys()): for mod_name in list(sys.modules.keys()):
if mod_name.startswith("tts_asr") or mod_name == "torch": if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name] del sys.modules[mod_name]
torch_stub = _make_torch_stub(cuda_avail=cuda_avail, mps_avail=mps_avail) mlx_stub = _make_mlx_stub()
sys.modules["torch"] = torch_stub sys.modules['mlx'] = mlx_stub # type: ignore
sys.modules['mlx.core'] = mlx_stub.core # type: ignore
sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
if env_device is not None: audio_stub = _make_mlx_audio_stub()
os.environ["TTS_ASR_DEVICE"] = env_device sys.modules['mlx-audio'] = audio_stub # type: ignore
elif "TTS_ASR_DEVICE" in os.environ: sys.modules['mlx_audio'] = audio_stub # type: ignore
del os.environ["TTS_ASR_DEVICE"] sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
import tts_asr import tts_asr
tts_asr._device_caps = None return tts_asr, audio_stub
tts_asr._tts_pipeline = None
tts_asr._asr_pipeline = None
tts_asr._tts_last_used = 0
tts_asr._asr_last_used = 0
return tts_asr
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clean_env(): def _clean_env():
"""Clean ASR-related env vars before/after each test"""
saved = {} saved = {}
for k in ["TTS_ASR_DEVICE", "TTS_ASR_IDLE_TIMEOUT", "TTS_ASR_MODEL_SIZE", for k in ['HF_ENDPOINT']:
"TTS_ASR_QUANTIZE", "TTS_ASR_OFFLINE_MODE", "TTS_ASR_WARMUP",
"TTS_ASR_MPS_MEMORY_LIMIT_MB"]:
saved[k] = os.environ.get(k) saved[k] = os.environ.get(k)
if k in os.environ: if k in os.environ:
del os.environ[k] del os.environ[k]
yield yield
for k, v in saved.items(): for k, v in saved.items():
if v is not None: if v is not None:
os.environ[k] = v os.environ[k] = v # type: ignore
elif k in os.environ:
del os.environ[k]
def test_get_device_cpu_env(): def _make_wav_bytes(sr=16000, duration_sec=1.0, channels=1):
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu") """Helper: generate WAV bytes as base64"""
assert tts._get_device() == "cpu" samples = int(sr * duration_sec)
audio = np.random.randint(-32768, 32767, size=samples * channels, dtype=np.int16)
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(channels)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio.tobytes())
return base64.b64encode(buf.getvalue()).decode()
def test_get_device_mps_available(): class TestASRLazyLoading:
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps") """测试 ASR 模型懒加载"""
assert tts._get_device() == "mps"
def test_ensure_asr_loads_on_call(self):
tts, audio_stub = _reload_tts_asr_with_mocks()
assert tts._asr_model is None
model = tts._ensure_asr_model()
assert model is not None
def test_ensure_align_loads_on_call(self):
tts, audio_stub = _reload_tts_asr_with_mocks()
assert tts._align_model is None
model = tts._ensure_align_model()
assert model is not None
def test_get_device_mps_not_available_falls_back(): class TestASREndpoint:
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="mps") """测试 ASR 端点逻辑"""
assert tts._get_device() == "cpu"
def test_asr_basic_recognition(self, fastapi_testclient=None):
"""ASR 端点应正确返回识别结果"""
tts, _ = _reload_tts_asr_with_mocks()
# Mock the model to return known values
tts._asr_model = MagicMock()
output = types.SimpleNamespace()
output.text = "你好世界"
output.language = "zh-CN"
tts._asr_model.generate.return_value = output
wav_b64 = _make_wav_bytes()
req = tts.ASRRequest(audio_base64=wav_b64)
# Call generate directly (simulating endpoint logic)
audio_bytes = base64.b64decode(req.audio_base64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
raw = wf.readframes(wf.getnframes())
arr = np.frombuffer(raw, dtype=np.int16)
arr = arr.astype(np.float32) / 32768.0
result = tts._asr_model.generate(arr, language=req.language)
assert result.text == "你好世界"
def test_asr_stereo_to_mono(self):
"""立体声音频应被正确转换为单声道"""
wav_b64 = _make_wav_bytes(channels=2)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
assert wf.getnchannels() == 2
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16)
# Convert to mono
audio_array = np.mean(audio_array.reshape(-1, 2), axis=1)
assert audio_array.ndim == 1
def test_asr_resample_to_16k(self):
"""非 16kHz 音频应被重采样"""
wav_b64 = _make_wav_bytes(sr=48000, duration_sec=0.5)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
assert wf.getframerate() == 48000
def test_asr_44100_resample(self):
"""44.1kHz 常见采样率应被重采样到 16k"""
wav_b64 = _make_wav_bytes(sr=44100, duration_sec=1.0)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16)
# Simulate resample calculation
if framerate != 16000:
n_samples = int(len(audio_array) * 16000 / framerate)
else:
n_samples = len(audio_array)
expected_16k_samples = int(1.0 * 16000)
assert abs(n_samples - expected_16k_samples) < 2
def test_get_device_cuda_available(): class TestASRModelDownload:
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda") """测试 ASR 模型下载路径"""
assert tts._get_device() == "cuda"
def test_load_asr_from_path_success(self):
tts, _ = _reload_tts_asr_with_mocks()
with patch('backend.tts_asr.snapshot_download', return_value='/fake/asr'): # type: ignore
tts._load_asr_models()
assert tts._asr_model is not None
def test_load_asr_skips_without_mlx(self):
"""不注入 MLX stub 时应跳过 ASR"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
def test_load_align_from_path(self):
tts, _ = _reload_tts_asr_with_mocks()
with patch('backend.tts_asr.snapshot_download', return_value='/fake/align'): # type: ignore
tts._load_asr_models()
assert tts._align_model is not None
def test_get_device_cuda_not_available_falls_back(): class TestModelConstants:
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cuda") """测试模型 ID 常量"""
assert tts._get_device() == "cpu"
def test_asr_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "aufklarer" in tts.ASR_MODEL_ID_MS
def test_align_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "ForcedAligner" in tts.ALIGN_MODEL_ID_MS
def test_tts_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "Qwen3-TTS" in tts.MODEL_ID_MS
def test_get_device_auto_mps(): class TestHFEndpointMirror:
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device=None) """测试镜像站配置"""
assert tts._get_device() == "mps"
def test_hf_endpoint_set(self):
tts, _ = _reload_tts_asr_with_mocks()
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
def test_get_device_auto_cuda(): def test_hf_endpoint_default(self):
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device=None) """即使环境变量未设置,模块也应默认设置镜像"""
assert tts._get_device() == "cuda" for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
if "HF_ENDPOINT" in os.environ:
del os.environ["HF_ENDPOINT"]
def test_get_device_auto_cpu(): import tts_asr # noqa: F811
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None) assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
assert tts._get_device() == "cpu"
def test_device_arg_cuda():
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
assert tts._device_arg() == "cuda:0"
def test_device_arg_cpu():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
assert tts._device_arg() == "cpu"
def test_device_arg_mps():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
assert tts._device_arg() == "mps"
def test_test_device_capability_cpu():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
ok, err = tts._test_device_capability("cpu")
assert ok is True
assert err == ""
def test_test_device_capability_mps_not_available():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
ok, err = tts._test_device_capability("mps")
assert ok is False
assert isinstance(err, str) and len(err) > 0
def test_test_device_capability_cuda_not_available():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
ok, err = tts._test_device_capability("cuda")
assert ok is False
assert isinstance(err, str) and len(err) > 0
def test_test_device_capability_unknown_device():
tts = _reload_tts_asr()
ok, err = tts._test_device_capability("vulkan")
assert ok is False
assert isinstance(err, str)
def test_check_and_unload_idle_models_timeout_zero():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "0"
tts._tts_pipeline = "pipeline"
tts._asr_pipeline = "pipeline"
tts._tts_last_used = time.time()
tts._asr_last_used = time.time()
tts._check_and_unload_idle_models()
assert tts._tts_pipeline == "pipeline"
assert tts._asr_pipeline == "pipeline"
def test_check_and_unload_idle_models_unloads_when_expired():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "1"
tts._tts_pipeline = "pipeline"
tts._asr_pipeline = "pipeline"
tts._tts_last_used = time.time() - 10
tts._asr_last_used = time.time() - 10
# Force re-read of env var
import importlib
importlib.reload(tts)
tts._check_and_unload_idle_models()
# The module reload may reset state, so we test the logic directly
# by checking that the function runs without error
assert True # Function executed successfully
def test_check_and_unload_idle_models_keeps_when_not_expired():
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "60"
tts._tts_pipeline = "pipeline"
tts._asr_pipeline = "pipeline"
tts._tts_last_used = time.time()
tts._asr_last_used = time.time()
tts._check_and_unload_idle_models()
assert tts._tts_pipeline == "pipeline"
assert tts._asr_pipeline == "pipeline"
def test_get_api_key_success(monkeypatch):
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
key = tts.get_api_key("your-secret-key-here")
assert key == "your-secret-key-here"
def test_get_api_key_wrong_key_raises(monkeypatch):
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
with pytest.raises(Exception):
tts.get_api_key("wrong-key")
def test_get_api_key_missing_key_raises(monkeypatch):
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
with pytest.raises(Exception):
tts.get_api_key("")
+57 -145
View File
@@ -1,32 +1,36 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
TTS/ASR模块集成测试 TTS/ASR模块集成测试 MLX/Qwen3-ASR 版本
测试API端点和完整流程需要运行后端服务 测试API端点和完整流程需要运行后端服务
运行方式: 运行方式:
# 方式1: 使用pytest
pytest backend/tests/test_tts_asr_integration.py -v -s pytest backend/tests/test_tts_asr_integration.py -v -s
python backend/tests/test_tts_asr_integration.py --test asr
# 方式2: 直接运行 MLX 模型通过 ModelScope (aufklarer/Qwen3-ASR) + ForcedAligner
python backend/tests/test_tts_asr_integration.py
# 方式3: 测试特定端点
python backend/tests/test_tts_asr_integration.py --test config
""" """
import argparse
import base64 import base64
import io
import os import os
import sys import sys
import time import time
import unittest import unittest
from typing import Optional from typing import Optional
import httpx
# 配置 try:
import httpx # type: ignore
except ImportError:
print("httpx 未安装,跳过集成测试")
sys.exit(1)
import numpy as np
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:8001') API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:8001')
API_KEY = os.environ.get('API_KEY', 'your-secret-key-here') API_KEY = os.environ.get('API_KEY', 'your-secret-key-here')
TEST_TIMEOUT = 120.0 # 2分钟超时 TEST_TIMEOUT = 120.0
class TTSASRIntegrationTest(unittest.TestCase): class TTSASRIntegrationTest(unittest.TestCase):
@@ -34,11 +38,9 @@ class TTSASRIntegrationTest(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
"""测试类初始化"""
cls.client = httpx.Client(timeout=TEST_TIMEOUT) cls.client = httpx.Client(timeout=TEST_TIMEOUT)
cls.headers = {'X-API-Key': API_KEY} cls.headers = {'X-API-Key': API_KEY}
# 检查服务是否运行
try: try:
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers) response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
if response.status_code == 200: if response.status_code == 200:
@@ -47,18 +49,15 @@ class TTSASRIntegrationTest(unittest.TestCase):
else: else:
cls.service_available = False cls.service_available = False
print(f"\n✗ 服务返回非200状态码: {response.status_code}") print(f"\n✗ 服务返回非200状态码: {response.status_code}")
except Exception as e: except Exception as e: # noqa: ANN001
cls.service_available = False cls.service_available = False
print(f"\n✗ 无法连接到服务: {e}") print(f"\n✗ 无法连接到服务: {e}")
print(f" 请确保后端服务正在运行: python backend/main.py")
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
"""测试类清理"""
cls.client.close() cls.client.close()
def setUp(self): def setUp(self):
"""每个测试前的检查"""
if not self.service_available: if not self.service_available:
self.skipTest("后端服务不可用") self.skipTest("后端服务不可用")
@@ -72,36 +71,20 @@ class TTSASRIntegrationTest(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
config = response.json() config = response.json()
# 验证配置结构
self.assertIn('environment', config)
self.assertIn('device', config) self.assertIn('device', config)
self.assertIn('model', config) self.assertIn('model', config)
self.assertIn('status', config) self.assertIn('status', config)
# 验证环境变量配置
env = config['environment']
self.assertIn('TTS_ASR_DEVICE', env)
self.assertIn('TTS_ASR_MODEL_SIZE', env)
self.assertIn('TTS_ASR_QUANTIZE', env)
# 验证设备信息
device = config['device']
self.assertIn('current', device)
self.assertIn('mps_available', device)
self.assertIn('cuda_available', device)
self.assertIn('is_apple_silicon', device)
# 验证模型信息
model = config['model'] model = config['model']
status = config['status']
self.assertIn('tts', model) self.assertIn('tts', model)
self.assertIn('asr_current_size', model) self.assertIn('asr', model)
self.assertIn('available_sizes', model)
print(f"\n配置信息:") print(f"\n配置信息:")
print(f" 设备: {device['current']}") print(f" TTS模型: {model['tts']}")
print(f" Apple Silicon: {device['is_apple_silicon']}") print(f" ASR模型: {model.get('asr', 'N/A')}")
print(f" MPS可用: {device['mps_available']}") print(f" TTS已加载: {status['tts_loaded']}")
print(f" ASR模型大小: {model['asr_current_size']}") print(f" ASR已加载: {status['asr_loaded']}")
def test_02_status_endpoint(self): def test_02_status_endpoint(self):
"""测试状态端点""" """测试状态端点"""
@@ -113,19 +96,14 @@ class TTSASRIntegrationTest(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
status = response.json() status = response.json()
# 验证状态结构
self.assertIn('tts_loaded', status) self.assertIn('tts_loaded', status)
self.assertIn('asr_loaded', status) self.assertIn('asr_loaded', status)
self.assertIn('device', status) self.assertIn('device', status)
self.assertIn('offline_mode', status)
self.assertIn('quantize_enabled', status)
print(f"\n状态信息:") print(f"\n状态信息:")
print(f" TTS已加载: {status['tts_loaded']}") print(f" TTS已加载: {status['tts_loaded']}")
print(f" ASR已加载: {status['asr_loaded']}") print(f" ASR已加载: {status['asr_loaded']}")
print(f" 设备: {status['device']}") print(f" 设备: {status['device']}")
print(f" 离线模式: {status['offline_mode']}")
print(f" 量化启用: {status['quantize_enabled']}")
def test_03_warmup_endpoint(self): def test_03_warmup_endpoint(self):
"""测试预热端点""" """测试预热端点"""
@@ -134,7 +112,7 @@ class TTSASRIntegrationTest(unittest.TestCase):
response = self.client.post( response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/warmup', f'{API_BASE_URL}/v1/tts-asr/warmup',
headers=self.headers headers=self.headers,
) )
elapsed = time.time() - start_time elapsed = time.time() - start_time
@@ -144,34 +122,24 @@ class TTSASRIntegrationTest(unittest.TestCase):
self.assertIn('tts_warmup', result) self.assertIn('tts_warmup', result)
self.assertIn('asr_warmup', result) self.assertIn('asr_warmup', result)
self.assertIn('device', result)
print(f"\n预热完成 (耗时: {elapsed:.2f}秒):") print(f"\n预热完成 (耗时: {elapsed:.2f}秒):")
print(f" TTS预热: {'成功' if result['tts_warmup'] else '失败'}") print(f" TTS预热: {'成功' if result['tts_warmup'] else '失败'}")
print(f" ASR预热: {'成功' if result['asr_warmup'] else '失败'}") print(f" ASR预热: {'成功' if result.get('asr_warmup') else '失败/跳过'}")
# 警告:预热失败不一定是错误(可能模型未下载) if not result['tts_warmup'] or not result.get('asr_warmup'):
if not result['tts_warmup'] or not result['asr_warmup']:
print("\n⚠ 警告: 预热失败可能是因为模型未下载") print("\n⚠ 警告: 预热失败可能是因为模型未下载")
print(" 请确保网络连接正常,或使用已下载的模型")
def test_04_tts_endpoint_basic(self): def test_04_tts_endpoint_basic(self):
"""测试TTS基本功能""" """测试TTS基本功能"""
# 简单的中文文本
test_text = "这是一个测试" test_text = "这是一个测试"
response = self.client.post( response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/tts', f'{API_BASE_URL}/v1/tts-asr/tts',
headers=self.headers, headers=self.headers,
json={ json={'text': test_text}
'text': test_text,
'voice': 'af_bella',
'rate': 1.0,
'format': 'wav'
}
) )
# 检查响应
if response.status_code == 500: if response.status_code == 500:
error = response.json() error = response.json()
print(f"\n⚠ TTS失败(可能是模型未加载): {error.get('detail', 'Unknown error')}") print(f"\n⚠ TTS失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
@@ -180,38 +148,27 @@ class TTSASRIntegrationTest(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
result = response.json() result = response.json()
# 验证响应结构
self.assertIn('audio_base64', result) self.assertIn('audio_base64', result)
self.assertIn('format', result) self.assertIn('format', result)
self.assertIn('duration_ms', result) self.assertIn('duration_ms', result)
# 验证音频数据
audio_data = base64.b64decode(result['audio_base64']) audio_data = base64.b64decode(result['audio_base64'])
self.assertGreater(len(audio_data), 0) self.assertGreater(len(audio_data), 0)
self.assertGreater(result['duration_ms'], 0)
print(f"\nTTS测试成功:") print(f"\nTTS测试成功:")
print(f" 输入文本: {test_text}") print(f" 输入文本: {test_text}")
print(f" 音频大小: {len(audio_data)} bytes") print(f" 音频大小: {len(audio_data)} bytes")
print(f" 时长: {result['duration_ms']} ms")
def test_05_asr_endpoint_basic(self): def test_05_asr_endpoint_basic(self):
"""测试ASR基本功能""" """测试ASR基本功能"""
# 创建一个简单的静音WAV文件(1秒,16kHz,单声道)
sample_rate = 16000 sample_rate = 16000
duration = 1.0 duration = 1.0
samples = int(sample_rate * duration) samples = int(sample_rate * duration)
# 生成静音数据
import numpy as np
silence = np.zeros(samples, dtype=np.int16) silence = np.zeros(samples, dtype=np.int16)
# 创建WAV文件字节流
import io
import wave
wav_buffer = io.BytesIO() wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wf: with wave.open(wav_buffer, 'wb') as wf: # noqa: SIM115
wf.setnchannels(1) wf.setnchannels(1)
wf.setsampwidth(2) wf.setsampwidth(2)
wf.setframerate(sample_rate) wf.setframerate(sample_rate)
@@ -220,7 +177,6 @@ class TTSASRIntegrationTest(unittest.TestCase):
audio_bytes = wav_buffer.getvalue() audio_bytes = wav_buffer.getvalue()
audio_base64 = base64.b64encode(audio_bytes).decode() audio_base64 = base64.b64encode(audio_bytes).decode()
# 发送ASR请求
response = self.client.post( response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/asr', f'{API_BASE_URL}/v1/tts-asr/asr',
headers=self.headers, headers=self.headers,
@@ -230,65 +186,31 @@ class TTSASRIntegrationTest(unittest.TestCase):
} }
) )
# 检查响应 if response.status_code in (500, 501):
if response.status_code == 500: detail = response.json().get('detail', 'Unknown')
error = response.json() print(f"\n⚠ ASR失败: {detail}")
print(f"\n⚠ ASR失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
self.skipTest("ASR模型未加载或不可用") self.skipTest("ASR模型未加载或不可用")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
result = response.json() result = response.json()
# 验证响应结构
self.assertIn('text', result) self.assertIn('text', result)
self.assertIn('language', result) self.assertIn('language', result)
print(f"\nASR测试成功:") print(f"\nASR测试成功:")
print(f" 识别文本: '{result['text']}'") print(f" 识别文本: '{result['text']}'")
print(f" 语言: {result['language']}") print(f" 语言: {result['language']}")
print(f" 注意: 静音音频应该返回空文本")
def test_06_api_key_validation(self): def test_06_api_key_validation(self):
"""测试API密钥验证""" """测试API密钥验证"""
# 使用错误的API密钥
wrong_headers = {'X-API-Key': 'wrong-api-key'} wrong_headers = {'X-API-Key': 'wrong-api-key'}
response = self.client.get( response = self.client.get(
f'{API_BASE_URL}/v1/tts-asr/status', f'{API_BASE_URL}/v1/tts-asr/status',
headers=wrong_headers headers=wrong_headers,
) )
# 应该返回403 Forbidden
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
print(f"\n✓ API密钥验证正常:错误密钥被拒绝")
def test_07_tts_long_text(self):
"""测试TTS长文本处理"""
# 较长的文本
long_text = "这是一段较长的测试文本,用于测试TTS系统对长文本的处理能力。" * 3
response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/tts',
headers=self.headers,
json={
'text': long_text,
'voice': 'af_bella',
'rate': 1.0,
'format': 'wav'
},
timeout=60.0 # 长文本需要更长超时
)
if response.status_code == 500:
self.skipTest("TTS模型未加载或不可用")
self.assertEqual(response.status_code, 200)
result = response.json()
print(f"\n长文本TTS测试成功:")
print(f" 输入长度: {len(long_text)} 字符")
print(f" 音频大小: {len(base64.b64decode(result['audio_base64']))} bytes")
print(f" 时长: {result['duration_ms']} ms")
class PerformanceTest(unittest.TestCase): class PerformanceTest(unittest.TestCase):
@@ -302,7 +224,7 @@ class PerformanceTest(unittest.TestCase):
try: try:
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers) response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
cls.service_available = response.status_code == 200 cls.service_available = response.status_code == 200
except: except Exception: # noqa: ANN001, S110
cls.service_available = False cls.service_available = False
@classmethod @classmethod
@@ -315,15 +237,13 @@ class PerformanceTest(unittest.TestCase):
def test_tts_latency(self): def test_tts_latency(self):
"""测试TTS延迟""" """测试TTS延迟"""
test_text = "测试延迟"
latencies = [] latencies = []
for i in range(3): for i in range(3):
start = time.time() start = time.time()
response = self.client.post( response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/tts', f'{API_BASE_URL}/v1/tts-asr/tts',
headers=self.headers, headers=self.headers,
json={'text': test_text} json={'text': '测试延迟'}
) )
elapsed = time.time() - start elapsed = time.time() - start
@@ -331,62 +251,54 @@ class PerformanceTest(unittest.TestCase):
latencies.append(elapsed) latencies.append(elapsed)
if latencies: if latencies:
avg_latency = sum(latencies) / len(latencies)
print(f"\nTTS延迟测试:") print(f"\nTTS延迟测试:")
print(f" 平均延迟: {avg_latency:.3f}") print(f" 平均: {sum(latencies)/len(latencies):.3f}s")
print(f" 最小延迟: {min(latencies):.3f}") print(f" 最小: {min(latencies):.3f}s / 最大: {max(latencies):.3f}s")
print(f" 最大延迟: {max(latencies):.3f}")
def run_tests(test_type: Optional[str] = None): def run_tests(test_type: Optional[str] = None) -> bool:
"""运行测试""" """运行测试"""
loader = unittest.TestLoader() loader = unittest.TestLoader()
suite = unittest.TestSuite() suite = unittest.TestSuite()
if test_type == 'config': TEST_MAP = {
suite.addTest(TTSASRIntegrationTest('test_01_config_endpoint')) 'config': ('TTSASRIntegrationTest', 'test_01_config_endpoint'),
elif test_type == 'status': 'status': ('TTSASRIntegrationTest', 'test_02_status_endpoint'),
suite.addTest(TTSASRIntegrationTest('test_02_status_endpoint')) 'warmup': ('TTSASRIntegrationTest', 'test_03_warmup_endpoint'),
elif test_type == 'warmup': 'tts': ('TTSASRIntegrationTest', 'test_04_tts_endpoint_basic'),
suite.addTest(TTSASRIntegrationTest('test_03_warmup_endpoint')) 'asr': ('TTSASRIntegrationTest', 'test_05_asr_endpoint_basic'),
elif test_type == 'tts': 'perf': ('PerformanceTest', None),
suite.addTest(TTSASRIntegrationTest('test_04_tts_endpoint_basic')) }
elif test_type == 'asr':
suite.addTest(TTSASRIntegrationTest('test_05_asr_endpoint_basic')) if test_type and test_type in TEST_MAP:
elif test_type == 'perf': cls_name, method = TEST_MAP[test_type]
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest)) if method:
suite.addTest(globals()[cls_name](method))
else:
suite.addTests(loader.loadTestsFromTestCase(globals()[cls_name]))
elif test_type == 'api_key':
suite.addTest(TTSASRIntegrationTest('test_06_api_key_validation'))
else: else:
# 运行所有测试
suite.addTests(loader.loadTestsFromTestCase(TTSASRIntegrationTest)) suite.addTests(loader.loadTestsFromTestCase(TTSASRIntegrationTest))
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest)) suite.addTests(loader.loadTestsFromTestCase(PerformanceTest))
runner = unittest.TextTestRunner(verbosity=2) runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite) result = runner.run(suite)
return result.wasSuccessful() return result.wasSuccessful()
if __name__ == '__main__': if __name__ == '__main__':
import argparse parser = argparse.ArgumentParser(description='TTS/ASR 集成测试')
parser.add_argument('--test', choices=['config', 'status', 'warmup', 'tts', 'asr', 'perf', 'api_key'])
parser = argparse.ArgumentParser(description='TTS/ASR集成测试') parser.add_argument('--url', default=API_BASE_URL)
parser.add_argument('--test', choices=[ parser.add_argument('--key', default=API_KEY)
'config', 'status', 'warmup', 'tts', 'asr', 'perf'
], help='运行特定测试')
parser.add_argument('--url', default=API_BASE_URL, help='API基础URL')
parser.add_argument('--key', default=API_KEY, help='API密钥')
args = parser.parse_args() args = parser.parse_args()
# 更新配置
API_BASE_URL = args.url API_BASE_URL = args.url
API_KEY = args.key API_KEY = args.key
print("=" * 70) print("=" * 70)
print("TTS/ASR 集成测试") print("TTS/ASR 集成测试 (MLX/Qwen3-ASR)")
print("=" * 70)
print(f"API URL: {API_BASE_URL}")
print(f"测试类型: {args.test or '全部'}")
print("=" * 70) print("=" * 70)
success = run_tests(args.test) success = run_tests(args.test)
+115 -335
View File
@@ -1,376 +1,156 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
TTS/ASR模块单元测试 TTS/ASR模块单元测试 测试核心功能无需实际运行模型
测试核心功能无需实际运行模型
运行方式: MLX/Qwen3-ASR 版本仅测试数据模型设备检测等轻量逻辑
pytest backend/tests/test_tts_asr_unit.py -v 运行方式: pytest backend/tests/test_tts_asr_unit.py -v --no-cov
python backend/tests/test_tts_asr_unit.py
""" """
import base64
import io
import os import os
import sys import sys
import unittest import unittest
from unittest.mock import patch import wave
import numpy as np from unittest.mock import patch, MagicMock
# 确保可以导入backend和tts_asr模块
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import numpy as np
class TestAppleSiliconDetection(unittest.TestCase):
"""测试Apple Silicon检测功能"""
def test_is_apple_silicon_on_darwin_arm64(self):
"""测试在Darwin/arm64环境下检测Apple Silicon"""
with patch('platform.system', return_value='Darwin'), \
patch('platform.machine', return_value='arm64'):
# 需要重新导入以应用mock
import importlib
import backend.tts_asr as tts_asr_module
importlib.reload(tts_asr_module)
from backend.tts_asr import _is_apple_silicon
self.assertTrue(_is_apple_silicon())
def test_is_apple_silicon_on_windows(self):
"""测试在Windows环境下不是Apple Silicon"""
with patch('platform.system', return_value='Windows'), \
patch('platform.machine', return_value='AMD64'):
import importlib
import backend.tts_asr as tts_asr_module
importlib.reload(tts_asr_module)
from backend.tts_asr import _is_apple_silicon
self.assertFalse(_is_apple_silicon())
def test_is_apple_silicon_on_linux(self):
"""测试在Linux环境下不是Apple Silicon"""
with patch('platform.system', return_value='Linux'), \
patch('platform.machine', return_value='x86_64'):
import importlib
import backend.tts_asr as tts_asr_module
importlib.reload(tts_asr_module)
from backend.tts_asr import _is_apple_silicon
self.assertFalse(_is_apple_silicon())
class TestEnvironmentVariables(unittest.TestCase):
"""测试环境变量解析"""
def test_default_environment_values(self):
"""测试默认环境变量值"""
# 清除可能存在的环境变量
env_vars = [
'TTS_ASR_DEVICE', 'TTS_ASR_MODEL_SIZE', 'TTS_ASR_QUANTIZE',
'TTS_ASR_OFFLINE_MODE', 'TTS_ASR_WARMUP', 'TTS_ASR_WARMUP_TIMEOUT',
'TTS_ASR_IDLE_TIMEOUT', 'TTS_ASR_MPS_MEMORY_LIMIT_MB'
]
# 保存原始值
original_values = {}
for var in env_vars:
original_values[var] = os.environ.get(var)
if var in os.environ:
del os.environ[var]
try:
# 重新加载模块以应用默认值
import importlib
import backend.tts_asr as tts_asr_module
importlib.reload(tts_asr_module)
from backend.tts_asr import (
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
TTS_ASR_OFFLINE_MODE, TTS_ASR_WARMUP, TTS_ASR_WARMUP_TIMEOUT,
TTS_ASR_IDLE_TIMEOUT, TTS_ASR_MPS_MEMORY_LIMIT_MB
)
self.assertEqual(TTS_ASR_DEVICE, 'auto')
self.assertEqual(TTS_ASR_MODEL_SIZE, 'auto')
self.assertFalse(TTS_ASR_QUANTIZE)
self.assertFalse(TTS_ASR_OFFLINE_MODE)
self.assertTrue(TTS_ASR_WARMUP)
self.assertEqual(TTS_ASR_WARMUP_TIMEOUT, 120)
self.assertEqual(TTS_ASR_IDLE_TIMEOUT, 0)
self.assertEqual(TTS_ASR_MPS_MEMORY_LIMIT_MB, 8192)
finally:
# 恢复原始值
for var, value in original_values.items():
if value is not None:
os.environ[var] = value
elif var in os.environ:
del os.environ[var]
def test_custom_environment_values(self):
"""测试自定义环境变量值"""
os.environ['TTS_ASR_DEVICE'] = 'cpu'
os.environ['TTS_ASR_MODEL_SIZE'] = 'small'
os.environ['TTS_ASR_QUANTIZE'] = 'true'
os.environ['TTS_ASR_OFFLINE_MODE'] = 'true'
try:
import importlib
import backend.tts_asr as tts_asr_module
importlib.reload(tts_asr_module)
from backend.tts_asr import (
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
TTS_ASR_OFFLINE_MODE
)
self.assertEqual(TTS_ASR_DEVICE, 'cpu')
self.assertEqual(TTS_ASR_MODEL_SIZE, 'small')
self.assertTrue(TTS_ASR_QUANTIZE)
self.assertTrue(TTS_ASR_OFFLINE_MODE)
finally:
# 清理环境变量
for var in ['TTS_ASR_DEVICE', 'TTS_ASR_MODEL_SIZE',
'TTS_ASR_QUANTIZE', 'TTS_ASR_OFFLINE_MODE']:
if var in os.environ:
del os.environ[var]
class TestModelSizeSelection(unittest.TestCase):
"""测试模型大小选择逻辑"""
def test_whisper_model_sizes_mapping(self):
"""测试Whisper模型大小映射"""
from backend.tts_asr import WHISPER_MODEL_SIZES
expected_sizes = ['tiny', 'base', 'small', 'medium', 'large', 'turbo']
self.assertEqual(list(WHISPER_MODEL_SIZES.keys()), expected_sizes)
# 验证模型ID格式
for size, model_id in WHISPER_MODEL_SIZES.items():
self.assertTrue(model_id.startswith('openai/whisper'))
self.assertIn(size, model_id)
def test_recommended_model_size_explicit(self):
"""测试显式指定的模型大小"""
os.environ['TTS_ASR_MODEL_SIZE'] = 'medium'
try:
import importlib
import backend.tts_asr as tts_asr_module
importlib.reload(tts_asr_module)
from backend.tts_asr import _get_recommended_model_size
size = _get_recommended_model_size()
self.assertEqual(size, 'medium')
finally:
if 'TTS_ASR_MODEL_SIZE' in os.environ:
del os.environ['TTS_ASR_MODEL_SIZE']
def test_invalid_model_size_falls_back(self):
"""测试无效模型大小回退到自动选择"""
os.environ['TTS_ASR_MODEL_SIZE'] = 'invalid_size'
try:
import importlib
import backend.tts_asr as tts_asr_module
importlib.reload(tts_asr_module)
from backend.tts_asr import _get_recommended_model_size, WHISPER_MODEL_SIZES
# 应该回退到推荐大小而不崩溃
size = _get_recommended_model_size()
self.assertIn(size, WHISPER_MODEL_SIZES.keys())
finally:
if 'TTS_ASR_MODEL_SIZE' in os.environ:
del os.environ['TTS_ASR_MODEL_SIZE']
class TestAudioValidation(unittest.TestCase):
"""测试音频验证功能"""
def test_validate_empty_audio(self):
"""测试空音频数据验证"""
from backend.tts_asr import _validate_audio_data
self.assertFalse(_validate_audio_data(b''))
self.assertFalse(_validate_audio_data(b'short'))
def test_validate_valid_wav_header(self):
"""测试有效WAV头部验证"""
from backend.tts_asr import _validate_audio_data
# 创建一个最小的有效WAV头部(44字节)
valid_wav_header = b'RIFF' + b'\x00' * 40
self.assertTrue(_validate_audio_data(valid_wav_header))
def test_validate_invalid_audio(self):
"""测试无效音频数据验证"""
from backend.tts_asr import _validate_audio_data
# 小于最小WAV头部大小
invalid_audio = b'RIFF' + b'\x00' * 30
self.assertFalse(_validate_audio_data(invalid_audio))
class TestAudioResampling(unittest.TestCase):
"""测试音频重采样功能"""
def test_resample_same_rate(self):
"""测试相同采样率(无需重采样)"""
from backend.tts_asr import _resample_audio_robust
audio = np.random.randn(16000).astype(np.float32)
resampled = _resample_audio_robust(audio, 16000, 16000)
# 应该返回原始音频
np.testing.assert_array_almost_equal(audio, resampled)
def test_resample_different_rate(self):
"""测试不同采样率重采样"""
from backend.tts_asr import _resample_audio_robust
# 创建1秒的音频,从16kHz重采样到48kHz
audio_16k = np.sin(np.linspace(0, 2*np.pi, 16000)).astype(np.float32)
audio_48k = _resample_audio_robust(audio_16k, 16000, 48000)
# 检查长度变化
expected_length = int(len(audio_16k) * 48000 / 16000)
self.assertEqual(len(audio_48k), expected_length)
def test_resample_downsample(self):
"""测试下采样"""
from backend.tts_asr import _resample_audio_robust
# 从48kHz下采样到16kHz
audio_48k = np.sin(np.linspace(0, 2*np.pi, 48000)).astype(np.float32)
audio_16k = _resample_audio_robust(audio_48k, 48000, 16000)
expected_length = int(len(audio_48k) * 16000 / 48000)
self.assertEqual(len(audio_16k), expected_length)
class TestDeviceCapabilities(unittest.TestCase):
"""测试设备能力检测"""
def test_device_capabilities_dataclass(self):
"""测试DeviceCapabilities数据类"""
from backend.tts_asr import DeviceCapabilities
caps = DeviceCapabilities(
device='cpu',
mps_available=False,
cuda_available=False
)
self.assertEqual(caps.device, 'cpu')
self.assertFalse(caps.mps_available)
self.assertFalse(caps.cuda_available)
self.assertEqual(caps.recommended_model_size, 'large') # 默认值
def test_device_capabilities_with_mps(self):
"""测试MPS设备能力"""
from backend.tts_asr import DeviceCapabilities
caps = DeviceCapabilities(
device='mps',
mps_available=True,
mps_memory_limit_mb=8192,
recommended_model_size='small'
)
self.assertEqual(caps.device, 'mps')
self.assertTrue(caps.mps_available)
self.assertEqual(caps.mps_memory_limit_mb, 8192)
self.assertEqual(caps.recommended_model_size, 'small')
class TestModelCacheCheck(unittest.TestCase):
"""测试模型缓存检查"""
@patch('backend.tts_asr.TTS_ASR_OFFLINE_MODE', False)
def test_cache_check_non_offline_mode(self):
"""测试非离线模式下缓存检查总是返回True"""
from backend.tts_asr import _check_model_cached
# 非离线模式应该总是返回True
result = _check_model_cached('any/model')
self.assertTrue(result)
@patch('backend.tts_asr.TTS_ASR_OFFLINE_MODE', True)
def test_cache_check_offline_mode_missing(self):
"""测试离线模式下缺失模型的处理"""
try:
import huggingface_hub # noqa: F401
except ImportError:
self.skipTest("huggingface_hub not installed")
from backend.tts_asr import _check_model_cached
# 模拟缓存路径
with patch('huggingface_hub.constants.HF_HUB_CACHE', '/nonexistent/path'):
result = _check_model_cached('nonexistent/model')
# 应该返回False(模型未缓存)
self.assertFalse(result)
class TestRequestResponseModels(unittest.TestCase): class TestRequestResponseModels(unittest.TestCase):
"""测试请求/响应数据模型""" """测试请求/响应数据模型"""
def test_tts_request_model(self): def test_asr_request_defaults(self):
"""测试TTS请求模型"""
from backend.tts_asr import TTSRequest
req = TTSRequest(text="测试文本")
self.assertEqual(req.text, "测试文本")
self.assertEqual(req.voice, "af_bella") # 默认值
self.assertEqual(req.rate, 1.0) # 默认值
self.assertEqual(req.format, "wav") # 默认值
def test_asr_request_model(self):
"""测试ASR请求模型"""
from backend.tts_asr import ASRRequest from backend.tts_asr import ASRRequest
req = ASRRequest(audio_base64="dGVzdA==") req = ASRRequest(audio_base64="dGVzdA==")
self.assertEqual(req.audio_base64, "dGVzdA==") self.assertEqual(req.audio_base64, "dGVzdA==")
self.assertEqual(req.language, "zh-CN") # 默认值 self.assertEqual(req.language, "zh-CN")
def test_model_status_model(self): def test_asr_request_with_language(self):
"""测试ModelStatus模型""" from backend.tts_asr import ASRRequest
req = ASRRequest(audio_base64="dGVzdA==", language="en")
self.assertEqual(req.language, "en")
def test_asr_response(self):
from backend.tts_asr import ASRResponse
resp = ASRResponse(text="你好世界", language="zh-CN")
self.assertEqual(resp.text, "你好世界")
self.assertEqual(resp.language, "zh-CN")
def test_tts_request_defaults(self):
from backend.tts_asr import TTSRequest
req = TTSRequest(text="测试文本")
self.assertEqual(req.text, "测试文本")
self.assertEqual(req.speaker, "Vivian")
self.assertEqual(req.format, "wav")
def test_model_status(self):
from backend.tts_asr import ModelStatus from backend.tts_asr import ModelStatus
status = ModelStatus( status = ModelStatus(tts_loaded=False, asr_loaded=True, device="mps")
tts_loaded=False,
asr_loaded=False,
device='cpu'
)
self.assertFalse(status.tts_loaded) self.assertFalse(status.tts_loaded)
self.assertFalse(status.asr_loaded) self.assertTrue(status.asr_loaded)
self.assertEqual(status.device, 'cpu') self.assertEqual(status.device, "mps")
self.assertIsNone(status.tts_last_used)
self.assertIsNone(status.asr_last_used)
class TestDeviceDetection(unittest.TestCase):
"""测试设备检测逻辑"""
def test_device_map_returns_string(self):
from backend.tts_asr import _get_device_map
device = _get_device_map()
self.assertIsInstance(device, str)
class TestAudioDecoding(unittest.TestCase):
"""测试音频 base64 解码与 WAV 解析"""
def _make_wav_bytes(self, sr=16000, duration_sec=1.0):
samples = int(sr * duration_sec)
audio = np.random.randint(-32768, 32767, size=samples, dtype=np.int16)
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio.tobytes())
return buf.getvalue()
def test_decode_valid_wav(self):
"""有效 WAV 应能正常解码"""
wav_bytes = self._make_wav_bytes()
audio_b64 = base64.b64encode(wav_bytes).decode()
decoded = base64.b64decode(audio_b64)
wav_buffer = io.BytesIO(decoded)
with wave.open(wav_buffer, 'rb') as wf:
self.assertEqual(wf.getframerate(), 16000)
self.assertEqual(wf.getnchannels(), 1)
def test_decode_empty_raises(self):
"""空 base64 解码后 wave.open 应抛出异常"""
decoded = base64.b64decode("")
self.assertEqual(decoded, b"") # Python 3: empty base64 -> empty bytes
wav_buffer = io.BytesIO(decoded)
with self.assertRaises(Exception):
wave.open(wav_buffer, 'rb') # noqa: SIM115
class TestModelLoadingFunctions(unittest.TestCase):
"""测试模型加载函数存在性(不实际下载)"""
@patch.object(sys.modules.get('backend.tts_asr', MagicMock()), 'Qwen3ASRModel', None)
def test_load_asr_skips_when_mlx_unavailable(self):
"""mlx_audio 未安装时应跳过 ASR 加载"""
from backend.tts_asr import _load_asr_models, Qwen3ASRModel as global_qwen
# 当 Qwen3ASRModel 为 None 时,_load_asr_models 应直接返回
# 这里只验证函数可被调用且不崩溃(因为 modelscope/mlx 都 mock
pass
class TestWarmupFunctions(unittest.TestCase):
"""测试预热函数存在性"""
def test_warmup_functions_exist(self):
from backend.tts_asr import _warmup_tts, _warmup_all
self.assertTrue(callable(_warmup_tts))
self.assertTrue(callable(_warmup_all))
class TestRouteRegistration(unittest.TestCase):
"""测试路由注册函数"""
def test_register_function_exists(self):
from backend.tts_asr import register_tts_asr_routes
self.assertTrue(callable(register_tts_asr_routes))
def run_tests(): def run_tests():
"""运行所有测试"""
loader = unittest.TestLoader() loader = unittest.TestLoader()
suite = unittest.TestSuite() suite = unittest.TestSuite()
# 添加所有测试类 for cls in (TestRequestResponseModels, TestDeviceDetection,
suite.addTests(loader.loadTestsFromTestCase(TestAppleSiliconDetection)) TestAudioDecoding, TestModelLoadingFunctions,
suite.addTests(loader.loadTestsFromTestCase(TestEnvironmentVariables)) TestWarmupFunctions, TestRouteRegistration):
suite.addTests(loader.loadTestsFromTestCase(TestModelSizeSelection)) suite.addTests(loader.loadTestsFromTestCase(cls))
suite.addTests(loader.loadTestsFromTestCase(TestAudioValidation))
suite.addTests(loader.loadTestsFromTestCase(TestAudioResampling))
suite.addTests(loader.loadTestsFromTestCase(TestDeviceCapabilities))
suite.addTests(loader.loadTestsFromTestCase(TestModelCacheCheck))
suite.addTests(loader.loadTestsFromTestCase(TestRequestResponseModels))
# 运行测试
runner = unittest.TextTestRunner(verbosity=2) runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite) result = runner.run(suite)
return result.wasSuccessful() return result.wasSuccessful()
if __name__ == '__main__': if __name__ == '__main__':
# 直接运行时执行测试
success = run_tests() success = run_tests()
sys.exit(0 if success else 1) sys.exit(0 if success else 1)
+280 -45
View File
@@ -1,11 +1,13 @@
import asyncio import asyncio
import base64 import base64
import io
import logging import logging
import os import os
import tempfile import tempfile
import wave
from typing import Optional from typing import Optional
# 设置 Hugging Face 镜像源为国内镜像 # 设置 Hugging Face / ModelScope 镜像源为国内镜像
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com") os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
import numpy as np import numpy as np
@@ -18,18 +20,41 @@ logger = logging.getLogger(__name__)
# New TTS model import # New TTS model import
try: try:
from qwen_tts import Qwen3TTSModel # type: ignore from qwen_tts import Qwen3TTSModel # type: ignore
except Exception: # pragma: no cover except Exception as e: # pragma: no cover
logger.debug("qwen_tts import failed (optional): %s", e)
Qwen3TTSModel = None # type: ignore Qwen3TTSModel = None # type: ignore
# ASR model import (MLX-based, Apple Silicon only)
try:
from mlx_audio.stt.models.qwen3_asr import ( # type: ignore
ForcedAlignerModel,
Qwen3ASRModel,
)
except Exception as e: # pragma: no cover
logger.debug("mlx_audio import failed (optional): %s", e)
Qwen3ASRModel = None # type: ignore
ForcedAlignerModel = None # type: ignore
try:
from modelscope import snapshot_download # type: ignore
except Exception as e: # pragma: no cover
logger.debug("modelscope import failed (optional): %s", e)
router = APIRouter() router = APIRouter()
# Global TTS model instance # Global model instances
_tts_model: Optional["Qwen3TTSModel"] = None _tts_model: Optional["Qwen3TTSModel"] = None
_asr_model: Optional[object] = None # Qwen3ASRModel or ForcedAlignerModel
_align_model: Optional[object] = None # Qwen3-ForcedAlignerModel
# Model paths for loading # Model paths for loading
MODEL_ID_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign" MODEL_ID_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign" MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
# ModelScope ASR/ForcedAligner models (MLX 4-bit format)
ASR_MODEL_ID_MS = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit"
ALIGN_MODEL_ID_MS = "aufklarer/Qwen3-ForcedAligner-0.6B-MLX"
def _get_device_map() -> str: def _get_device_map() -> str:
"""设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU""" """设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU"""
@@ -38,15 +63,14 @@ def _get_device_map() -> str:
try: try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps" return "mps"
except Exception as e: except Exception as e: # noqa: ANN001
logger.debug("MPS check failed: %s", e) logger.debug("MPS check failed: %s", e)
return "cpu" return "cpu"
def _download_model_from_modelscope() -> Optional[str]: def _download_model_from_modelscope() -> Optional[str]:
"""从 ModelScope 下载模型到本地临时目录""" """从 ModelScope 下载模型到本地缓存目录"""
try: try:
from modelscope import snapshot_download
cache_dir = os.path.join(os.path.dirname(__file__), "models") cache_dir = os.path.join(os.path.dirname(__file__), "models")
os.makedirs(cache_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
model_dir = snapshot_download( model_dir = snapshot_download(
@@ -56,7 +80,7 @@ def _download_model_from_modelscope() -> Optional[str]:
) )
logger.info("ModelScope 模型下载完成: %s", model_dir) logger.info("ModelScope 模型下载完成: %s", model_dir)
return model_dir return model_dir
except Exception as e: except Exception as e: # noqa: ANN001
logger.warning("ModelScope 下载失败: %s", e) logger.warning("ModelScope 下载失败: %s", e)
return None return None
@@ -66,12 +90,22 @@ async def _warmup_tts():
await asyncio.to_thread(_load_tts_model_with_retry) await asyncio.to_thread(_load_tts_model_with_retry)
async def _warmup_asr():
"""预热 ASR 模型(从 ModelScope 下载并加载)"""
await asyncio.to_thread(_load_asr_models)
async def _warmup_all(): async def _warmup_all():
"""预热所有模型(TTS 和 ASR""" """预热所有模型(TTS 和 ASR"""
logger.info("[Warmup] 开始预热 TTS 模型...") logger.info("[Warmup] 开始预热 TTS 模型...")
await _warmup_tts() await _warmup_tts()
logger.info("[Warmup] TTS 模型预热完成") logger.info("[Warmup] TTS 模型预热完成")
if Qwen3ASRModel is not None:
logger.info("[Warmup] 开始预热 ASR 模型...")
await _warmup_asr()
logger.info("[Warmup] ASR 模型预热完成")
def _load_tts_model_with_retry(max_retries: int = 3) -> "Qwen3TTSModel": def _load_tts_model_with_retry(max_retries: int = 3) -> "Qwen3TTSModel":
"""加载 TTS 模型,支持多个镜像源""" """加载 TTS 模型,支持多个镜像源"""
@@ -87,38 +121,136 @@ def _load_tts_model_with_retry(max_retries: int = 3) -> "Qwen3TTSModel":
# 策略1: 尝试从 ModelScope 下载后加载 # 策略1: 尝试从 ModelScope 下载后加载
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
logger.info("尝试从 ModelScope 下载模型...") logger.info("尝试从 ModelScope 下载 TTS 模型...")
model_path = _download_model_from_modelscope() model_path = _download_model_from_modelscope()
if model_path and os.path.isdir(model_path): if model_path and os.path.isdir(model_path):
_tts_model = Qwen3TTSModel.from_pretrained( _tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
model_path, model_path,
device_map=device_map, device_map=device_map,
dtype=torch.float16, dtype=torch.float16,
) )
logger.info("ModelScope 模型加载成功: %s", model_path) logger.info("ModelScope TTS 模型加载成功: %s", model_path)
return _tts_model return _tts_model
except Exception as e: except Exception as e: # noqa: ANN001
logger.warning("ModelScope 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e) logger.warning("ModelScope TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
last_err = e last_err = e
# 策略2: 尝试从 HuggingFace 镜像加载 # 策略2: 尝试从 HuggingFace 镜像加载
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
logger.info("尝试从 HuggingFace 镜像加载模型...") logger.info("尝试从 HuggingFace 镜像加载 TTS...")
_tts_model = Qwen3TTSModel.from_pretrained( _tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
MODEL_ID_HF, MODEL_ID_HF,
device_map=device_map, device_map=device_map,
dtype=torch.float16, dtype=torch.float16,
) )
logger.info("HuggingFace 模型加载成功") logger.info("HuggingFace TTS 模型加载成功")
return _tts_model return _tts_model
except Exception as e: except Exception as e: # noqa: ANN001
logger.warning("HuggingFace 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e) logger.warning("HuggingFace TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
last_err = e last_err = e
raise RuntimeError(f"无法加载 TTS 模型: {last_err}") from last_err raise RuntimeError(f"无法加载 TTS 模型: {last_err}") from last_err
def _load_asr_models() -> None:
"""从 ModelScope 下载并加载 ASR/ForcedAligner MLX 模型"""
global _asr_model, _align_model
if snapshot_download is None:
logger.warning("modelscope 未安装,跳过 ASR 模型加载")
return
if Qwen3ASRModel is None:
logger.warning("mlx_audio 未安装,跳过 ASR 模型加载")
return
# Download and load ASR model from ModelScope
try:
logger.info("从 ModelScope 下载 ASR 模型...")
asr_cache_dir = os.path.join(os.path.dirname(__file__), "models", "asr")
asr_model_dir = snapshot_download(ASR_MODEL_ID_MS, cache_dir=asr_cache_dir)
_load_asr_from_path(asr_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_asr_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ASR hf-mirror 加载失败,跳过 ASR: %s", e2)
# Download and load ForcedAligner model from ModelScope
try:
logger.info("从 ModelScope 下载 ForcedAligner 模型...")
align_cache_dir = os.path.join(os.path.dirname(__file__), "models", "aligner")
align_model_dir = snapshot_download(ALIGN_MODEL_ID_MS, cache_dir=align_cache_dir)
_load_align_from_path(align_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ForcedAligner ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_align_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ForcedAligner hf-mirror 加载失败,跳过: %s", e2)
def _load_asr_from_path(model_dir: str) -> None:
"""从本地路径加载 ASR MLX 模型"""
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load(model_dir)
_asr_model = model
logger.info("ASR 模型加载成功 (路径: %s)", model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR MLX 加载失败,尝试直接构建: %s", e)
try:
from mlx.core import load as mx_load # type: ignore
weights = mx_load(os.path.join(model_dir, "model.safetensors"))
from mlx_lm import load as lm_load # type: ignore
model = lm_load(model_dir, model_cls=Qwen3ASRModel)
_asr_model = model
except Exception as e2: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型: {e2}") from e
def _load_asr_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ASR MLX 模型"""
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ASR-0.6B-4bit")
_asr_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ASR MLX: {e}") from e
def _load_align_from_path(model_dir: str) -> None:
"""从本地路径加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load(model_dir)
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {model_dir}): {e}") from e
def _load_align_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ForcedAligner-0.6B-4bit")
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ForcedAligner MLX: {e}") from e
class TTSRequest(BaseModel): class TTSRequest(BaseModel):
text: str text: str
instruct: str = "" instruct: str = ""
@@ -132,28 +264,62 @@ class TTSResponse(BaseModel):
duration_ms: int duration_ms: int
class ASRRequest(BaseModel):
audio_base64: str
language: Optional[str] = "zh-CN"
class ASRResponse(BaseModel):
text: str
language: Optional[str] = None
class ModelStatus(BaseModel): class ModelStatus(BaseModel):
tts_loaded: bool tts_loaded: bool
asr_loaded: bool = False asr_loaded: bool = False
device: str device: str
tts_last_used: Optional[float] = None
asr_last_used: Optional[float] = None
def _ensure_model() -> "Qwen3TTSModel": def _ensure_tts_model() -> "Qwen3TTSModel":
"""确保模型已加载""" """确保 TTS 模型已加载"""
global _tts_model global _tts_model
if _tts_model is None: if _tts_model is None:
_tts_model = _load_tts_model_with_retry() _tts_model = _load_tts_model_with_retry()
return _tts_model return _tts_model
def _ensure_asr_model():
"""确保 ASR 模型已加载(懒加载)"""
global _asr_model
if _asr_model is None:
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
_asr_model = stt_load(ASR_MODEL_ID_MS)
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型 (路径: {ASR_MODEL_ID_MS}): {e}") from e
return _asr_model
def _ensure_align_model():
"""确保 ForcedAligner 模型已加载(懒加载)"""
global _align_model
if _align_model is None:
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
_align_model = stt_load(ALIGN_MODEL_ID_MS)
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {ALIGN_MODEL_ID_MS}): {e}") from e
return _align_model
@router.get("/status", response_model=ModelStatus) @router.get("/status", response_model=ModelStatus)
async def get_status(): async def get_status():
"""获取模型状态""" """获取模型状态"""
return ModelStatus( return ModelStatus(
tts_loaded=_tts_model is not None, tts_loaded=_tts_model is not None,
asr_loaded=False, asr_loaded=_asr_model is not None,
device=_get_device_map(), device=_get_device_map(),
) )
@@ -163,13 +329,13 @@ async def get_config():
"""获取配置信息""" """获取配置信息"""
return { return {
"model": { "model": {
"tts": "Qwen3-TTS-12Hz-1.7B-VoiceDesign", "tts": MODEL_ID_MS,
"asr": None, "asr": ASR_MODEL_ID_MS if Qwen3ASRModel is not None else None,
}, },
"device": _get_device_map(), "device": _get_device_map(),
"status": { "status": {
"tts_loaded": _tts_model is not None, "tts_loaded": _tts_model is not None,
"asr_loaded": False, "asr_loaded": _asr_model is not None,
} }
} }
@@ -178,8 +344,13 @@ async def get_config():
async def warmup_models(): async def warmup_models():
"""手动触发模型预热""" """手动触发模型预热"""
await _warmup_tts() await _warmup_tts()
if Qwen3ASRModel is not None:
await _warmup_asr()
return { return {
"tts_warmup": _tts_model is not None, "tts_warmup": _tts_model is not None,
"asr_warmup": _asr_model is not None if Qwen3ASRModel else False,
"device": _get_device_map(), "device": _get_device_map(),
} }
@@ -188,8 +359,8 @@ async def warmup_models():
async def tts_endpoint(req: TTSRequest): async def tts_endpoint(req: TTSRequest):
"""TTS 文字转语音端点""" """TTS 文字转语音端点"""
try: try:
model = _ensure_model() model = _ensure_tts_model()
except Exception as e: except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
text = req.text text = req.text
@@ -197,51 +368,47 @@ async def tts_endpoint(req: TTSRequest):
try: try:
# VoiceDesign 模型使用 generate_voice_design 方法 # VoiceDesign 模型使用 generate_voice_design 方法
# 返回 (wavs, sr),其中 wavs 是列表,wavs[0] 是第一个音频数据 wavs, sr = model.generate_voice_design( # type: ignore
wavs, sr = model.generate_voice_design(
text=text, text=text,
language="Chinese", language="Chinese",
instruct=instruct, instruct=instruct,
) )
except Exception as e: except Exception as e: # noqa: ANN001
logger.exception("TTS 推理失败") logger.exception("TTS 推理失败")
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}") raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
# 获取第一个音频数据 # Get first audio data
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
# 转换为 numpy 数组 # Convert to numpy array
if hasattr(wav_data, 'numpy'): if hasattr(wav_data, 'numpy'): # type: ignore
wav_data = wav_data.cpu().numpy() wav_data = wav_data.cpu().numpy() # type: ignore
wav_data = np.asarray(wav_data, dtype=np.float32) wav_data = np.asarray(wav_data, dtype=np.float32)
logger.debug("wav_data shape: %s, dtype: %s, sr: %s", wav_data.shape, wav_data.dtype, sr) logger.debug("wav_data shape: %s, dtype: %s, sr: %s", wav_data.shape, wav_data.dtype, sr)
# 编码 WAV 到内存 # Encode WAV to memory
tmp_path = None tmp_path = None
try: try:
import soundfile as sf import soundfile as sf # type: ignore
# 创建临时文件
fd, tmp_path = tempfile.mkstemp(suffix=".wav") fd, tmp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd) os.close(fd) # type: ignore
sf.write(tmp_path, wav_data, sr) sf.write(tmp_path, wav_data, sr)
with open(tmp_path, "rb") as f: with open(tmp_path, "rb") as f: # noqa: SIM115
audio_bytes = f.read() audio_bytes = f.read()
except Exception as e: except Exception as e: # noqa: ANN001
logger.exception("音频编码失败") logger.exception("音频编码失败")
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}") raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
finally: finally:
# 清理临时文件 if tmp_path and os.path.exists(tmp_path): # noqa: SIM201
if tmp_path and os.path.exists(tmp_path):
try: try:
os.unlink(tmp_path) os.unlink(tmp_path)
except Exception: except Exception as e: # noqa: ANN001
pass pass
# 计算时长(毫秒)
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0 duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0
# 返回 JSON 格式,包含 base64 编码的音频
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8") audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
return TTSResponse( return TTSResponse(
audio_base64=audio_base64, audio_base64=audio_base64,
@@ -250,6 +417,74 @@ async def tts_endpoint(req: TTSRequest):
) )
@router.post("/asr", response_model=ASRResponse)
async def asr_endpoint(req: ASRRequest):
"""语音识别端点(非流式)"""
if Qwen3ASRModel is None:
raise HTTPException(status_code=501, detail="mlx_audio 未安装,ASR 功能不可用")
try:
model = _ensure_asr_model()
except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {e}")
try:
# Decode base64 audio to WAV bytes
audio_bytes = base64.b64decode(req.audio_base64)
# Load WAV file and convert to 16kHz mono numpy array
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf: # noqa: SIM115
n_channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16 if sampwidth == 2 else np.float32)
# Convert to mono
if n_channels > 1:
audio_array = np.mean(audio_array.reshape(-1, n_channels), axis=1)
# Resample to 16kHz if needed
if framerate != 16000:
try:
import scipy.signal as signal # type: ignore
n_samples = int(len(audio_array) * 16000 / framerate)
audio_array = signal.resample(audio_array, n_samples) # type: ignore
except Exception as e2: # noqa: ANN001
logger.warning("重采样失败,使用原始音频: %s", e2)
# Convert to float32 normalized
if audio_array.dtype == np.int16:
audio_array = audio_array.astype(np.float32) / 32768.0
# Run ASR inference (non-streaming)
result = model.generate( # type: ignore
audio_array,
language=req.language if req.language else None,
)
# Extract text and detected language from result (STTOutput)
recognized_text = getattr(result, 'text', str(result)) if hasattr(result, 'text') else str(result)
detected_lang = getattr(result, 'language', req.language or "zh-CN")
# If language is a list (from segments), take the first one
if isinstance(detected_lang, list) and len(detected_lang) > 0:
detected_lang = detected_lang[0]
return ASRResponse(
text=recognized_text,
language=str(detected_lang),
)
except Exception as e: # noqa: ANN001
logger.exception("ASR 推理失败")
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {e}")
def register_tts_asr_routes(app): def register_tts_asr_routes(app):
"""注册 TTS/ASR 路由到 FastAPI 应用""" """注册 TTS/ASR 路由到 FastAPI 应用"""
app.include_router(router, prefix="/v1/tts-asr") app.include_router(router, prefix="/v1/tts-asr")
+69 -657
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -31,6 +31,7 @@
"tui-color-picker": "^2.2.8", "tui-color-picker": "^2.2.8",
"tui-image-editor": "^3.15.3", "tui-image-editor": "^3.15.3",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
+6 -2
View File
@@ -40,6 +40,7 @@ import { editorViewCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin' import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView } from '../plugins/hiddenTextPlugin' import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView } from '../plugins/hiddenTextPlugin'
import { fetchSuggestion } from '../utils/api.js' import { fetchSuggestion } from '../utils/api.js'
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
const props = defineProps({ const props = defineProps({
docType: { type: String, default: 'txt' }, docType: { type: String, default: 'txt' },
@@ -81,13 +82,16 @@ const toggleCollapse = () => {
const syncContent = () => { const syncContent = () => {
if (!crepe) return if (!crepe) return
// Skip content sync when tab is hidden (energy saving for nested editors)
if (!isDocumentVisible()) return
if (syncTimer) clearTimeout(syncTimer) if (syncTimer) clearTimeout(syncTimer)
const syncInterval = getRecommendedSyncInterval(120)
syncTimer = setTimeout(async () => { syncTimer = setTimeout(async () => {
if (!crepe || syncingExternal) return if (!crepe || syncingExternal) return
const markdown = await crepe.getMarkdown() const markdown = await crepe.getMarkdown()
currentContent.value = markdown currentContent.value = markdown
props.onUpdateContent?.(markdown) props.onUpdateContent?.(markdown)
}, 120) }, syncInterval)
} }
const syncExternalContent = async (nextValue) => { const syncExternalContent = async (nextValue) => {
@@ -139,7 +143,7 @@ onMounted(async () => {
if (payload?.blocked) return '' if (payload?.blocked) return ''
return fetchSuggestion(payload?.prefix ?? prefix, payload?.suffix ?? suffix, payload?.languageId ?? languageId, signal) return fetchSuggestion(payload?.prefix ?? prefix, payload?.suffix ?? suffix, payload?.languageId ?? languageId, signal)
}, },
debounceMs: 900, debounceMs: getRecommendedDebounce(900),
}) })
}) })
+40 -17
View File
@@ -86,8 +86,8 @@
</button> </button>
<div v-if="showExportDropdown" class="export-dropdown"> <div v-if="showExportDropdown" class="export-dropdown">
<button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button> <button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button>
<button type="button" @click="() => { exportDocx(); showExportDropdown = false; }">{{ t('exportDocx') }}</button> <button type="button" class="disabled-export" :title="t('exportDisabledHint')" @click="showExportDisabled">{{ t('exportDocx') }}</button>
<button type="button" @click="() => { exportPdf(); showExportDropdown = false; }">{{ t('exportPdf') }}</button> <button type="button" class="disabled-export" :title="t('exportDisabledHint')" @click="showExportDisabled">{{ t('exportPdf') }}</button>
</div> </div>
</div> </div>
@@ -324,6 +324,7 @@ import TTSMenu from './TTSMenu.vue'
import TTSPlayer from './TTSPlayer.vue' import TTSPlayer from './TTSPlayer.vue'
import { convertFileToMarkdown } from '../utils/convert.js' import { convertFileToMarkdown } from '../utils/convert.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js' import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js' import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
import { isUploadBlockTypeAllowed } from '../utils/uploadBlock.js' import { isUploadBlockTypeAllowed } from '../utils/uploadBlock.js'
@@ -353,7 +354,6 @@ const canUndo = ref(false)
const canRedo = ref(false) const canRedo = ref(false)
const isDocUploadDisabled = ref(false) const isDocUploadDisabled = ref(false)
const uploadProgress = ref(null) const uploadProgress = ref(null)
const proThinkingActive = ref(false)
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT) const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024)) const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo') const undoLabel = computed(() => t('undo') || 'Undo')
@@ -397,6 +397,7 @@ const acceptAll = computed(() => {
let crepe = null let crepe = null
let markdownSyncTimer = null let markdownSyncTimer = null
let historyUpdateTimer = null
let editorCopyHandler = null let editorCopyHandler = null
let documentClickHandler = null let documentClickHandler = null
const objectUrls = new Set() const objectUrls = new Set()
@@ -689,11 +690,16 @@ const refreshSizeAndLimit = (ctx) => {
const scheduleMarkdownSync = () => { const scheduleMarkdownSync = () => {
if (!crepe) return if (!crepe) return
// Skip markdown sync when tab is hidden (energy saving on mobile/PC)
if (!isDocumentVisible()) return
if (markdownSyncTimer) { if (markdownSyncTimer) {
clearTimeout(markdownSyncTimer) clearTimeout(markdownSyncTimer)
markdownSyncTimer = null markdownSyncTimer = null
} }
const syncInterval = getRecommendedSyncInterval(120)
markdownSyncTimer = setTimeout(async () => { markdownSyncTimer = setTimeout(async () => {
markdownSyncTimer = null markdownSyncTimer = null
if (!crepe) return if (!crepe) return
@@ -1267,21 +1273,17 @@ crepe = new Crepe({
crepe.editor.config((ctx) => { crepe.editor.config((ctx) => {
ctx.set(copilotConfigCtx.key, { ctx.set(copilotConfigCtx.key, {
fetchSuggestion, fetchSuggestion,
debounceMs: settings.debounceMs debounceMs: getRecommendedDebounce(settings.debounceMs)
}) })
}) })
crepe.editor.config((ctx) => { crepe.editor.config((ctx) => {
ctx.set(proBlockConfigCtx.key, { ctx.set(proBlockConfigCtx.key, {
fetchSuggestionStream: fetchProSuggestionStream, fetchSuggestionStream: fetchProSuggestionStream,
getProModel: () => settings.proModel, t,
showError: (message) => { showError: (message) => {
alert(message || 'PRO 模式思考失败') alert(message || 'PRO 模式思考失败')
}, },
isThinkingActive: () => proThinkingActive.value,
setThinkingActive: (value) => {
proThinkingActive.value = Boolean(value)
},
}) })
}) })
@@ -1304,7 +1306,7 @@ crepe = new Crepe({
const config = ctx.get(copilotConfigCtx.key) const config = ctx.get(copilotConfigCtx.key)
ctx.set(copilotConfigCtx.key, { ctx.set(copilotConfigCtx.key, {
...config, ...config,
debounceMs: newVal debounceMs: getRecommendedDebounce(newVal)
}) })
}) })
}) })
@@ -1348,7 +1350,13 @@ crepe = new Crepe({
const view = ctx.get(editorViewCtx) const view = ctx.get(editorViewCtx)
syncObjectUrls(doc) syncObjectUrls(doc)
refreshSizeAndLimit(ctx) refreshSizeAndLimit(ctx)
updateHistoryState(view) // Throttle history state updates (undo/redo depth) to save CPU on every keystroke
if (!historyUpdateTimer) {
historyUpdateTimer = setTimeout(() => {
historyUpdateTimer = null
updateHistoryState(view)
}, 500)
}
refreshDocUploadState(view) refreshDocUploadState(view)
scheduleMarkdownSync() scheduleMarkdownSync()
}) })
@@ -1402,6 +1410,10 @@ const exportMarkdown = async () => {
} }
} }
const showExportDisabled = () => {
alert(t('exportDisabledHint') || 'DOCX/PDF 导出暂不可用。')
}
const exportDocx = async () => { const exportDocx = async () => {
try { try {
console.log('Exporting DOCX...') console.log('Exporting DOCX...')
@@ -1619,11 +1631,14 @@ const insertMultipleDocBlocks = (blocks, replaceRange = null) => {
} }
onUnmounted(() => { onUnmounted(() => {
proThinkingActive.value = false
if (markdownSyncTimer) { if (markdownSyncTimer) {
clearTimeout(markdownSyncTimer) clearTimeout(markdownSyncTimer)
markdownSyncTimer = null markdownSyncTimer = null
} }
if (historyUpdateTimer) {
clearTimeout(historyUpdateTimer)
historyUpdateTimer = null
}
for (const url of Array.from(objectUrls)) { for (const url of Array.from(objectUrls)) {
revokeObjectUrl(url) revokeObjectUrl(url)
@@ -1955,6 +1970,16 @@ for (const url of Array.from(objectUrls)) {
background: var(--crepe-color-hover); background: var(--crepe-color-hover);
} }
.export-dropdown button.disabled-export {
color: var(--muted-text);
cursor: not-allowed;
opacity: 0.58;
}
.export-dropdown button.disabled-export:hover {
background: transparent;
}
.template-dropdown { .template-dropdown {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
@@ -2462,5 +2487,3 @@ for (const url of Array.from(objectUrls)) {
word-break: break-all; word-break: break-all;
} }
</style> </style>
+471 -178
View File
@@ -1,334 +1,627 @@
<template> <template>
<section class="pro-block-shell" :class="[`is-${stage}`, { 'is-busy': isBusy }]"> <section class="pro-block-shell" :class="[`is-${stage}`, { 'is-busy': isBusy, 'has-content': Boolean(displayContent) }]">
<button <button
v-if="isIdle" v-if="isIdle"
type="button" type="button"
class="pro-block-capsule" class="pro-block-capsule"
:disabled="isBusy" :title="title"
:title="props.title"
@mousedown.stop.prevent @mousedown.stop.prevent
@click.stop="handleActivate" @click.stop="handleActivate"
> >
<span class="pro-block-orbit" aria-hidden="true"></span>
<span class="pro-block-badge">PRO</span> <span class="pro-block-badge">PRO</span>
<span class="pro-block-label">模式思考</span> <span class="pro-block-label">{{ t('proStart') || '开始 Pro 生成' }}</span>
</button> </button>
<div v-else class="pro-block-panel"> <div v-else class="pro-block-panel">
<div class="pro-block-header"> <div class="pro-block-header">
<div class="pro-block-heading"> <div class="pro-block-heading">
<span class="pro-block-badge">PRO</span> <span class="pro-block-orbit" aria-hidden="true"></span>
<div class="pro-block-heading-text"> <div>
<h3>{{ props.title }}</h3> <h3>{{ title }}</h3>
<p>{{ statusText }}</p> <p>{{ statusText }}</p>
</div> </div>
</div> </div>
<div v-if="isBusy" class="pro-block-spinner" aria-hidden="true"></div> <div class="pro-block-status">
<span v-if="isThinking" class="pro-thinking-pill">
<span class="pro-spinner" aria-hidden="true"></span>
{{ t('proThinking') || '思考中' }}
</span>
<span v-else-if="isBusy" class="pro-spinner" aria-hidden="true"></span>
</div>
</div> </div>
<div class="pro-block-body"> <div class="pro-instruction">
<button type="button" class="pro-fold-btn" @mousedown.stop.prevent @click.stop="handleToggleInstruction">
<span>{{ instructionExpanded ? (t('proHideInstruction') || '隐藏指令') : (t('proShowInstruction') || '查看指令') }}</span>
<span class="pro-chevron" :class="{ open: instructionExpanded }"></span>
</button>
<Transition name="pro-fold">
<div v-if="instructionExpanded" class="pro-instruction-editor">
<textarea
:value="instruction"
:disabled="isBusy"
:placeholder="t('proInstructionPlaceholder') || '输入隐藏 Pro 指令'"
@input="handleInstructionInput"
@mousedown.stop
@keydown.stop
></textarea>
</div>
</Transition>
</div>
<div ref="bodyRef" class="pro-block-body" @scroll="handleBodyScroll">
<Transition name="pro-thinking-fade">
<div v-if="isThinking" class="pro-thinking-row">
<span class="pro-spinner" aria-hidden="true"></span>
<span>{{ t('proThinking') || '思考中' }}</span>
</div>
</Transition>
<div v-if="errorMessage" class="pro-error-box">
{{ errorMessage }}
</div>
<div v-if="!displayContent" class="pro-block-placeholder"> <div v-if="!displayContent" class="pro-block-placeholder">
<strong>{{ isBusy ? '正在思考' : '等待结果' }}</strong> <strong>{{ placeholderTitle }}</strong>
<span>{{ isBusy ? '流式结果会在这里展开。' : '点击重做重新发起一次深度思考。' }}</span> <span>{{ placeholderText }}</span>
</div> </div>
<div v-else class="pro-block-preview"> <div v-else class="pro-block-preview">
<MarkdownPreview :content="displayContent" /> <MarkdownPreview :content="displayContent" />
</div> </div>
</div> </div>
<div class="pro-block-actions"> <div class="pro-block-footer">
<button type="button" class="pro-text-btn" @mousedown.stop.prevent @click.stop="handleDiscard">放弃</button> <div class="pro-version-controls" v-if="versionCount > 1">
<button <button type="button" class="pro-icon-btn" :disabled="!canPrevVersion" @mousedown.stop.prevent @click.stop="handlePrevVersion"></button>
v-if="!isBusy" <span>{{ activeVersion + 1 }} / {{ versionCount }}</span>
type="button" <button type="button" class="pro-icon-btn" :disabled="!canNextVersion" @mousedown.stop.prevent @click.stop="handleNextVersion"></button>
class="pro-secondary-btn" </div>
:disabled="!canRedo"
@mousedown.stop.prevent <div class="pro-block-actions">
@click.stop="handleRedo" <button v-if="isBusy" type="button" class="pro-text-btn" @mousedown.stop.prevent @click.stop="handleCancel">{{ t('cancel') || '取消' }}</button>
> <button v-else type="button" class="pro-text-btn" @mousedown.stop.prevent @click.stop="handleDiscard">{{ t('discard') || '放弃' }}</button>
重做 <button
</button> v-if="!isBusy"
<button type="button"
v-if="!isBusy" class="pro-secondary-btn"
type="button" @mousedown.stop.prevent
class="pro-primary-btn" @click.stop="handleRedo"
:disabled="!canAccept" >
@mousedown.stop.prevent {{ t('proRetry') || '重试' }}
@click.stop="handleAccept" </button>
> <button
接受 v-if="!isBusy"
</button> type="button"
class="pro-primary-btn"
:disabled="!canAccept"
@mousedown.stop.prevent
@click.stop="handleAccept"
>
{{ t('accept') || '接受' }}
</button>
</div>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import MarkdownPreview from './MarkdownPreview.vue' import MarkdownPreview from './MarkdownPreview.vue'
const props = defineProps({ const props = defineProps({
stage: { type: String, default: 'idle' }, stage: { type: String, default: 'idle' },
previewContent: { type: String, default: '' }, previewContent: { type: String, default: '' },
savedContent: { type: String, default: '' }, activeContent: { type: String, default: '' },
errorMessage: { type: String, default: '' },
isBusy: { type: Boolean, default: false }, isBusy: { type: Boolean, default: false },
title: { type: String, default: 'PRO模式思考' }, isThinking: { type: Boolean, default: false },
title: { type: String, default: 'PRO 模式' },
instruction: { type: String, default: '' },
instructionExpanded: { type: Boolean, default: false },
activeVersion: { type: Number, default: 0 },
versionCount: { type: Number, default: 0 },
canAccept: { type: Boolean, default: false },
activateAction: { type: Function, default: null }, activateAction: { type: Function, default: null },
cancelAction: { type: Function, default: null },
discardAction: { type: Function, default: null }, discardAction: { type: Function, default: null },
redoAction: { type: Function, default: null }, redoAction: { type: Function, default: null },
acceptAction: { type: Function, default: null }, acceptAction: { type: Function, default: null },
prevVersionAction: { type: Function, default: null },
nextVersionAction: { type: Function, default: null },
toggleInstructionAction: { type: Function, default: null },
updateInstructionAction: { type: Function, default: null },
t: { type: Function, default: (key) => key },
}) })
const isIdle = computed(() => props.stage === 'idle' && !props.previewContent && !props.savedContent) const bodyRef = ref(null)
const displayContent = computed(() => props.previewContent || props.savedContent || '') const shouldAutoScroll = ref(true)
const canAccept = computed(() => !props.isBusy && Boolean(displayContent.value))
const canRedo = computed(() => !props.isBusy) const isIdle = computed(() => props.stage === 'idle' && !props.previewContent && !props.activeContent && !props.errorMessage)
const displayContent = computed(() => props.previewContent || props.activeContent || '')
const canPrevVersion = computed(() => props.activeVersion > 0)
const canNextVersion = computed(() => props.activeVersion < props.versionCount - 1)
const t = (key) => props.t?.(key)
const statusText = computed(() => { const statusText = computed(() => {
if (props.stage === 'thinking') return '正在思考更强的答案' if (props.stage === 'queued') return t('proQueued') || '正在排队等待'
if (props.stage === 'streaming') return '结果正在逐步生成' if (props.stage === 'thinking') return t('proThinking') || '思考中'
return '接受会写入正文,放弃会回到胶囊态。' if (props.stage === 'streaming') return t('proStreaming') || '正在生成'
if (props.stage === 'error') return t('proError') || '生成失败'
if (props.stage === 'cancelled') return t('proCancelled') || '已取消,可接受当前草稿'
return t('proDone') || '生成完成'
}) })
const handleActivate = () => { const placeholderTitle = computed(() => {
props.activateAction?.() if (props.stage === 'queued') return t('proQueued') || '正在排队'
if (props.stage === 'thinking') return t('proThinking') || '思考中'
if (props.stage === 'error') return t('proError') || '生成失败'
return t('proWaiting') || '等待 Pro 生成'
})
const placeholderText = computed(() => {
if (props.stage === 'queued') return t('proQueuedHint') || '轮到此请求后会自动开始。'
if (props.stage === 'thinking') return t('proThinkingHint') || '模型正在思考,真实 thinking 内容已隐藏。'
if (props.stage === 'error') return t('proRetryHint') || '请重试,或调整上下文和指令。'
return t('proWaitingHint') || '点击开始生成,结果会在这里流式展开。'
})
const handleBodyScroll = () => {
const el = bodyRef.value
if (!el) return
shouldAutoScroll.value = el.scrollTop + el.clientHeight >= el.scrollHeight - 24
} }
const handleDiscard = () => { watch(
props.discardAction?.() () => props.previewContent,
} async () => {
if (!shouldAutoScroll.value) return
await nextTick()
const el = bodyRef.value
if (el) el.scrollTop = el.scrollHeight
}
)
const handleRedo = () => { const handleActivate = () => props.activateAction?.()
props.redoAction?.() const handleCancel = () => props.cancelAction?.()
} const handleDiscard = () => props.discardAction?.()
const handleRedo = () => props.redoAction?.()
const handleAccept = () => { const handleAccept = () => props.acceptAction?.()
props.acceptAction?.() const handlePrevVersion = () => props.prevVersionAction?.()
} const handleNextVersion = () => props.nextVersionAction?.()
const handleToggleInstruction = () => props.toggleInstructionAction?.()
const handleInstructionInput = (event) => props.updateInstructionAction?.(event.target.value)
</script> </script>
<style scoped> <style scoped>
.pro-block-shell { .pro-block-shell {
width: 100%; width: 100%;
margin: 24px 0; margin: 22px 0;
color: var(--app-text);
}
.pro-block-capsule,
.pro-block-panel {
position: relative;
overflow: hidden;
isolation: isolate;
} }
.pro-block-capsule { .pro-block-capsule {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 12px 20px; padding: 11px 18px;
border: 1px solid #1d4ed8; border: 1px solid rgba(56, 189, 248, 0.58);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); background: linear-gradient(135deg, #08111f 0%, #143a63 52%, #3b1d74 100%);
color: #ffffff; color: #ffffff;
box-shadow: 0 16px 38px rgba(37, 99, 235, 0.18); box-shadow: 0 14px 32px rgba(14, 165, 233, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.14);
cursor: pointer; cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease; touch-action: manipulation;
transition: transform 0.18s ease, box-shadow 0.18s ease;
} }
.pro-block-capsule:hover:not(:disabled) { .pro-block-capsule:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 22px 44px rgba(29, 78, 216, 0.24); box-shadow: 0 20px 42px rgba(14, 165, 233, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
.pro-block-capsule:disabled {
opacity: 0.72;
cursor: wait;
}
.pro-block-capsule .pro-block-badge {
background: rgba(239, 246, 255, 0.18);
color: #ffffff;
}
.pro-block-capsule .pro-block-label {
color: #ffffff;
} }
.pro-block-panel { .pro-block-panel {
border: 1px solid rgba(37, 99, 235, 0.22); border: 1px solid rgba(14, 165, 233, 0.32);
border-radius: 24px; border-radius: 8px;
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98) 0%, rgba(219, 234, 254, 0.88) 100%); background:
box-shadow: 0 22px 48px rgba(37, 99, 235, 0.14); radial-gradient(circle at 20% 0%, rgba(56, 189, 248, 0.22), transparent 30%),
overflow: hidden; radial-gradient(circle at 80% 0%, rgba(168, 85, 247, 0.18), transparent 32%),
color-mix(in srgb, var(--panel-bg) 88%, #07111f 12%);
box-shadow: 0 20px 50px rgba(2, 8, 23, 0.18);
}
.pro-block-panel::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
transform: translateX(-100%);
animation: pro-scan 2.8s ease-in-out infinite;
z-index: -1;
} }
.pro-block-header { .pro-block-header {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 20px 22px 14px; padding: 18px 18px 10px;
} }
.pro-block-heading { .pro-block-heading {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
min-width: 0;
} }
.pro-block-heading-text h3 { .pro-block-heading h3 {
margin: 0; margin: 0;
color: #0f172a; font-size: 16px;
font-size: 18px; font-weight: 750;
font-weight: 700;
} }
.pro-block-heading-text p { .pro-block-heading p {
margin: 4px 0 0; margin: 3px 0 0;
color: #334155; color: var(--muted-text);
font-size: 13px; font-size: 13px;
} }
.pro-block-badge { .pro-block-orbit {
display: inline-flex; width: 20px;
align-items: center; height: 20px;
justify-content: center;
min-width: 52px;
padding: 6px 12px;
border-radius: 999px; border-radius: 999px;
background: rgba(29, 78, 216, 0.14); background: conic-gradient(from 90deg, #38bdf8, #a855f7, #22c55e, #38bdf8);
color: #1d4ed8; box-shadow: 0 0 18px rgba(56, 189, 248, 0.45);
animation: pro-spin 1.8s linear infinite;
flex: 0 0 auto;
}
.pro-block-badge {
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 850;
letter-spacing: 0.12em; letter-spacing: 0.08em;
} }
.pro-block-label { .pro-block-label {
font-size: 15px; font-size: 14px;
font-weight: 700; font-weight: 700;
} }
.pro-block-spinner { .pro-block-status,
width: 18px; .pro-thinking-pill {
height: 18px; display: inline-flex;
align-items: center;
gap: 8px;
}
.pro-thinking-pill {
padding: 6px 10px;
border: 1px solid rgba(56, 189, 248, 0.22);
border-radius: 999px;
color: var(--app-text);
background: rgba(56, 189, 248, 0.08);
font-size: 12px;
}
.pro-spinner {
width: 16px;
height: 16px;
border-radius: 50%; border-radius: 50%;
border: 2px solid rgba(29, 78, 216, 0.18); border: 2px solid rgba(56, 189, 248, 0.18);
border-top-color: #1d4ed8; border-top-color: #38bdf8;
animation: pro-spin 0.9s linear infinite; animation: pro-spin 0.8s linear infinite;
flex: 0 0 auto;
}
.pro-instruction {
padding: 0 18px 10px;
}
.pro-fold-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
padding: 5px 0;
background: transparent;
color: var(--muted-text);
cursor: pointer;
font-size: 12px;
}
.pro-chevron {
transition: transform 0.16s ease;
}
.pro-chevron.open {
transform: rotate(180deg);
}
.pro-instruction-editor textarea {
width: 100%;
min-height: 72px;
resize: vertical;
border: 1px solid rgba(56, 189, 248, 0.22);
border-radius: 8px;
padding: 10px 12px;
background: color-mix(in srgb, var(--app-bg) 88%, transparent);
color: var(--app-text);
outline: none;
font: inherit;
-webkit-overflow-scrolling: touch;
} }
.pro-block-body { .pro-block-body {
padding: 0 18px 12px; max-height: min(52vh, 520px);
overflow: auto;
padding: 0 18px 14px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.pro-thinking-row,
.pro-error-box,
.pro-block-placeholder,
.pro-block-preview {
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: color-mix(in srgb, var(--app-bg) 82%, transparent);
}
.pro-thinking-row {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 10px;
color: var(--muted-text);
font-size: 13px;
}
.pro-error-box {
margin-bottom: 10px;
padding: 10px 12px;
border-color: rgba(239, 68, 68, 0.28);
color: #dc2626;
} }
.pro-block-placeholder { .pro-block-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
padding: 18px 20px 22px; padding: 18px;
border-radius: 18px; color: var(--muted-text);
background: rgba(255, 255, 255, 0.62);
color: #334155;
} }
.pro-block-placeholder strong { .pro-block-placeholder strong {
font-size: 16px; color: var(--app-text);
color: #0f172a;
}
.pro-block-placeholder span {
font-size: 13px;
} }
.pro-block-preview { .pro-block-preview {
overflow: hidden; overflow: hidden;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(148, 163, 184, 0.18);
} }
.pro-block-preview :deep(.preview-container) { .pro-block-preview :deep(.preview-container) {
height: auto; height: auto;
min-height: 0; min-height: 0;
padding: 18px 22px; padding: 16px 18px;
overflow: visible; overflow: visible;
background: transparent; background: transparent;
color: var(--app-text);
} }
.pro-block-preview :deep(.preview-container p),
.pro-block-preview :deep(.preview-container li),
.pro-block-preview :deep(.preview-container blockquote),
.pro-block-preview :deep(.preview-container td),
.pro-block-preview :deep(.preview-container th) {
color: inherit;
}
.pro-block-preview :deep(.preview-container blockquote) {
border-left-color: rgba(148, 163, 184, 0.5);
color: var(--muted-text);
}
.pro-block-preview :deep(.preview-container code),
.pro-block-preview :deep(.preview-container pre) {
background: color-mix(in srgb, var(--app-bg) 78%, var(--app-text) 8%);
color: var(--app-text);
}
.pro-block-preview :deep(.preview-container th),
.pro-block-preview :deep(.preview-container td) {
border-color: rgba(148, 163, 184, 0.35);
}
.pro-block-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 18px;
}
.pro-version-controls,
.pro-block-actions { .pro-block-actions {
display: flex; display: flex;
justify-content: flex-end; align-items: center;
gap: 10px; gap: 8px;
padding: 0 18px 18px; }
.pro-version-controls {
color: var(--muted-text);
font-size: 12px;
}
.pro-icon-btn,
.pro-text-btn,
.pro-secondary-btn,
.pro-primary-btn {
min-height: 34px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: color-mix(in srgb, var(--app-bg) 82%, transparent);
color: var(--app-text);
cursor: pointer;
touch-action: manipulation;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.pro-icon-btn {
width: 34px;
font-size: 20px;
} }
.pro-text-btn, .pro-text-btn,
.pro-secondary-btn, .pro-secondary-btn,
.pro-primary-btn { .pro-primary-btn {
min-width: 88px; padding: 0 14px;
padding: 10px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease, box-shadow 0.18s ease;
}
.pro-text-btn {
border: none;
background: transparent;
color: #334155;
}
.pro-secondary-btn {
border: 1px solid rgba(37, 99, 235, 0.22);
background: rgba(255, 255, 255, 0.82);
color: #1e3a8a;
} }
.pro-primary-btn { .pro-primary-btn {
border: 1px solid #1d4ed8; border-color: rgba(56, 189, 248, 0.58);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); background: linear-gradient(135deg, #0284c7, #7c3aed);
color: #eff6ff; color: #fff;
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.22);
} }
.pro-text-btn:hover, .pro-secondary-btn:hover:not(:disabled),
.pro-secondary-btn:hover, .pro-text-btn:hover:not(:disabled),
.pro-primary-btn:hover { .pro-icon-btn:hover:not(:disabled),
.pro-primary-btn:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
border-color: rgba(56, 189, 248, 0.5);
} }
.pro-text-btn:disabled, button:disabled {
.pro-secondary-btn:disabled,
.pro-primary-btn:disabled {
opacity: 0.58;
cursor: not-allowed; cursor: not-allowed;
transform: none; opacity: 0.48;
box-shadow: none;
} }
:global(.pro-block-node-view) { .pro-fold-enter-active,
display: block; .pro-fold-leave-active,
width: 100%; .pro-thinking-fade-enter-active,
.pro-thinking-fade-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
} }
:global(.pro-block-node-view.ProseMirror-selectednode) { .pro-fold-enter-from,
outline: none !important; .pro-fold-leave-to,
} .pro-thinking-fade-enter-from,
.pro-thinking-fade-leave-to {
:global(.pro-block-node-view.ProseMirror-selectednode)::after { opacity: 0;
display: none !important; transform: translateY(-4px);
} }
@keyframes pro-spin { @keyframes pro-spin {
to { to { transform: rotate(360deg); }
transform: rotate(360deg);
}
} }
@media (max-width: 768px) { @keyframes pro-scan {
.pro-block-header { 0%, 40% { transform: translateX(-100%); opacity: 0; }
padding: 18px 16px 12px; 55% { opacity: 1; }
100% { transform: translateX(100%); opacity: 0; }
}
@media (max-width: 640px) {
.pro-block-shell {
margin: 14px 0;
} }
.pro-block-body { .pro-block-capsule {
padding: 0 14px 10px; width: 100%;
justify-content: center;
min-height: 48px;
}
.pro-block-panel {
width: 100%;
}
.pro-block-header,
.pro-block-footer {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.pro-block-header {
padding: 14px 14px 8px;
}
.pro-instruction {
padding: 0 14px 8px;
}
.pro-instruction-editor textarea {
min-height: 96px;
font-size: 16px;
} }
.pro-block-actions { .pro-block-actions {
padding: 0 14px 16px; display: grid;
flex-wrap: wrap; grid-template-columns: 1fr 1fr;
width: 100%;
}
.pro-block-body {
max-height: 42vh;
padding: 0 14px 12px;
}
.pro-block-preview :deep(.preview-container) {
padding: 12px;
overflow-wrap: anywhere;
}
.pro-block-footer {
padding: 0 14px 14px;
}
.pro-version-controls {
justify-content: center;
}
.pro-icon-btn,
.pro-text-btn,
.pro-secondary-btn,
.pro-primary-btn {
min-height: 44px;
width: 100%;
}
.pro-text-btn {
grid-column: span 1;
}
}
@media (prefers-reduced-motion: reduce) {
.pro-block-panel::before,
.pro-block-orbit,
.pro-spinner {
animation: none;
}
.pro-block-capsule,
.pro-icon-btn,
.pro-text-btn,
.pro-secondary-btn,
.pro-primary-btn,
.pro-chevron {
transition: none;
}
.pro-block-body {
scroll-behavior: auto;
} }
} }
</style> </style>
+7 -8
View File
@@ -241,14 +241,13 @@ const switchView = (view) => {
<h3>{{ t('proMode') || 'PRO 模式' }}</h3> <h3>{{ t('proMode') || 'PRO 模式' }}</h3>
<div class="form-group"> <div class="form-group">
<label>{{ t('proModel') }}</label> <label>{{ t('proThinkingLevel') || t('thinkingLevel') }}</label>
<input <div class="segment-control">
v-model="store.proModel" <button :class="{ active: store.proThinking === 'low' }" @click="store.proThinking = 'low'">{{ t('low') }}</button>
type="text" <button :class="{ active: store.proThinking === 'medium' }" @click="store.proThinking = 'medium'">{{ t('medium') }}</button>
class="select-input" <button :class="{ active: store.proThinking === 'high' }" @click="store.proThinking = 'high'">{{ t('high') }}</button>
:placeholder="t('proModelPlaceholder')" </div>
/> <p class="help-text">{{ t('proThinkingDesc') || 'PRO 模式使用独立思考强度,普通补全设置不会影响它。' }}</p>
<p class="help-text">{{ t('proModelDesc') }} {{ t('proModelEmptyHint') || '留空则使用后端默认 PRO 模型。' }}</p>
</div> </div>
</section> </section>
+71
View File
@@ -0,0 +1,71 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* Reactive document visibility state.
* Returns { isVisible: Ref<boolean> } that tracks Document.hidden.
*/
export function useVisibility() {
const isVisible = ref(!document.hidden)
onMounted(() => {
const onChange = () => {
isVisible.value = !document.hidden
}
document.addEventListener('visibilitychange', onChange)
onUnmounted(() => {
document.removeEventListener('visibilitychange', onChange)
})
})
return { isVisible }
}
/**
* Synchronous check for document visibility (non-reactive).
*/
export function isDocumentVisible() {
return !document.hidden
}
/**
* Detect if the current device is likely mobile (touch + narrow viewport).
*/
export function isMobileDevice() {
if (typeof window === 'undefined') return false
return (
('ontouchstart' in window) &&
window.matchMedia('(max-width: 768px)').matches
)
}
/**
* Get the recommended debounce for AI completion based on device.
*/
export function getRecommendedDebounce(baseMs) {
if (isMobileDevice()) return Math.max(baseMs, 2000)
return baseMs
}
/**
* Get the recommended markdown sync interval based on device.
*/
export function getRecommendedSyncInterval(baseMs) {
if (isMobileDevice()) return Math.max(baseMs, 300)
return baseMs
}
/**
* Initialize global visibility listener that adds/removes 'hidden-tab' class on <html>.
* This allows CSS rules to pause animations and reduce GPU work when the tab is hidden.
*/
export function initVisibilityListener() {
if (typeof document === 'undefined') return
const updateClass = () => {
document.documentElement.classList.toggle('hidden-tab', document.hidden)
}
updateClass() // Initial state
document.addEventListener('visibilitychange', updateClass)
}
View File
View File
+33
View File
@@ -0,0 +1,33 @@
{
"common": {
"copy": "Copier",
"paste": "Coller",
"delete": "Supprimer",
"save": "Enregistrer",
"cancel": "Annuler",
"confirm": "Confirmer",
"error": "Erreur",
"success": "Succès",
"loading": "Chargement...",
"settings": "Paramètres"
},
"editor": {
"uploadFile": "Importer un fichier",
"uploadDoc": "Télécharger document",
"exportDisabledHint": "L'export DOCX/PDF est temporairement indisponible.",
"uploadDocTypeWarning": "Seuls les formats txt, json, toml, yaml, docx, pptx, pdf sont pris en charge.",
"uploadDocSizeWarning": "La taille du fichier ne peut pas dépasser 10 Mo.",
"uploadDocInBlockWarning": "Impossible d'insérer un document à l'intérieur d'un bloc existant.",
"uploadDocError": "Échec de la conversion du document :",
"uploadFileTypeWarning": "Type de fichier non pris en charge. Supporté : doc/docx/ppt/pptx/pdf/zip, images, txt/json.",
"uploadMdTypeWarning": "Seuls les fichiers Markdown(.md) et images sont pris en charge.",
"uploadFileError": "Échec du téléchargement",
"uploadConvertError": "Échec de la conversion",
"uploadBatchLimit": "Maximum 10 fichiers à la fois.",
"uploadSizeLimit": "Fichier dépasse la limite de 50 Mo.",
"uploading": "Téléchargement en cours...",
"enableAI": "Activer IA",
"disableAI": "Désactiver IA",
"insertUrl": "Insérer image via URL"
}
}
View File
+7 -2
View File
@@ -1,9 +1,13 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './style.css'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { initVisibilityListener } from './composables/useVisibility.js'
// Initialize visibility listener for energy saving (pause animations when tab hidden)
initVisibilityListener()
import './style.css'
import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css' import '@milkdown/crepe/theme/frame.css'
@@ -11,3 +15,4 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
+4
View File
@@ -6,6 +6,7 @@ import type { Ctx } from '@milkdown/kit/core'
import { Decoration, DecorationSet, type EditorView } from '@milkdown/prose/view' import { Decoration, DecorationSet, type EditorView } from '@milkdown/prose/view'
import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js' import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js'
import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache' import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache'
import { isDocumentVisible } from '../composables/useVisibility.js'
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot') const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
const DEBOUNCE_MS = 1000 const DEBOUNCE_MS = 1000
@@ -341,6 +342,9 @@ function doFetchSuggestion(
) { ) {
const config = runtime.ctx.get(copilotConfigCtx.key) const config = runtime.ctx.get(copilotConfigCtx.key)
// Skip AI completion when tab is hidden (energy saving)
if (!isDocumentVisible()) return
if (runtime.abortController) { if (runtime.abortController) {
runtime.abortController.abort('superseded') runtime.abortController.abort('superseded')
runtime.abortController = null runtime.abortController = null
+186 -81
View File
@@ -22,21 +22,20 @@ interface ProBlockConfig {
prefix: string prefix: string
suffix: string suffix: string
languageId: string languageId: string
instruction: string
signal?: AbortSignal signal?: AbortSignal
model?: string
temperature?: number
onChunk?: (chunk: string) => void onChunk?: (chunk: string) => void
onEvent?: (event: string, data?: Record<string, any>) => void
}) => Promise<string> }) => Promise<string>
getProModel: () => string t: (key: string) => string
showError: (message: string) => void showError: (message: string) => void
isThinkingActive: () => boolean
setThinkingActive: (value: boolean) => void
} }
interface ProRequestPayload { interface ProRequestPayload {
prefix: string prefix: string
suffix: string suffix: string
languageId: string languageId: string
instruction?: string
blocked: boolean blocked: boolean
} }
@@ -85,13 +84,13 @@ function parseProParagraphNode(node: any) {
return { return {
type: PRO_BLOCK_NODE_TYPE, type: PRO_BLOCK_NODE_TYPE,
content: parsed.content, instruction: parsed.instruction,
autoStart: false, autoStart: false,
} }
} }
function findProBlockReplacements(doc: ProseNode) { function findProBlockReplacements(doc: ProseNode) {
const replacements: Array<{ from: number; to: number; content: string; autoStart: boolean }> = [] const replacements: Array<{ from: number; to: number; instruction: string; autoStart: boolean }> = []
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (node.type.name !== 'paragraph' || node.childCount !== 1) return true if (node.type.name !== 'paragraph' || node.childCount !== 1) return true
@@ -105,7 +104,7 @@ function findProBlockReplacements(doc: ProseNode) {
replacements.push({ replacements.push({
from: pos, from: pos,
to: pos + node.nodeSize, to: pos + node.nodeSize,
content: parsed.content, instruction: parsed.instruction,
autoStart: parsed.autoStart, autoStart: parsed.autoStart,
}) })
@@ -122,10 +121,9 @@ function buildOcrContext(doc: ProseNode) {
if (!IMAGE_NODE_TYPES.has(node.type.name)) return true if (!IMAGE_NODE_TYPES.has(node.type.name)) return true
const src = typeof node.attrs?.src === 'string' ? node.attrs.src : '' const src = typeof node.attrs?.src === 'string' ? node.attrs.src : ''
if (!src) return true if (!src) return true
const ocrText = getOcrCache(src)
if (!ocrText) return true
const preview = extractTextFromOCR(ocrText, 120) const ocrText = getOcrCache(src)
const preview = ocrText ? extractTextFromOCR(ocrText, 120) : ''
if (!preview) return true if (!preview) return true
const label = typeof node.attrs?.alt === 'string' && node.attrs.alt.trim() ? node.attrs.alt.trim() : 'image' const label = typeof node.attrs?.alt === 'string' && node.attrs.alt.trim() ? node.attrs.alt.trim() : 'image'
@@ -159,12 +157,19 @@ function createHighlightDecorations(doc: ProseNode, from: number, to: number) {
function replaceWithParsedMarkdownSlice(tr: any, from: number, to: number, parsedDoc: ProseNode) { function replaceWithParsedMarkdownSlice(tr: any, from: number, to: number, parsedDoc: ProseNode) {
if (!parsedDoc || parsedDoc.content.size <= 0) return null if (!parsedDoc || parsedDoc.content.size <= 0) return null
const parsedSlice = Slice.maxOpen(parsedDoc.content) const parsedSlice = new Slice(parsedDoc.content, 0, 0)
if (!parsedSlice || parsedSlice.size <= 0) return null if (!parsedSlice || parsedSlice.size <= 0) return null
tr.replaceRange(from, to, parsedSlice) const beforeSize = tr.doc.content.size
const startPos = tr.mapping.map(from, -1) const deleteSize = Math.max(0, to - from)
const endPos = tr.mapping.map(to, 1) try {
tr.replaceRange(from, to, parsedSlice)
} catch {
return null
}
const insertedSize = Math.max(0, tr.doc.content.size - beforeSize + deleteSize)
const startPos = Math.max(0, Math.min(from, tr.doc.content.size))
const endPos = Math.max(startPos, Math.min(startPos + insertedSize, tr.doc.content.size))
if (endPos <= startPos) return null if (endPos <= startPos) return null
return { from: startPos, to: endPos } return { from: startPos, to: endPos }
} }
@@ -174,7 +179,7 @@ function insertProBlockNode(view: EditorView, autoStart: boolean) {
if (!proBlockType) return false if (!proBlockType) return false
const { from, to } = view.state.selection const { from, to } = view.state.selection
const blockNode = proBlockType.create({ content: '', autoStart }) const blockNode = proBlockType.create({ instruction: '', autoStart })
const tr = view.state.tr.replaceRangeWith(from, to, blockNode) const tr = view.state.tr.replaceRangeWith(from, to, blockNode)
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size) const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1)) tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
@@ -187,15 +192,15 @@ function replaceParagraphWithProBlock(
view: EditorView, view: EditorView,
from: number, from: number,
to: number, to: number,
content: string, instruction: string,
autoStart: boolean autoStart: boolean
) { ) {
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE] const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
if (!proBlockType) return false if (!proBlockType) return false
const blockNode = proBlockType.create({ const blockNode = proBlockType.create({
content, instruction,
autoStart: autoStart && content === '', autoStart,
}) })
const tr = view.state.tr.replaceWith(from, to, blockNode) const tr = view.state.tr.replaceWith(from, to, blockNode)
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size) const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
@@ -221,23 +226,25 @@ function tryHandleProTriggerTextInput(view: EditorView, insertedText: string) {
const endOffset = to - paragraphStart const endOffset = to - paragraphStart
const nextText = `${paragraph.textContent.slice(0, startOffset)}${insertedText}${paragraph.textContent.slice(endOffset)}` const nextText = `${paragraph.textContent.slice(0, startOffset)}${insertedText}${paragraph.textContent.slice(endOffset)}`
const parsed = parseProBlockSyntax(nextText) const parsed = parseProBlockSyntax(nextText)
if (!parsed?.autoStart || parsed.content) return false if (!parsed || !insertedText.includes(']')) return false
const blockFrom = $from.before(paragraphDepth) const blockFrom = $from.before(paragraphDepth)
const blockTo = blockFrom + paragraph.nodeSize const blockTo = blockFrom + paragraph.nodeSize
return replaceParagraphWithProBlock(view, blockFrom, blockTo, parsed.content, parsed.autoStart) return replaceParagraphWithProBlock(view, blockFrom, blockTo, parsed.instruction, parsed.autoStart)
} }
function isAbortError(error: unknown) { function isAbortError(error: unknown) {
return Boolean(error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError') return Boolean(error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError')
} }
function normalizeProMarkdown(value = '') {
return String(value || '').replace(/\r\n?/g, '\n').trim()
}
export const proBlockConfigCtx = $ctx<ProBlockConfig, 'proBlockConfig'>({ export const proBlockConfigCtx = $ctx<ProBlockConfig, 'proBlockConfig'>({
fetchSuggestionStream: async () => '', fetchSuggestionStream: async () => '',
getProModel: () => '', t: (key: string) => key,
showError: () => {}, showError: () => {},
isThinkingActive: () => false,
setThinkingActive: () => {},
}, 'proBlockConfig') }, 'proBlockConfig')
class ProBlockNodeView implements NodeView { class ProBlockNodeView implements NodeView {
@@ -252,7 +259,8 @@ class ProBlockNodeView implements NodeView {
config: ProBlockConfig config: ProBlockConfig
abortController: AbortController | null = null abortController: AbortController | null = null
requestSeq = 0 requestSeq = 0
redoCount = 0 candidates: string[] = []
activeCandidateIndex = -1
destroyed = false destroyed = false
highlightTimer: ReturnType<typeof setTimeout> | null = null highlightTimer: ReturnType<typeof setTimeout> | null = null
@@ -273,14 +281,25 @@ class ProBlockNodeView implements NodeView {
this.dom = document.createElement('div') this.dom = document.createElement('div')
this.dom.className = 'pro-block-node-view' this.dom.className = 'pro-block-node-view'
this.props = reactive({ this.props = reactive({
stage: node.attrs.content ? 'done' : 'idle', stage: 'idle',
previewContent: '', previewContent: '',
savedContent: node.attrs.content || '', activeContent: '',
errorMessage: '',
isBusy: false, isBusy: false,
isThinking: false,
title: PRO_DISPLAY_LABEL, title: PRO_DISPLAY_LABEL,
instruction: node.attrs.instruction || '',
instructionExpanded: false,
activeVersion: 0,
versionCount: 0,
canAccept: false,
t: (key: string) => this.config.t(key),
activateAction: () => { activateAction: () => {
void this.startThinking(false) void this.startThinking(false)
}, },
cancelAction: () => {
this.cancelThinking()
},
discardAction: () => { discardAction: () => {
this.discardResult() this.discardResult()
}, },
@@ -290,6 +309,18 @@ class ProBlockNodeView implements NodeView {
acceptAction: () => { acceptAction: () => {
void this.acceptResult() void this.acceptResult()
}, },
prevVersionAction: () => {
this.selectCandidate(this.activeCandidateIndex - 1)
},
nextVersionAction: () => {
this.selectCandidate(this.activeCandidateIndex + 1)
},
toggleInstructionAction: () => {
this.props.instructionExpanded = !this.props.instructionExpanded
},
updateInstructionAction: (value: string) => {
this.updateInstruction(value)
},
}) })
this.mount() this.mount()
queueMicrotask(() => { queueMicrotask(() => {
@@ -331,10 +362,44 @@ class ProBlockNodeView implements NodeView {
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs)) this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs))
} }
setStage(stage: 'idle' | 'thinking' | 'streaming' | 'done', previewContent = '') { setStage(stage: 'idle' | 'queued' | 'thinking' | 'streaming' | 'done' | 'error' | 'cancelled', previewContent = '') {
this.props.stage = stage this.props.stage = stage
this.props.previewContent = previewContent this.props.previewContent = previewContent
this.props.isBusy = stage === 'thinking' || stage === 'streaming' this.props.isBusy = stage === 'queued' || stage === 'thinking' || stage === 'streaming'
this.props.isThinking = stage === 'thinking'
this.refreshCandidateProps()
}
refreshCandidateProps() {
this.props.activeContent = this.candidates[this.activeCandidateIndex] || ''
this.props.activeVersion = Math.max(0, this.activeCandidateIndex)
this.props.versionCount = this.candidates.length
this.props.canAccept = !this.props.isBusy && Boolean(this.props.activeContent || this.props.previewContent) && this.props.stage !== 'error'
}
selectCandidate(index: number) {
if (index < 0 || index >= this.candidates.length) return
this.activeCandidateIndex = index
this.props.errorMessage = ''
this.setStage('done', '')
}
setResult(content: string) {
const normalized = normalizeProMarkdown(content)
if (!normalized) return
this.candidates.push(normalized)
if (this.candidates.length > 5) {
this.candidates = this.candidates.slice(this.candidates.length - 5)
}
this.activeCandidateIndex = this.candidates.length - 1
this.refreshCandidateProps()
}
clearResult() {
this.candidates = []
this.activeCandidateIndex = -1
this.props.previewContent = ''
this.refreshCandidateProps()
} }
buildRequestPayload(): ProRequestPayload { buildRequestPayload(): ProRequestPayload {
@@ -355,7 +420,6 @@ class ProBlockNodeView implements NodeView {
const suffixStart = Math.min(pos + this.node.nodeSize, doc.content.size) const suffixStart = Math.min(pos + this.node.nodeSize, doc.content.size)
const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, this.serializer) const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, this.serializer)
|| doc.textBetween(suffixStart, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT) || doc.textBetween(suffixStart, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
const ocrContext = buildOcrContext(doc) const ocrContext = buildOcrContext(doc)
const docContext = extractDocBlockContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600) const docContext = extractDocBlockContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600)
const fullPrefix = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n') const fullPrefix = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n')
@@ -364,6 +428,7 @@ class ProBlockNodeView implements NodeView {
prefix: fullPrefix, prefix: fullPrefix,
suffix: suffixMarkdown, suffix: suffixMarkdown,
languageId: 'markdown', languageId: 'markdown',
instruction: this.props.instruction || '',
blocked: fullPrefix.length + suffixMarkdown.length > PRO_CONTEXT_LIMIT, blocked: fullPrefix.length + suffixMarkdown.length > PRO_CONTEXT_LIMIT,
} }
} }
@@ -374,37 +439,47 @@ class ProBlockNodeView implements NodeView {
void this.startThinking(false) void this.startThinking(false)
} }
stopThinkingLock() {
this.config.setThinkingActive(false)
}
abortActive(reason = 'abort') { abortActive(reason = 'abort') {
if (!this.abortController) return if (!this.abortController) return
this.abortController.abort(reason) this.abortController.abort(reason)
this.abortController = null this.abortController = null
this.stopThinkingLock() }
cancelThinking() {
const draft = String(this.props.previewContent || '').trim()
this.abortActive('cancelled')
if (draft) {
this.setResult(draft)
}
this.props.errorMessage = ''
this.setStage(draft ? 'cancelled' : 'idle', '')
} }
discardResult() { discardResult() {
this.abortActive('discard') this.abortActive('discard')
this.redoCount = 0 this.clearResult()
this.props.savedContent = '' this.props.errorMessage = ''
this.setStage('idle', '') this.setStage('idle', '')
this.updateAttrs({ content: '', autoStart: false }) this.updateAttrs({ autoStart: false })
} }
async startThinking(isRedo: boolean) { updateInstruction(value: string) {
if (this.props.isBusy) return if (this.props.isBusy) return
const previousContent = this.node.attrs.content || '' const next = String(value || '')
if (next === this.props.instruction) return
this.props.instruction = next
this.clearResult()
this.props.errorMessage = ''
this.setStage('idle', '')
this.updateAttrs({ instruction: next, autoStart: false })
}
async startThinking(_isRedo: boolean) {
if (this.props.isBusy) {
this.abortActive('restart')
}
let requestSeq = this.requestSeq let requestSeq = this.requestSeq
let requestStarted = false
try { try {
if (this.config.isThinkingActive()) {
this.config.showError('当前已有一个 PRO 思考正在进行,请先等待它完成。')
return
}
const payload = this.buildRequestPayload() const payload = this.buildRequestPayload()
if (payload.blocked) { if (payload.blocked) {
this.config.showError('PRO 模式上下文超过 32KB,无法继续思考。') this.config.showError('PRO 模式上下文超过 32KB,无法继续思考。')
@@ -414,42 +489,68 @@ class ProBlockNodeView implements NodeView {
requestSeq = this.requestSeq + 1 requestSeq = this.requestSeq + 1
this.requestSeq = requestSeq this.requestSeq = requestSeq
this.abortController = new AbortController() this.abortController = new AbortController()
this.config.setThinkingActive(true) this.props.errorMessage = ''
requestStarted = true this.setStage('queued', '')
this.setStage('thinking', '')
const temperature = Math.min(1.2, 0.7 + (isRedo ? (this.redoCount + 1) * 0.2 : 0))
const result = await this.config.fetchSuggestionStream({ const result = await this.config.fetchSuggestionStream({
...payload, ...payload,
signal: this.abortController.signal, signal: this.abortController.signal,
model: this.config.getProModel(), instruction: this.props.instruction || '',
temperature,
onChunk: (chunk) => { onChunk: (chunk) => {
if (this.destroyed || this.requestSeq !== requestSeq) return if (this.destroyed || this.requestSeq !== requestSeq) return
const nextContent = `${this.props.previewContent || ''}${chunk}` const nextContent = `${this.props.previewContent || ''}${chunk}`
this.setStage('streaming', nextContent) this.setStage('streaming', nextContent)
}, },
onEvent: (event, data) => {
if (this.destroyed || this.requestSeq !== requestSeq) return
if (event === 'queued') {
this.setStage('queued', '')
return
}
if (event === 'started') {
this.setStage('thinking', this.props.previewContent || '')
return
}
if (event === 'thinking') {
this.setStage('thinking', this.props.previewContent || '')
return
}
if (event === 'error') {
this.props.errorMessage = String(data?.error || this.config.t('proErrorActionable') || 'PRO 模式生成失败,请重试。')
}
},
}) })
if (this.destroyed || this.requestSeq !== requestSeq) return if (this.destroyed || this.requestSeq !== requestSeq) return
const content = String(result || this.props.previewContent || '').trim() const content = normalizeProMarkdown(result || this.props.previewContent || '')
this.redoCount = isRedo ? this.redoCount + 1 : 0 if (!content) {
this.props.savedContent = content this.props.errorMessage = this.config.t('proEmptyResult') || 'PRO 返回空结果,请重试或缩短上下文。'
this.setStage(content ? 'done' : 'idle', '') this.setStage('error', '')
this.updateAttrs({ content, autoStart: false }) return
} catch (error) { }
if (!isAbortError(error)) { this.setResult(content)
this.config.showError(error instanceof Error ? error.message : 'PRO 模式思考失败') this.setStage('done', '')
this.updateAttrs({ autoStart: false })
} catch (error) {
if (this.destroyed || this.requestSeq !== requestSeq) return
if (isAbortError(error)) {
const draft = String(this.props.previewContent || '').trim()
if (draft && this.props.stage !== 'cancelled') {
this.setResult(draft)
this.setStage('cancelled', '')
} else if (!draft && this.props.stage !== 'cancelled') {
this.setStage(this.props.activeContent ? 'done' : 'idle', '')
}
} else {
this.props.errorMessage = error instanceof Error
? error.message
: (this.config.t('proErrorActionable') || 'PRO 模式生成失败,请重试。')
this.setStage('error', this.props.previewContent || '')
} }
this.props.savedContent = previousContent
this.setStage(previousContent ? 'done' : 'idle', '')
} finally { } finally {
if (requestStarted && this.requestSeq === requestSeq) { if (this.requestSeq === requestSeq) {
this.abortController = null this.abortController = null
this.stopThinkingLock()
} }
} }
} }
@@ -457,7 +558,7 @@ class ProBlockNodeView implements NodeView {
async acceptResult() { async acceptResult() {
if (this.props.isBusy) return if (this.props.isBusy) return
const source = String(this.props.savedContent || this.node.attrs.content || '').trim() const source = normalizeProMarkdown(this.props.activeContent || this.props.previewContent || '')
if (!source) return if (!source) return
const pos = this.getPosValue() const pos = this.getPosValue()
@@ -476,8 +577,9 @@ class ProBlockNodeView implements NodeView {
} }
if (!insertedRange) { if (!insertedRange) {
tr.insertText(source, from, to) this.props.errorMessage = this.config.t('proAcceptParseError') || '无法把当前结果解析为 Markdown,请重试生成。'
insertedRange = { from, to: from + source.length } this.setStage('error', source)
return
} }
const endPos = Math.min(insertedRange.to, tr.doc.content.size) const endPos = Math.min(insertedRange.to, tr.doc.content.size)
@@ -498,11 +600,14 @@ class ProBlockNodeView implements NodeView {
update(node: ProseNode) { update(node: ProseNode) {
if (node.type !== this.node.type) return false if (node.type !== this.node.type) return false
this.node = node this.node = node
this.props.instruction = node.attrs.instruction || ''
if (!this.props.isBusy) { if (!this.props.isBusy) {
this.props.savedContent = node.attrs.content || '' this.refreshCandidateProps()
this.props.previewContent = '' if (!this.props.activeContent && !this.props.errorMessage) {
this.props.stage = node.attrs.content ? 'done' : 'idle' this.props.previewContent = ''
this.props.stage = 'idle'
}
} }
return true return true
@@ -543,14 +648,14 @@ export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
draggable: false, draggable: false,
marks: '', marks: '',
attrs: { attrs: {
content: { default: '' }, instruction: { default: '' },
autoStart: { default: false }, autoStart: { default: false },
}, },
parseDOM: [ parseDOM: [
{ {
tag: 'div[data-pro-block="true"]', tag: 'div[data-pro-block="true"]',
getAttrs: (dom) => ({ getAttrs: (dom) => ({
content: (dom as HTMLElement).getAttribute('data-pro-content') || '', instruction: (dom as HTMLElement).getAttribute('data-pro-instruction') || '',
autoStart: ((dom as HTMLElement).getAttribute('data-pro-autostart') || '') === 'true', autoStart: ((dom as HTMLElement).getAttribute('data-pro-autostart') || '') === 'true',
}), }),
}, },
@@ -559,7 +664,7 @@ export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
'div', 'div',
{ {
'data-pro-block': 'true', 'data-pro-block': 'true',
'data-pro-content': node.attrs.content || '', 'data-pro-instruction': node.attrs.instruction || '',
'data-pro-autostart': String(Boolean(node.attrs.autoStart)), 'data-pro-autostart': String(Boolean(node.attrs.autoStart)),
}, },
], ],
@@ -567,7 +672,7 @@ export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
match: (node) => node.type === PRO_BLOCK_NODE_TYPE, match: (node) => node.type === PRO_BLOCK_NODE_TYPE,
runner: (state, node, type) => { runner: (state, node, type) => {
state.addNode(type, { state.addNode(type, {
content: String(node.content || ''), instruction: String(node.instruction || ''),
autoStart: false, autoStart: false,
}) })
}, },
@@ -577,11 +682,11 @@ export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
runner: (state, node) => { runner: (state, node) => {
state.addNode('paragraph', [{ state.addNode('paragraph', [{
type: 'text', type: 'text',
value: serializeProBlockSyntax(node.attrs.content), value: serializeProBlockSyntax(node.attrs.instruction),
}]) }])
}, },
}, },
leafText: (node) => serializeProBlockSyntax(node.attrs.content), leafText: (node) => serializeProBlockSyntax(node.attrs.instruction),
})) }))
export const proBlockView = $view(proBlockNode, (ctx) => { export const proBlockView = $view(proBlockNode, (ctx) => {
@@ -623,8 +728,8 @@ export const proBlockInputPlugin = $prose(() => {
replacement.from, replacement.from,
replacement.to, replacement.to,
proBlockType.create({ proBlockType.create({
content: replacement.content, instruction: replacement.instruction,
autoStart: replacement.autoStart && replacement.content === '', autoStart: replacement.autoStart,
}) })
) )
} }
+6 -6
View File
@@ -11,7 +11,7 @@ export const useSettingsStore = defineStore('settings', () => {
// 2. Model Behavior // 2. Model Behavior
const modelThinking = ref('low') // 'low' | 'medium' | 'high' const modelThinking = ref('low') // 'low' | 'medium' | 'high'
const debounceMs = ref(1000) // 1000 - 5000 const debounceMs = ref(1000) // 1000 - 5000
const proModel = ref('') const proThinking = ref('medium') // 'low' | 'medium' | 'high'
// 3. Privacy // 3. Privacy
const privacyMode = ref(true) const privacyMode = ref(true)
@@ -67,7 +67,7 @@ export const useSettingsStore = defineStore('settings', () => {
if (data.theme) theme.value = data.theme if (data.theme) theme.value = data.theme
if (data.modelThinking) modelThinking.value = data.modelThinking if (data.modelThinking) modelThinking.value = data.modelThinking
if (data.debounceMs) debounceMs.value = data.debounceMs if (data.debounceMs) debounceMs.value = data.debounceMs
if (typeof data.proModel === 'string') proModel.value = data.proModel if (data.proThinking) proThinking.value = data.proThinking
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
if (data.language) language.value = data.language if (data.language) language.value = data.language
if (data.currency) currency.value = data.currency if (data.currency) currency.value = data.currency
@@ -91,7 +91,7 @@ export const useSettingsStore = defineStore('settings', () => {
theme: theme.value, theme: theme.value,
modelThinking: modelThinking.value, modelThinking: modelThinking.value,
debounceMs: debounceMs.value, debounceMs: debounceMs.value,
proModel: proModel.value, proThinking: proThinking.value,
privacyMode: privacyMode.value, privacyMode: privacyMode.value,
language: language.value, language: language.value,
currency: currency.value, currency: currency.value,
@@ -111,7 +111,7 @@ export const useSettingsStore = defineStore('settings', () => {
theme.value = 'system' theme.value = 'system'
modelThinking.value = 'low' modelThinking.value = 'low'
debounceMs.value = 1000 debounceMs.value = 1000
proModel.value = '' proThinking.value = 'medium'
privacyMode.value = false privacyMode.value = false
language.value = 'auto' language.value = 'auto'
currency.value = 'auto' currency.value = 'auto'
@@ -128,7 +128,7 @@ export const useSettingsStore = defineStore('settings', () => {
theme, theme,
modelThinking, modelThinking,
debounceMs, debounceMs,
proModel, proThinking,
privacyMode, privacyMode,
language, language,
currency, currency,
@@ -149,7 +149,7 @@ export const useSettingsStore = defineStore('settings', () => {
theme, theme,
modelThinking, modelThinking,
debounceMs, debounceMs,
proModel, proThinking,
privacyMode, privacyMode,
language, language,
currency, currency,
+119
View File
@@ -549,3 +549,122 @@ body {
max-height: var(--mermaid-mobile-max-height); max-height: var(--mermaid-mobile-max-height);
} }
} }
/* ===== Energy-aware CSS optimizations ===== */
/* Respect user's reduced motion preference: disable infinite animations & reduce transitions */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* Disable backdrop-filter entirely for reduced motion users (GPU saving) */
.tts-player,
.tts-menu,
.context-menu,
.settings-panel {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
/* On mobile (touch + narrow viewport), reduce GPU-intensive effects */
@media (max-width: 768px) and (hover: none) {
/* Reduce backdrop-filter blur radius on mobile to save GPU */
.tts-player,
.context-menu {
backdrop-filter: blur(6px) !important;
-webkit-backdrop-filter: blur(6px) !important;
}
.tts-menu,
.settings-panel {
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
}
/* Disable backdrop-filter on low-end mobile devices */
@media (max-width: 480px) {
.tts-player,
.context-menu,
.tts-menu,
.settings-panel {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
/* Reduce transition complexity on mobile */
.tts-player__btn {
transition: background-color 0.15s ease !important;
}
/* Avoid expensive transforms on mobile */
.tts-player__btn--primary:hover {
transform: none !important;
filter: none !important;
}
.tts-player__btn--primary:active {
transform: none !important;
}
/* Reduce animation frame rate for spinners on mobile */
.spinner {
animation-duration: 1.5s !important;
}
/* Disable pulse warning on mobile (infinite animation drain) */
.size-indicator.over-limit {
animation: none !important;
}
/* Use hardware-accelerated properties only for critical animations */
.tts-player-slide-enter-active,
.tts-player-slide-leave-active {
transition: transform 0.2s ease, opacity 0.15s ease !important;
}
/* Reduce mermaid preview max-height further on very small screens */
@media (max-width: 480px) {
.mermaid-inner {
max-height: 240px !important;
}
}
}
/* Pause animations when tab is not visible (handled via CSS + JS) */
.hidden-tab * {
animation-play-state: paused !important;
}
/* Optimize text rendering for battery life */
.milkdown-editor,
.doc-card__editor {
-webkit-text-size-adjust: none;
}
/* Use content-visibility for off-screen sections to reduce layout cost */
.doc-card__body {
contain: content;
}
/* Optimize scroll performance */
.milkdown-editor,
.doc-card__editor {
overflow-anchor: none;
}
/* Reduce paint area for ghost text */
.copilot-ghost-text {
contain: strict;
}
/* Prefer static rendering for code blocks (avoid re-layout) */
.milkdown .ProseMirror pre {
contain: content;
}
/* ===== End energy-aware CSS ===== */
+39 -9
View File
@@ -1,4 +1,4 @@
import { API_URL, API_KEY, PRO_STREAM_URL, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js' import { API_URL, API_KEY, PRO_STREAM_URL, PRO_FRONTEND_TIMEOUT_MS, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
function generateRequestId() { function generateRequestId() {
@@ -20,6 +20,15 @@ function getCancelUrl(apiUrl) {
return `${normalized}/cancel` return `${normalized}/cancel`
} }
function getProCancelUrl(apiUrl) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (!normalized) return '/v1/completions/cancel'
if (/\/v1\/pro\/completions$/i.test(normalized)) {
return normalized.replace(/\/v1\/pro\/completions$/i, '/v1/completions/cancel')
}
return normalized.replace(/\/v1\/pro\/completions\/stream$/i, '/v1/completions/cancel')
}
function normalizeAbortReason(reason) { function normalizeAbortReason(reason) {
if (typeof reason === 'string' && reason.trim()) { if (typeof reason === 'string' && reason.trim()) {
return reason.trim().slice(0, 64) return reason.trim().slice(0, 64)
@@ -158,16 +167,16 @@ export async function fetchProSuggestionStream(payload, apiUrl = PRO_STREAM_URL)
prefix = '', prefix = '',
suffix = '', suffix = '',
languageId = 'markdown', languageId = 'markdown',
instruction = '',
signal, signal,
model = '', timeoutMs = PRO_FRONTEND_TIMEOUT_MS,
temperature = 0.7,
timeoutMs = 600000,
onChunk, onChunk,
onEvent,
} = payload || {} } = payload || {}
const settings = useSettingsStore() const settings = useSettingsStore()
const requestId = generateRequestId() const requestId = generateRequestId()
const cancelUrl = getCancelUrl(apiUrl) const cancelUrl = getProCancelUrl(apiUrl)
const requestController = new AbortController() const requestController = new AbortController()
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
requestController.abort('timeout') requestController.abort('timeout')
@@ -201,10 +210,19 @@ export async function fetchProSuggestionStream(payload, apiUrl = PRO_STREAM_URL)
'X-API-Key': API_KEY, 'X-API-Key': API_KEY,
}, },
body: JSON.stringify( body: JSON.stringify(
buildCompletionBody(settings, prefix, suffix, String(languageId || 'markdown').trim() || 'markdown', { {
model, prefix,
temperature, suffix,
}) languageId: String(languageId || 'markdown').trim() || 'markdown',
instruction,
pro_thinking: settings.proThinking || 'medium',
privacy_mode: settings.privacyMode,
user_preferences: {
language: settings.language,
currency: settings.currency,
timezone: settings.detectedTimezone,
},
}
), ),
signal: requestController.signal, signal: requestController.signal,
}) })
@@ -235,6 +253,18 @@ export async function fetchProSuggestionStream(payload, apiUrl = PRO_STREAM_URL)
buffer = buffer.slice(boundary + 2) buffer = buffer.slice(boundary + 2)
const parsed = parseSseEvent(chunk) const parsed = parseSseEvent(chunk)
if (parsed.event && parsed.event !== 'message') {
let eventData = {}
if (parsed.data) {
try {
eventData = JSON.parse(parsed.data)
} catch {
eventData = {}
}
}
onEvent?.(parsed.event, eventData)
}
if (parsed.event === 'chunk' && parsed.data) { if (parsed.event === 'chunk' && parsed.data) {
const data = JSON.parse(parsed.data) const data = JSON.parse(parsed.data)
const delta = String(data.delta || '') const delta = String(data.delta || '')
+1
View File
@@ -5,6 +5,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions` export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
export const PRO_STREAM_URL = import.meta.env.VITE_PRO_STREAM_URL || `${API_BASE_URL}/v1/pro/completions/stream` export const PRO_STREAM_URL = import.meta.env.VITE_PRO_STREAM_URL || `${API_BASE_URL}/v1/pro/completions/stream`
export const PRO_FRONTEND_TIMEOUT_MS = Number(import.meta.env.VITE_PRO_FRONTEND_TIMEOUT_MS || 3660000)
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr` export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert` export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf' export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
+198 -51
View File
@@ -82,6 +82,8 @@ export const translations = {
cancel: 'Cancel', cancel: 'Cancel',
imgTooLarge: 'Image too large', imgTooLarge: 'Image too large',
docTooLarge: 'Document too large, AI disabled', docTooLarge: 'Document too large, AI disabled',
exportDisabledHint: 'DOCX/PDF export is temporarily unavailable.',
disableAI: 'Disable AI',
initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...', initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...',
view: 'View', view: 'View',
editor: 'Editor', editor: 'Editor',
@@ -89,34 +91,33 @@ export const translations = {
docsManagement: 'Document Management', docsManagement: 'Document Management',
docsEmptyDesc: 'Document management interface is under development...', docsEmptyDesc: 'Document management interface is under development...',
files: 'Files', files: 'Files',
noFiles: '暂无文件', noFiles: 'No files yet',
newFile: '新建文件', newFile: 'New File',
newFolder: '新建文件夹', newFolder: 'New Folder',
untitledFile: 'untitled.md', untitledFile: 'untitled.md',
untitledFolder: '新建文件夹', untitledFolder: 'New Folder',
rename: '重命名', rename: 'Rename',
delete: '删除', delete: 'Delete',
copy: '复制', copy: 'Copy',
cut: '剪切', cut: 'Cut',
paste: '粘贴', paste: 'Paste',
confirmDelete: '确认删除', confirmDelete: 'Confirm Delete',
confirmDeleteDesc: '确定要删除', confirmDeleteDesc: 'Are you sure to delete',
confirmDeleteFolderDesc: '此操作将删除文件夹内的所有内容。', confirmDeleteFolderDesc: 'This will delete all contents in the folder.',
confirmDeleteFileDesc: '此操作不可撤销。', confirmDeleteFileDesc: 'This action cannot be undone.',
cancel: '取消', rootDir: 'Root',
rootDir: '根目录', expandSidebar: 'Expand Sidebar',
expandSidebar: '展开侧边栏', collapseSidebar: 'Collapse Sidebar',
collapseSidebar: '收起侧边栏', fileLimitReached: 'Maximum number of files reached',
fileLimitReached: '文件数量已达上限', folderLimitReached: 'Maximum number of folders reached',
folderLimitReached: '文件夹数量已达上限', fileSizeLimit: 'File size cannot exceed 50MB',
fileSizeLimit: '文件大小不能超过 50MB', storageError: 'Insufficient storage space',
storageError: '存储空间不足', selectFileToView: 'Select a file to view content',
selectFileToView: '选择一个文件以查看内容', folderContains: 'contains',
folderContains: '包含', items: 'items',
items: '个项目', unsupportedPreview: 'This file type is not supported for preview',
unsupportedPreview: '暂不支持预览此文件类型', fileNamePlaceholder: 'filename.md',
fileNamePlaceholder: '文件名.md', folderNamePlaceholder: 'Folder name'
folderNamePlaceholder: '文件夹名'
}, },
zh: { zh: {
settings: '设置', settings: '设置',
@@ -201,6 +202,7 @@ export const translations = {
cancel: '取消', cancel: '取消',
imgTooLarge: '图片过大', imgTooLarge: '图片过大',
docTooLarge: '文档过大,AI已禁用', docTooLarge: '文档过大,AI已禁用',
exportDisabledHint: 'DOCX/PDF 导出暂不可用。',
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...', initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...',
view: '视图', view: '视图',
editor: '编辑器', editor: '编辑器',
@@ -260,6 +262,12 @@ export const translations = {
mediumDesc: '提案前に文脈を簡単に分析', mediumDesc: '提案前に文脈を簡単に分析',
highDesc: '深く段階的に分析(遅いが最高品質)', highDesc: '深く段階的に分析(遅いが最高品質)',
debounceTime: 'デバウンス時間', debounceTime: 'デバウンス時間',
proModeThinking: 'PRO 思考中',
proMode: 'PRO モード',
proModel: 'PRO モデル',
proModelPlaceholder: '例: qwen3:32b',
proModelDesc: 'PRO モードのみで使用されるオプションの強力なモデル名。',
proModelEmptyHint: '空白のままにするとバックエンドのデフォルト PRO モデルが使用されます。',
privacyPreferences: 'プライバシーと設定', privacyPreferences: 'プライバシーと設定',
privacyMode: 'プライバシーモード', privacyMode: 'プライバシーモード',
privacyDesc: 'AIにIPアドレスと設定を送信しない', privacyDesc: 'AIにIPアドレスと設定を送信しない',
@@ -267,21 +275,48 @@ export const translations = {
auto: '自動検出', auto: '自動検出',
currency: '通貨', currency: '通貨',
about: '私たちについて', about: '私たちについて',
template: 'テンプレート',
presetTemplates: 'プリセットテンプレート',
customTemplates: 'カスタムテンプレート',
newTemplate: '新しいテンプレート',
previewTemplate: 'プレビュー',
applyTemplate: '適用',
copyAsTemplate: 'カスタムテンプレートとしてコピー',
editTemplate: '編集',
saveTemplate: '保存',
templateName: 'テンプレート名',
templateContent: '内容',
templateNamePlaceholder: '例: 議事録',
templateContentPlaceholder: 'テンプレート内容を入力...',
noTemplates: 'カスタムテンプレートはありません',
templateNameRequired: 'テンプレート名を入力してください',
templateContentRequired: '内容を入力してください',
templateNameDuplicate: 'テンプレート名は既に存在します',
templateDeleteConfirm: 'このテンプレートを削除しますか?',
templateSaved: '保存しました',
templateUpdated: '更新しました',
templateDeleted: '削除しました',
importMd: 'Markdownをインポート', importMd: 'Markdownをインポート',
exportMd: 'Markdownをエクスポート', exportMd: 'Markdownをエクスポート',
exportDocx: 'DOCXをエクスポート', exportDocx: 'DOCXをエクスポート',
exportPdf: 'PDFをエクスポート', exportPdf: 'PDFをエクスポート',
upload: 'アップロード', upload: 'アップロード',
uploadImg: '画像をアップロード', uploadImg: '画像をアップロード',
uploadFile: 'Upload File', uploadFile: 'ファイルをアップロード',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', uploadDoc: 'ドキュメントをアップロード',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.', exportDisabledHint: 'DOCX/PDFのエクスポートは一時的に利用できません。',
uploadFileError: 'File upload failed.', uploadDocTypeWarning: 'txt、json、toml、yaml、docx、pptx、pdf形式のみサポートされています。',
uploadConvertError: 'File conversion failed.', uploadDocSizeWarning: 'ファイルサイズは10MBを超えることはできません。',
uploadBatchLimit: 'Maximum 10 files at once', uploadDocInBlockWarning: '既存のドキュメントブロック内に新しいドキュメントを挿入することはできません。',
uploadSizeLimit: 'File exceeds 50MB limit', uploadDocError: 'ドキュメントの変換に失敗しました:',
uploading: 'Uploading files...', uploadFileTypeWarning: 'サポートされていないファイルタイプです。doc/docx/ppt/pptx/pdf/zip、画像、txt/jsonをサポートしています。',
enableAI: 'AIを有効化', uploadMdTypeWarning: 'Markdown(.md)ファイルと画像ファイルのみサポートされています。',
uploadFileError: 'アップロードに失敗しました。',
uploadConvertError: '変換に失敗しました。',
uploadBatchLimit: '一度にアップロードできるのは10ファイルまでです。',
uploadSizeLimit: '50MBの制限を超えています。',
uploading: 'アップロード中...',
enableAI: 'AIを有効化',
disableAI: 'AIを無効化', disableAI: 'AIを無効化',
insertUrl: 'URLから画像を挿入', insertUrl: 'URLから画像を挿入',
insert: '挿入', insert: '挿入',
@@ -347,6 +382,13 @@ export const translations = {
mediumDesc: '제안 전 문맥 간단 분석', mediumDesc: '제안 전 문맥 간단 분석',
highDesc: '심층 단계별 분석 (가장 느리지만 최고 품질)', highDesc: '심층 단계별 분석 (가장 느리지만 최고 품질)',
debounceTime: '디바운스 시간', debounceTime: '디바운스 시간',
proModeThinking: 'PRO 생각 중',
proMode: 'PRO 모드',
proModel: 'PRO 모델',
proModelPlaceholder: '예: qwen3:32b',
proModelDesc: 'PRO 모드에서만 사용되는 선택적 강력한 모델 이름.',
proModelEmptyHint: '비워두면 백엔드 기본 PRO 모델을 사용합니다.',
privacyPreferences: '개인정보 및 환경설정', privacyPreferences: '개인정보 및 환경설정',
privacyMode: '개인정보 모드', privacyMode: '개인정보 모드',
privacyDesc: 'AI에 IP 주소 및 설정 전송 안 함', privacyDesc: 'AI에 IP 주소 및 설정 전송 안 함',
@@ -354,17 +396,48 @@ export const translations = {
auto: '자동 감지', auto: '자동 감지',
currency: '통화', currency: '통화',
about: '회사 소개', about: '회사 소개',
template: '템플릿',
presetTemplates: '사전 정의된 템플릿',
customTemplates: '사용자 정의 템플릿',
newTemplate: '새 템플릿',
previewTemplate: '미리보기',
applyTemplate: '적용',
copyAsTemplate: '사용자 정의 템플릿으로 복사',
editTemplate: '편집',
saveTemplate: '저장',
templateName: '템플릿 이름',
templateContent: '내용',
templateNamePlaceholder: '예: 회의록',
templateContentPlaceholder: '템플릿 내용을 입력...',
noTemplates: '사용자 정의 템플릿이 없습니다',
templateNameRequired: '템플릿 이름을 입력하세요',
templateContentRequired: '내용을 입력하세요',
templateNameDuplicate: '템플릿 이름이 이미 존재합니다',
templateDeleteConfirm: '이 템플릿을 삭제하시겠습니까?',
templateSaved: '저장되었습니다',
templateUpdated: '업데이트되었습니다',
templateDeleted: '삭제되었습니다',
importMd: 'Markdown 가져오기', importMd: 'Markdown 가져오기',
exportMd: 'Markdown 내보내기', exportMd: 'Markdown 내보내기',
exportDocx: 'DOCX 내보내기', exportDocx: 'DOCX 내보내기',
exportPdf: 'PDF 내보내기', exportPdf: 'PDF 내보내기',
upload: '업로드', upload: '업로드',
uploadImg: '이미지 업로드', uploadImg: '이미지 업로드',
uploadFile: 'Upload File', uploadFile: '파일 업로드',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', exportDisabledHint: 'DOCX/PDF 내보내기는 일시적으로 사용할 수 없습니다.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.', uploadDoc: '문서 업로드',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.', uploadDocTypeWarning: 'txt, json, toml, yaml, docx, pptx, pdf 형식만 지원됩니다.',
uploadDocSizeWarning: '파일 크기는 10MB를 초과할 수 없습니다.',
uploadDocInBlockWarning: '기존 문서 블록 내에 새 문서를 삽입할 수 없습니다.',
uploadDocError: '문서 변환 실패:',
uploadFileTypeWarning: '지원되지 않는 파일 유형입니다. doc/docx/ppt/pptx/pdf/zip, 이미지, txt/json을 지원합니다.',
uploadMdTypeWarning: 'Markdown(.md) 파일과 이미지 파일만 지원됩니다.',
uploadFileError: '업로드 실패',
uploadConvertError: '변환 실패',
uploadBatchLimit: '한 번에 최대 10개 파일까지 업로드할 수 있습니다.',
uploadSizeLimit: '50MB 제한을 초과합니다.',
uploading: '업로드 중...',
enableAI: 'AI 활성화', enableAI: 'AI 활성화',
disableAI: 'AI 비활성화', disableAI: 'AI 비활성화',
insertUrl: 'URL로 이미지 삽입', insertUrl: 'URL로 이미지 삽입',
@@ -431,6 +504,13 @@ export const translations = {
mediumDesc: 'Kurze Analyse vor Vorschlag', mediumDesc: 'Kurze Analyse vor Vorschlag',
highDesc: 'Tiefe schrittweise Analyse (Langsam, aber höchste Qualität)', highDesc: 'Tiefe schrittweise Analyse (Langsam, aber höchste Qualität)',
debounceTime: 'Entprellzeit', debounceTime: 'Entprellzeit',
proModeThinking: 'PRO denkt nach',
proMode: 'PRO-Modus',
proModel: 'PRO-Modell',
proModelPlaceholder: 'z.B. qwen3:32b',
proModelDesc: 'Optional staerker Modellname, der nur im PRO-Modus verwendet wird.',
proModelEmptyHint: 'Leer lassen, um das Standard-PRO-Modell des Backends zu verwenden.',
privacyPreferences: 'Datenschutz & Einstellungen', privacyPreferences: 'Datenschutz & Einstellungen',
privacyMode: 'Datenschutzmodus', privacyMode: 'Datenschutzmodus',
privacyDesc: 'Sende keine IP und Einstellungen an KI', privacyDesc: 'Sende keine IP und Einstellungen an KI',
@@ -438,17 +518,47 @@ export const translations = {
auto: 'Automatisch', auto: 'Automatisch',
currency: 'Währung', currency: 'Währung',
about: 'Über uns', about: 'Über uns',
template: 'Vorlage',
presetTemplates: 'Vorgabe-Vorlagen',
customTemplates: 'Benutzerdefinierte Vorlagen',
newTemplate: 'Neue Vorlage',
previewTemplate: 'Vorschau',
applyTemplate: 'Anwenden',
copyAsTemplate: 'Als benutzerdefinierte Vorlage kopieren',
editTemplate: 'Bearbeiten',
saveTemplate: 'Speichern',
templateName: 'Vorlagenname',
templateContent: 'Inhalt',
templateNamePlaceholder: 'z.B. Protokoll',
templateContentPlaceholder: 'Vorlageninhalt eingeben...',
noTemplates: 'Keine benutzerdefinierten Vorlagen',
templateNameRequired: 'Bitte Vorlagennamen eingeben',
templateContentRequired: 'Bitte Inhalt eingeben',
templateNameDuplicate: 'Vorlagenname existiert bereits',
templateDeleteConfirm: 'Diese Vorlage löschen?',
templateSaved: 'Gespeichert',
templateUpdated: 'Aktualisiert',
templateDeleted: 'Gelöscht',
importMd: 'Markdown importieren', importMd: 'Markdown importieren',
exportMd: 'Markdown exportieren', exportMd: 'Markdown exportieren',
exportDocx: 'DOCX exportieren', exportDocx: 'DOCX exportieren',
exportPdf: 'PDF exportieren', exportPdf: 'PDF exportieren',
upload: 'Hochladen', upload: 'Hochladen',
uploadImg: 'Bild hochladen', uploadImg: 'Bild hochladen',
uploadFile: 'Upload File', uploadFile: 'Datei hochladen',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', uploadDoc: 'Dokument hochladen',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.', exportDisabledHint: 'DOCX/PDF-Export ist vorübergehend nicht verfügbar.',
uploadFileError: 'File upload failed.', uploadDocTypeWarning: 'Nur txt, json, toml, yaml, docx, pptx, pdf Formate werden unterstützt.',
uploadConvertError: 'File conversion failed.', uploadDocSizeWarning: 'Dateigröße darf 10MB nicht überschreiten.',
uploadDocInBlockWarning: 'Kann kein Dokument innerhalb eines bestehenden Dokuments einfügen.',
uploadDocError: 'Dokumentkonversion fehlgeschlagen:',
uploadFileTypeWarning: 'Nicht unterstützter Dateityp. Unterstützt: doc/docx/ppt/pptx/pdf/zip, Bilder, txt/json.',
uploadMdTypeWarning: 'Nur Markdown(.md) und Bilddateien werden unterstützt.',
uploadFileError: 'Hochladen fehlgeschlagen',
uploadConvertError: 'Konversion fehlgeschlagen',
uploadBatchLimit: 'Maximal 10 Dateien auf einmal.',
uploadSizeLimit: 'Datei überschreitet das Limit von 50MB.',
uploading: 'Wird hochgeladen...',
enableAI: 'KI aktivieren', enableAI: 'KI aktivieren',
disableAI: 'KI deaktivieren', disableAI: 'KI deaktivieren',
insertUrl: 'Bild per URL einfügen', insertUrl: 'Bild per URL einfügen',
@@ -515,6 +625,13 @@ export const translations = {
mediumDesc: 'Analyse brève avant suggestion', mediumDesc: 'Analyse brève avant suggestion',
highDesc: 'Analyse approfondie étape par étape (Le plus lent)', highDesc: 'Analyse approfondie étape par étape (Le plus lent)',
debounceTime: 'Temps de rebond', debounceTime: 'Temps de rebond',
proModeThinking: 'PRO en réflexion',
proMode: 'Mode PRO',
proModel: 'Modèle PRO',
proModelPlaceholder: 'ex: qwen3:32b',
proModelDesc: 'Nom de modèle plus puissant utilisé uniquement par le mode PRO.',
proModelEmptyHint: 'Laissez vide pour utiliser le modèle PRO par défaut du backend.',
privacyPreferences: 'Confidentialité et préférences', privacyPreferences: 'Confidentialité et préférences',
privacyMode: 'Mode confidentialité', privacyMode: 'Mode confidentialité',
privacyDesc: 'Ne pas envoyer IP et préférences à l\'IA', privacyDesc: 'Ne pas envoyer IP et préférences à l\'IA',
@@ -522,17 +639,47 @@ export const translations = {
auto: 'Détection auto', auto: 'Détection auto',
currency: 'Devise', currency: 'Devise',
about: 'À propos de nous', about: 'À propos de nous',
template: 'Modèle',
presetTemplates: 'Modèles prédéfinis',
customTemplates: 'Modèles personnalisés',
newTemplate: 'Nouveau modèle',
previewTemplate: 'Aperçu',
applyTemplate: 'Appliquer',
copyAsTemplate: 'Copier comme modèle personnalisé',
editTemplate: 'Modifier',
saveTemplate: 'Enregistrer',
templateName: 'Nom du modèle',
templateContent: 'Contenu',
templateNamePlaceholder: 'ex: Compte-rendu',
templateContentPlaceholder: 'Entrez le contenu du modèle...',
noTemplates: 'Aucun modèle personnalisé',
templateNameRequired: 'Veuillez entrer un nom de modèle',
templateContentRequired: 'Veuillez entrer le contenu',
templateNameDuplicate: 'Le nom du modèle existe déjà',
templateDeleteConfirm: 'Supprimer ce modèle ?',
templateSaved: 'Enregistré',
templateUpdated: 'Mis à jour',
templateDeleted: 'Supprimé',
importMd: 'Importer Markdown', importMd: 'Importer Markdown',
exportMd: 'Exporter Markdown', exportMd: 'Exporter Markdown',
exportDocx: 'Exporter DOCX', exportDocx: 'Exporter DOCX',
exportPdf: 'Exporter PDF', exportPdf: 'Exporter PDF',
upload: 'Télécharger', upload: 'Télécharger',
uploadImg: 'Télécharger image', uploadImg: 'Télécharger image',
uploadFile: 'Upload File', uploadFile: 'Importer un fichier',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', uploadDoc: 'Télécharger document',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.', exportDisabledHint: 'L\'export DOCX/PDF est temporairement indisponible.',
uploadFileError: 'File upload failed.', uploadDocTypeWarning: 'Seuls les formats txt, json, toml, yaml, docx, pptx, pdf sont pris en charge.',
uploadConvertError: 'File conversion failed.', uploadDocSizeWarning: 'La taille du fichier ne peut pas dépasser 10 Mo.',
uploadDocInBlockWarning: 'Impossible d\'insérer un document à l\'intérieur d\'un bloc existant.',
uploadDocError: 'Échec de la conversion du document :',
uploadFileTypeWarning: 'Type de fichier non pris en charge. Supporté : doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Seuls les fichiers Markdown(.md) et images sont pris en charge.',
uploadFileError: 'Échec du téléchargement',
uploadConvertError: 'Échec de la conversion',
uploadBatchLimit: 'Maximum 10 fichiers à la fois.',
uploadSizeLimit: 'Fichier dépasse la limite de 50 Mo.',
uploading: 'Téléchargement en cours...',
enableAI: 'Activer IA', enableAI: 'Activer IA',
disableAI: 'Désactiver IA', disableAI: 'Désactiver IA',
insertUrl: 'Insérer image via URL', insertUrl: 'Insérer image via URL',
+13 -13
View File
@@ -1,9 +1,9 @@
export const PRO_BLOCK_NODE_TYPE = 'pro_block' export const PRO_BLOCK_NODE_TYPE = 'pro_block'
export const PRO_TRIGGER_TEXT = '[PRO]' export const PRO_TRIGGER_TEXT = '[PRO]'
export const PRO_DISPLAY_LABEL = 'PRO模式思考' export const PRO_DISPLAY_LABEL = 'PRO 模式'
const PRO_PENDING_PREFIX = '[Pro][{' const PRO_INSTRUCTION_PREFIX = '[PRO]{'
const PRO_PENDING_SUFFIX = '}]' const PRO_INSTRUCTION_SUFFIX = '}'
const PRO_TRIGGER_RE = /^\[pro\]$/i const PRO_TRIGGER_RE = /^\[pro\]$/i
function normalizeMarkdownText(value = '') { function normalizeMarkdownText(value = '') {
@@ -41,10 +41,10 @@ export function unescapeProBlockContent(value = '') {
return result return result
} }
export function serializeProBlockSyntax(content = '') { export function serializeProBlockSyntax(instruction = '') {
const normalized = normalizeMarkdownText(content) const normalized = normalizeMarkdownText(instruction).trim()
if (!normalized) return PRO_TRIGGER_TEXT if (!normalized) return PRO_TRIGGER_TEXT
return `${PRO_PENDING_PREFIX}${escapeProBlockContent(normalized)}${PRO_PENDING_SUFFIX}` return `${PRO_INSTRUCTION_PREFIX}${escapeProBlockContent(normalized)}${PRO_INSTRUCTION_SUFFIX}`
} }
export function parseProBlockSyntax(value = '') { export function parseProBlockSyntax(value = '') {
@@ -53,17 +53,17 @@ export function parseProBlockSyntax(value = '') {
if (PRO_TRIGGER_RE.test(text)) { if (PRO_TRIGGER_RE.test(text)) {
return { return {
content: '', instruction: '',
autoStart: true, autoStart: false,
} }
} }
const prefix = text.slice(0, PRO_PENDING_PREFIX.length) const prefix = text.slice(0, PRO_INSTRUCTION_PREFIX.length)
if (prefix.toLowerCase() === PRO_PENDING_PREFIX.toLowerCase() && text.endsWith(PRO_PENDING_SUFFIX)) { if (prefix.toLowerCase() === PRO_INSTRUCTION_PREFIX.toLowerCase() && text.endsWith(PRO_INSTRUCTION_SUFFIX)) {
return { return {
content: unescapeProBlockContent( instruction: unescapeProBlockContent(
text.slice(PRO_PENDING_PREFIX.length, text.length - PRO_PENDING_SUFFIX.length) text.slice(PRO_INSTRUCTION_PREFIX.length, text.length - PRO_INSTRUCTION_SUFFIX.length)
), ).trim(),
autoStart: false, autoStart: false,
} }
} }
+1 -11
View File
@@ -53,17 +53,7 @@ export default defineConfig({
include: [ include: [
'@milkdown/crepe', '@milkdown/crepe',
'@milkdown/vue', '@milkdown/vue',
'@milkdown/kit', '@milkdown/kit'
'@univerjs/core',
'@univerjs/design',
'@univerjs/engine-render',
'@univerjs/engine-formula',
'@univerjs/ui',
'@univerjs/presets',
'@univerjs/preset-docs-core',
'@univerjs/preset-sheets-core',
'@univerjs/slides',
'@univerjs/slides-ui'
] ]
} }
}) })