Refactor settings store to rename proModel to proThinking and update related logic; enhance CSS for energy efficiency and reduced motion preferences; improve i18n translations for better clarity and consistency; modify proBlock utility functions for clearer instruction handling; streamline Vite configuration by removing unnecessary Univer.js dependencies.
This commit is contained in:
+2
-1
@@ -1,4 +1,4 @@
|
||||
# Logs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -13,6 +13,7 @@ dist-ssr
|
||||
*.local
|
||||
|
||||
# Python
|
||||
backend/models/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.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
|
||||
@@ -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.
|
||||
@@ -38,11 +38,11 @@
|
||||
## 技术架构
|
||||
|
||||
前端: Vue3 + Vite + Milkdown + ProseMirror
|
||||
后端: FastAPI + Python + Ollama
|
||||
后端: FastAPI + Python(OpenAI 兼容端点)
|
||||
|
||||
## 快速开始
|
||||
|
||||
环境: Node.js 18+、Python 3.8+、Ollama
|
||||
环境: Node.js 18+、Python 3.8+
|
||||
|
||||
安装:
|
||||
- 前端: npm install
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
### 后端
|
||||
- main.py: FastAPI服务器、SSE流式响应
|
||||
- llm.py: 异步Ollama调用、超时控制
|
||||
- llm.py: 异步LLM调用(OpenAI兼容)、超时控制
|
||||
- prompt.py: 7条Prompt规则
|
||||
- tts_asr.py: macOS/Apple Silicon优化的TTS/ASR处理
|
||||
- 自动检测Apple Silicon (M1/M2/M3)
|
||||
|
||||
+28
-3
@@ -1,4 +1,29 @@
|
||||
OPENAI_API_KEY=ollama
|
||||
OLLAMA_BASE_URL=http://192.168.0.120:11434/v1/
|
||||
OLLAMA_MODEL=gpt-oss:20b
|
||||
# LLM provider (OpenAI-compatible endpoint)
|
||||
LLM_BASE_URL=http://localhost:11434/v1/
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
+385
-174
@@ -2,64 +2,70 @@ import os
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from typing import AsyncIterator
|
||||
import ollama
|
||||
from typing import AsyncIterator, Literal
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from prompts import get_vlm_ocr_prompt
|
||||
|
||||
load_dotenv()
|
||||
|
||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||
PRO_OLLAMA_MODEL = os.getenv('PRO_OLLAMA_MODEL', OLLAMA_MODEL)
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
||||
# OpenAI-compatible endpoint config
|
||||
LLM_BASE_URL = os.getenv('LLM_BASE_URL', 'http://localhost:11434/v1/')
|
||||
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')
|
||||
|
||||
# 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)
|
||||
COMPLETION_TIMEOUT = 600
|
||||
OCR_TIMEOUT = 600
|
||||
COMPLETION_TIMEOUT = int(os.getenv("LLM_COMPLETION_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]:
|
||||
content = ""
|
||||
thinking = ""
|
||||
|
||||
if hasattr(response, 'message') and response.message:
|
||||
content = response.message.content or ""
|
||||
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 ""
|
||||
|
||||
def _extract_message(response: dict) -> tuple[str, str]:
|
||||
"""Extract content and thinking from an OpenAI-compatible response dict."""
|
||||
choices = response.get('choices', []) if isinstance(response, dict) else []
|
||||
msg = (choices[0].get('message', {}) if choices and isinstance(choices, list) else {}).copy()
|
||||
content = msg.get('content', '') or ''
|
||||
thinking = (msg.get('reasoning_content') or msg.get('thinking', '') or '').strip()
|
||||
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():
|
||||
return f"{system_prompt}\n\n{prompt}"
|
||||
return prompt
|
||||
return system_prompt.strip()
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
|
||||
candidate = (model or '').strip()
|
||||
if 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,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
@@ -67,270 +73,475 @@ def _build_generate_kwargs(
|
||||
thinking: str | None = None,
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
stream: bool = False,
|
||||
) -> dict:
|
||||
kwargs = {
|
||||
"model": _resolve_model_name(model, use_pro_model=use_pro_model),
|
||||
"prompt": _build_prompt(prompt, system_prompt),
|
||||
"stream": stream,
|
||||
"raw": True,
|
||||
"options": {
|
||||
'temperature': temperature,
|
||||
'repeat_penalty': 1.1,
|
||||
},
|
||||
messages = []
|
||||
sys_prompt = _resolve_system_prompt(system_prompt)
|
||||
if sys_prompt:
|
||||
messages.append({'role': 'system', 'content': sys_prompt})
|
||||
messages.append({'role': 'user', 'content': prompt})
|
||||
|
||||
payload = {
|
||||
'model': _resolve_model_name(model, use_pro_model=use_pro_model),
|
||||
'messages': messages,
|
||||
'stream': False,
|
||||
}
|
||||
|
||||
options = {'temperature': temperature}
|
||||
if thinking:
|
||||
kwargs["think"] = thinking
|
||||
return kwargs
|
||||
payload['options'] = {'temperature': temperature, 'think': thinking}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_stream_text(chunk) -> str:
|
||||
content, _ = _extract_message(chunk)
|
||||
if content:
|
||||
def _build_chat_stream_payload(
|
||||
prompt: str,
|
||||
*,
|
||||
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})
|
||||
|
||||
payload = {
|
||||
'model': _resolve_model_name(model, use_pro_model=use_pro_model),
|
||||
'messages': messages,
|
||||
'stream': True,
|
||||
}
|
||||
|
||||
options = {'temperature': temperature}
|
||||
if thinking:
|
||||
payload['options'] = {'temperature': temperature, 'think': thinking}
|
||||
|
||||
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
|
||||
|
||||
if isinstance(chunk, dict):
|
||||
return chunk.get('response', '') or ''
|
||||
|
||||
if hasattr(chunk, 'response'):
|
||||
return getattr(chunk, 'response', '') or ''
|
||||
|
||||
return ''
|
||||
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(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
tag: str = "default",
|
||||
tag: str = 'default',
|
||||
temperature: float = 0.7,
|
||||
thinking: str | None = None,
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
调用 Ollama API 并返回 content 和 thinking。
|
||||
"""
|
||||
"""Call OpenAI-compatible chat completions (non-streaming) and return content/thinking."""
|
||||
start = time.perf_counter()
|
||||
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
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
||||
tag,
|
||||
model_name,
|
||||
OLLAMA_HOST,
|
||||
len(prompt),
|
||||
len(system_prompt or ""),
|
||||
temperature,
|
||||
thinking,
|
||||
'[LLM][%s] request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
|
||||
tag, log_model_name, LLM_BASE_URL, len(prompt),
|
||||
len(system_prompt or ''), temperature, thinking,
|
||||
)
|
||||
|
||||
payload = _build_chat_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:
|
||||
kwargs = _build_generate_kwargs(
|
||||
prompt,
|
||||
system_prompt=system_prompt,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
model=model,
|
||||
use_pro_model=use_pro_model,
|
||||
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,
|
||||
)
|
||||
|
||||
response = await asyncio.wait_for(client.generate(**kwargs), timeout=COMPLETION_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
response = resp.json()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] call_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] call_time [%s --> %s]', tag,
|
||||
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
|
||||
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] call_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] call_time [%s --> %s]', tag,
|
||||
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
|
||||
|
||||
content, thinking = _extract_message(response)
|
||||
content, thinking_out = _extract_message(response)
|
||||
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] call_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] call_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d",
|
||||
tag,
|
||||
elapsed_ms,
|
||||
type(response).__name__,
|
||||
len(content),
|
||||
len(thinking),
|
||||
'[LLM][%s] response in %.1fms content_chars=%d thinking_chars=%d',
|
||||
tag, elapsed_ms, len(content), len(thinking_out or ''),
|
||||
)
|
||||
|
||||
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(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
tag: str = "default-stream",
|
||||
tag: str = 'default-stream',
|
||||
temperature: float = 0.7,
|
||||
thinking: str | None = None,
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream text deltas from OpenAI-compatible chat completions."""
|
||||
start = time.perf_counter()
|
||||
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(
|
||||
"[LLM][%s] stream request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
||||
tag,
|
||||
model_name,
|
||||
OLLAMA_HOST,
|
||||
len(prompt),
|
||||
len(system_prompt or ""),
|
||||
temperature,
|
||||
thinking,
|
||||
'[LLM][%s] stream request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
|
||||
tag, log_model_name, LLM_BASE_URL, len(prompt),
|
||||
len(system_prompt or ''), temperature, thinking,
|
||||
)
|
||||
|
||||
try:
|
||||
kwargs = _build_generate_kwargs(
|
||||
prompt,
|
||||
system_prompt=system_prompt,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
model=model,
|
||||
use_pro_model=use_pro_model,
|
||||
stream=True,
|
||||
payload = _build_chat_stream_payload(
|
||||
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
|
||||
thinking=thinking, model=model, use_pro_model=use_pro_model,
|
||||
)
|
||||
stream = await client.generate(**kwargs)
|
||||
iterator = stream.__aiter__()
|
||||
|
||||
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:
|
||||
try:
|
||||
async with client.stream('POST', '/chat/completions', json=payload) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
deadline = time.perf_counter() + COMPLETION_TIMEOUT
|
||||
line_iterator = response.aiter_lines().__aiter__()
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("LLM stream timed out")
|
||||
raise TimeoutError('LLM stream timed out')
|
||||
|
||||
try:
|
||||
chunk = await asyncio.wait_for(iterator.__anext__(), timeout=remaining)
|
||||
line = await asyncio.wait_for(line_iterator.__anext__(), timeout=remaining)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
text = _extract_stream_text(chunk)
|
||||
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
|
||||
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] stream_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] stream_time [%s --> %s]', tag,
|
||||
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
|
||||
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] stream_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] stream_time [%s --> %s]', tag,
|
||||
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
|
||||
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] stream_time [%s --> %s]",
|
||||
tag,
|
||||
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,
|
||||
'[LLM][%s] stream_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
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_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(
|
||||
"[VLM][ocr] request model=%s host=%s image_bytes=%d language=%s",
|
||||
VLM_MODEL,
|
||||
OLLAMA_HOST,
|
||||
len(image_bytes),
|
||||
language,
|
||||
'[LLM][%s] event_stream request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
|
||||
tag, log_model_name, LLM_BASE_URL, len(prompt),
|
||||
len(system_prompt or ''), temperature, thinking,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
client.chat(
|
||||
model=VLM_MODEL,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': get_vlm_ocr_prompt(),
|
||||
'images': [image_bytes]
|
||||
}],
|
||||
stream=False,
|
||||
options={'temperature': 0.3}
|
||||
),
|
||||
timeout=OCR_TIMEOUT
|
||||
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:
|
||||
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
|
||||
try:
|
||||
async with client.stream('POST', '/chat/completions', json=payload) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
deadline = time.perf_counter() + effective_timeout
|
||||
line_iterator = response.aiter_lines().__aiter__()
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
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:
|
||||
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"),
|
||||
'[LLM][%s] event_stream_time [%s --> %s]', tag,
|
||||
start_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
|
||||
|
||||
content, thinking = _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"),
|
||||
'[LLM][%s] event_stream_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[VLM][ocr] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d",
|
||||
elapsed_ms,
|
||||
type(response).__name__,
|
||||
len(content),
|
||||
len(thinking),
|
||||
'[LLM][%s] event stream finished in %.1fms yielded_chars=%d thinking_seen=%s',
|
||||
tag, elapsed_ms, yielded_chars, sent_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():
|
||||
logger.warning("[VLM][ocr] empty content returned by model")
|
||||
logger.warning('[VLM][ocr] empty content returned by model')
|
||||
|
||||
return content
|
||||
|
||||
+3
-10
@@ -1,4 +1,4 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -39,15 +39,8 @@ def _get_markitdown(): # pragma: no cover
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.on_event("startup") # pragma: no cover
|
||||
async def startup_event():
|
||||
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}")
|
||||
|
||||
# Startup event disabled — TTS model loads lazily on first request
|
||||
# to avoid blocking startup and OOM crashes.
|
||||
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
|
||||
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
@@ -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
@@ -65,6 +65,53 @@ def _prepare_context(prefix: str, suffix: str) -> Tuple[str, str]:
|
||||
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_INFO_RE = re.compile(r"^[ \t]*```[ \t]*(.*)$")
|
||||
MERMAID_CONTEXT_RE = re.compile(
|
||||
@@ -256,7 +303,7 @@ def build_completion_prompts(
|
||||
location: str = "",
|
||||
thinking_level: str = "low",
|
||||
preferences: UserPreferences | None = None,
|
||||
) -> Tuple[str, str]:
|
||||
) -> Tuple[str, str, str]:
|
||||
safe_language_id = _canonical_language_id(language_id)
|
||||
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
|
||||
recent_prefix = _normalize_newlines(recent_prefix)
|
||||
@@ -269,6 +316,7 @@ def build_completion_prompts(
|
||||
)
|
||||
prefix_ends_with_newline = recent_prefix.endswith("\n")
|
||||
suffix_starts_with_newline = recent_suffix.startswith("\n")
|
||||
prefill = _build_completion_prefill(recent_prefix)
|
||||
|
||||
tz_pref = preferences.timezone if preferences else "auto"
|
||||
current_time = _get_current_datetime(tz_pref)
|
||||
@@ -303,38 +351,25 @@ Requirements:
|
||||
- Concise unless structure needs more
|
||||
- Follows markdown rules in system prompt
|
||||
- Use real line breaks instead of spelled-out escape sequences unless PREFIX or SUFFIX clearly requires that text
|
||||
|
||||
=== BOUNDARY DECISION GUIDE ===
|
||||
|
||||
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
|
||||
- If a boundary needs separation, put the real newline directly in OUTPUT
|
||||
- Do not explain newline or boundary choices
|
||||
- Continue after the PREFILL text already placed after <|fim_middle|>
|
||||
|
||||
=== CONTEXT NOTES ===
|
||||
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
|
||||
- Match PREFIX tone, style, and indentation
|
||||
- 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 ===
|
||||
{_INLINE_EXAMPLES}
|
||||
|
||||
=== 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)
|
||||
return system_prompt.strip(), user_prompt.strip()
|
||||
return system_prompt.strip(), user_prompt.strip(), prefill
|
||||
|
||||
|
||||
def build_prompt(
|
||||
@@ -348,7 +383,7 @@ def build_prompt(
|
||||
"""
|
||||
Backward-compatible helper. Returns only the user prompt body.
|
||||
"""
|
||||
_, user_prompt = build_completion_prompts(
|
||||
_, user_prompt, _ = build_completion_prompts(
|
||||
prefix=prefix,
|
||||
suffix=suffix,
|
||||
language_id=language_id,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
fastapi>=0.95.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
pydantic>=1.10.0
|
||||
httpx>=0.24.0
|
||||
|
||||
numpy>=1.23.0
|
||||
soundfile>=0.10.3
|
||||
torch>=1.12.0
|
||||
torchaudio>=0.12.0
|
||||
torchaudio>=1.12.0
|
||||
transformers>=4.25.0
|
||||
whisper>=1.0.0
|
||||
qwen-tts>=0.0.0
|
||||
modelscope>=1.20.0
|
||||
|
||||
# MLX-based ASR (Apple Silicon only)
|
||||
mlx-audio>=0.4.3
|
||||
|
||||
# testing
|
||||
pytest>=7.0.0
|
||||
|
||||
+263
-30
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,47 +17,279 @@ except ModuleNotFoundError:
|
||||
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 = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
async def fake_post(url, json=None):
|
||||
captured["url"] = url
|
||||
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(
|
||||
llm.call_ollama(
|
||||
"user prompt body",
|
||||
system_prompt="system prompt body",
|
||||
tag="test",
|
||||
temperature=0.1,
|
||||
)
|
||||
llm.call_ollama("test prompt", system_prompt="sys", tag="t1")
|
||||
)
|
||||
|
||||
assert result["content"] == "ok"
|
||||
assert captured["kwargs"]["prompt"] == "system prompt body\n\nuser prompt body"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
assert result["content"] == "done"
|
||||
assert captured["url"] == "/chat/completions"
|
||||
assert captured["json"]["stream"] is False
|
||||
|
||||
|
||||
def test_call_ollama_messages_roles_without_system(monkeypatch):
|
||||
def test_stream_ollama_text_deltas(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
def make_lines():
|
||||
lines_iter = iter([
|
||||
'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(
|
||||
llm.call_ollama(
|
||||
"user prompt only",
|
||||
system_prompt="",
|
||||
tag="test-no-system",
|
||||
temperature=0.1,
|
||||
)
|
||||
)
|
||||
class Response:
|
||||
def __init__(self2): self2._lines = LineIterator()
|
||||
|
||||
assert result["content"] == "ok"
|
||||
assert captured["kwargs"]["prompt"] == "user prompt only"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
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 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,")
|
||||
|
||||
+144
-174
@@ -2,6 +2,7 @@ import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -15,66 +16,27 @@ except ModuleNotFoundError:
|
||||
pytest.skip("llm module dependencies are not available", allow_module_level=True)
|
||||
|
||||
|
||||
def test_extract_message_with_object_message_content_and_thinking():
|
||||
class Msg:
|
||||
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"))
|
||||
def test_extract_message_with_content_and_thinking():
|
||||
resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "hello world"
|
||||
assert thinking == "thinking about it"
|
||||
assert thinking == "reasoning"
|
||||
|
||||
|
||||
def test_extract_message_with_object_message_empty_content():
|
||||
class Msg:
|
||||
def __init__(self, content, thinking):
|
||||
self.content = content
|
||||
self.thinking = thinking
|
||||
|
||||
class Resp:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
resp = Resp(Msg("", None))
|
||||
def test_extract_message_empty_content():
|
||||
resp = {"choices": [{"message": {"content": "", "thinking": None}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_with_dict_message():
|
||||
resp = {"message": {"content": "ok", "thinking": "calc"}}
|
||||
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"}}
|
||||
def test_extract_message_dict_no_choices():
|
||||
resp = {"not_choices": []}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
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():
|
||||
resp = {}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
@@ -82,174 +44,182 @@ def test_extract_message_empty_dict():
|
||||
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 = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
async def fake_post(url, json=None):
|
||||
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": "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(
|
||||
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 captured["kwargs"]["prompt"] == "user prompt body"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
# Should only have user message, no system
|
||||
assert len(captured["json"]["messages"]) == 1
|
||||
|
||||
|
||||
def test_call_ollama_whitespace_system_message(monkeypatch):
|
||||
def test_call_ollama_with_system(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
async def fake_post(url, json=None):
|
||||
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": "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(
|
||||
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 captured["kwargs"]["prompt"] == "user prompt"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
# Should have both system and user messages
|
||||
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 = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"response": "ok", "message": {"thinking": "boom"}} # keep thinking for backward test though it might not be perfect
|
||||
async def fake_post(url, json=None):
|
||||
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": "ok"}}]}
|
||||
|
||||
res = asyncio.run(
|
||||
llm.call_ollama("prompt", thinking="boom", tag="think-flag", temperature=0.7)
|
||||
)
|
||||
assert res["content"] == "ok" and res["think"] == "boom"
|
||||
assert captured.get("think") == "boom"
|
||||
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()
|
||||
|
||||
def test_call_ollama_cancelled_reraises(monkeypatch):
|
||||
async def fake_generate(**kwargs):
|
||||
raise asyncio.CancelledError
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
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)
|
||||
result = asyncio.run(
|
||||
llm.call_ollama("prompt", model="custom-model")
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
assert captured["json"]["model"] == "custom-model"
|
||||
|
||||
|
||||
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": "回答"},
|
||||
def test_stream_ollama_events_error_handling(monkeypatch):
|
||||
def make_lines():
|
||||
lines_iter = iter([
|
||||
'data: {"error": "model not found"}',
|
||||
])
|
||||
|
||||
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 for chunk in llm.stream_ollama(
|
||||
"prompt",
|
||||
system_prompt="system",
|
||||
tag="stream",
|
||||
temperature=0.8,
|
||||
model="pro-model",
|
||||
use_pro_model=True,
|
||||
):
|
||||
chunks.append(chunk)
|
||||
async def raise_for_status(self2): pass
|
||||
async def aiter_lines(self2): return self2._lines
|
||||
|
||||
asyncio.run(collect_stream())
|
||||
class StreamCtx:
|
||||
async def __aenter__(self2): return Response()
|
||||
async def __aexit__(*a): pass
|
||||
|
||||
assert "".join(chunks) == "深度回答"
|
||||
assert captured["kwargs"]["model"] == "pro-model"
|
||||
assert captured["kwargs"]["stream"] is True
|
||||
class Client:
|
||||
stream = lambda self2, *args, **kw: StreamCtx()
|
||||
|
||||
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):
|
||||
image_bytes = b"image-bytes"
|
||||
called = {}
|
||||
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
|
||||
def test_call_vlm_ocr_payload_format(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
called["kwargs"] = kwargs
|
||||
return {"message": {"content": "ocr result", "thinking": ""}}
|
||||
async def fake_post(url, json=None):
|
||||
captured["json"] = json
|
||||
|
||||
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", [])
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "OCR PROMPT"
|
||||
assert messages[0]["images"] == [image_bytes]
|
||||
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"image"))
|
||||
assert result == "ocr result"
|
||||
|
||||
|
||||
def test_call_vlm_ocr_chat_raises_rethrows(monkeypatch):
|
||||
image_bytes = b"image-bytes"
|
||||
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
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"
|
||||
# Verify vision format: image_url content part with base64
|
||||
msgs = captured["json"]["messages"]
|
||||
assert len(msgs) == 1
|
||||
content_parts = msgs[0]["content"]
|
||||
image_part = [p for p in content_parts if p.get("type") == "image_url"]
|
||||
assert len(image_part) == 1
|
||||
|
||||
@@ -17,6 +17,7 @@ if "tts_asr" not in sys.modules:
|
||||
sys.modules["tts_asr"] = fake_tts_asr
|
||||
|
||||
import main # type: ignore
|
||||
import pro_completions # type: ignore
|
||||
|
||||
API_KEY = main.API_KEY
|
||||
HEADERS = {"X-API-Key": API_KEY}
|
||||
@@ -25,8 +26,10 @@ HEADERS = {"X-API-Key": API_KEY}
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_active_completions():
|
||||
main.ACTIVE_COMPLETIONS.clear()
|
||||
pro_completions.PRO_STATES.clear()
|
||||
yield
|
||||
main.ACTIVE_COMPLETIONS.clear()
|
||||
pro_completions.PRO_STATES.clear()
|
||||
|
||||
|
||||
class DummyRequest:
|
||||
@@ -76,6 +79,33 @@ def test_sanitize_markdown_normalize_crlf():
|
||||
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():
|
||||
req = DummyRequest(host="1.2.3.4", headers={})
|
||||
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):
|
||||
captured = {}
|
||||
|
||||
async def fake_call(*args, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"content": "done", "think": ""}
|
||||
monkeypatch.setattr(main, "call_ollama", fake_call)
|
||||
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
|
||||
data = resp.json()
|
||||
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):
|
||||
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"))
|
||||
|
||||
def test_old_post_pro_stream_returns_404():
|
||||
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",
|
||||
"suffix": "",
|
||||
"languageId": "markdown",
|
||||
"model_thinking": "high",
|
||||
"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:
|
||||
assert resp.status_code == 200
|
||||
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: done" 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"]["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 == {}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -10,7 +10,7 @@ import prompt # noqa: E402
|
||||
|
||||
|
||||
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 ",
|
||||
suffix="for this dataset.",
|
||||
language_id="markdown",
|
||||
@@ -29,14 +29,36 @@ def test_prompt_builds_system_and_user():
|
||||
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
|
||||
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_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 "make the first character of OUTPUT a real newline" in user_prompt
|
||||
assert "make the last character of OUTPUT a real newline" in user_prompt
|
||||
assert "Do not explain newline or boundary choices" 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 "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():
|
||||
assert prompt._cursor_in_fenced_code_block("") is False
|
||||
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():
|
||||
_, user_prompt_a = prompt.build_completion_prompts(
|
||||
_, user_prompt_a, _ = prompt.build_completion_prompts(
|
||||
prefix="Hello",
|
||||
suffix="World",
|
||||
)
|
||||
@@ -63,7 +85,7 @@ def test_newline_flags():
|
||||
assert "PREFIX_ENDS_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",
|
||||
suffix="\nWorld",
|
||||
)
|
||||
@@ -73,7 +95,7 @@ def test_newline_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 --> ",
|
||||
suffix="\n```",
|
||||
)
|
||||
@@ -81,7 +103,7 @@ def test_mermaid_context_flags():
|
||||
assert "CURSOR_FENCE_LANGUAGE: mermaid" 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.",
|
||||
suffix="",
|
||||
)
|
||||
@@ -91,6 +113,6 @@ def test_mermaid_context_flags():
|
||||
|
||||
|
||||
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):
|
||||
assert f"[EX{ex:02d}]" in user_prompt
|
||||
|
||||
@@ -4,7 +4,9 @@ from pathlib import Path
|
||||
|
||||
# Ensure the project root is in sys.path so imports like `from backend import prompt` work
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = ROOT / "backend"
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from backend import prompt # type: ignore
|
||||
|
||||
@@ -120,22 +122,24 @@ def test_build_completion_prompts_with_userprefs():
|
||||
language = "python"
|
||||
currency = "USD"
|
||||
timezone = "UTC+0"
|
||||
system, user = prompt.build_completion_prompts(
|
||||
system, user, prefill = prompt.build_completion_prompts(
|
||||
prefix="hello", suffix="world", language_id="markdown",
|
||||
preferences=UserPrefs(),
|
||||
)
|
||||
assert isinstance(system, str)
|
||||
assert isinstance(user, str)
|
||||
assert prefill == "hello"
|
||||
assert "python" in user.lower() or "USD" in user
|
||||
|
||||
|
||||
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",
|
||||
location="",
|
||||
)
|
||||
assert isinstance(system, str)
|
||||
assert isinstance(user, str)
|
||||
assert prefill == "hello"
|
||||
|
||||
|
||||
def test_build_prompt_backward_compatibility():
|
||||
|
||||
@@ -1,327 +1,193 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import asyncio
|
||||
import types
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def _make_torch_stub(cuda_avail=False, mps_avail=False):
|
||||
class DummyTensor:
|
||||
def __matmul__(self, other): return self
|
||||
def matmul(self, other): return self
|
||||
|
||||
stub = types.SimpleNamespace()
|
||||
stub.float32 = "float32"
|
||||
stub.float16 = "float16"
|
||||
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 _make_mlx_stub():
|
||||
"""Create minimal MLX stub for testing without Apple Silicon"""
|
||||
mlx = types.SimpleNamespace()
|
||||
mlx.core = types.SimpleNamespace()
|
||||
mx_array = type('mx.array', (), {'item': lambda self: 1})
|
||||
mlx.core.array = mx_array
|
||||
mlx.nn = types.SimpleNamespace()
|
||||
return mlx
|
||||
|
||||
|
||||
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()):
|
||||
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]
|
||||
torch_stub = _make_torch_stub(cuda_avail=cuda_avail, mps_avail=mps_avail)
|
||||
sys.modules["torch"] = torch_stub
|
||||
if env_device is not None:
|
||||
os.environ["TTS_ASR_DEVICE"] = env_device
|
||||
elif "TTS_ASR_DEVICE" in os.environ:
|
||||
del os.environ["TTS_ASR_DEVICE"]
|
||||
|
||||
mlx_stub = _make_mlx_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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_tts_env():
|
||||
def _clean_env():
|
||||
"""Clean ASR-related env vars before/after each test"""
|
||||
saved = {}
|
||||
for k in ["TTS_ASR_DEVICE", "TTS_ASR_IDLE_TIMEOUT", "TTS_ASR_MODEL_SIZE",
|
||||
"TTS_ASR_QUANTIZE", "TTS_ASR_OFFLINE_MODE", "TTS_ASR_WARMUP",
|
||||
"TTS_ASR_MPS_MEMORY_LIMIT_MB"]:
|
||||
for k in ['HF_ENDPOINT']:
|
||||
saved[k] = os.environ.get(k)
|
||||
if k in os.environ:
|
||||
del os.environ[k]
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
elif k in os.environ:
|
||||
del os.environ[k]
|
||||
os.environ[k] = v # type: ignore (unused var)
|
||||
|
||||
|
||||
# --- Cache clearing ---
|
||||
def test_clear_cuda_cache():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cpu")
|
||||
tts._clear_cuda_cache()
|
||||
class TestRequestResponseModels:
|
||||
"""Pydantic 数据模型测试"""
|
||||
|
||||
|
||||
def test_clear_mps_cache():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="cpu")
|
||||
tts._clear_mps_cache()
|
||||
|
||||
|
||||
# --- Model cache check ---
|
||||
def test_check_model_cached_non_offline():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_OFFLINE_MODE"] = "false"
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
assert tts._check_model_cached("openai/whisper-tiny") is True
|
||||
|
||||
|
||||
def test_check_model_cached_offline_mode():
|
||||
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
|
||||
|
||||
|
||||
# --- 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"
|
||||
|
||||
|
||||
def test_get_torch_dtype_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_torch_dtype() == "float32"
|
||||
|
||||
|
||||
def test_get_torch_dtype_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_torch_dtype() == "float16"
|
||||
|
||||
|
||||
# --- Device detection ---
|
||||
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():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_device() == "mps"
|
||||
|
||||
|
||||
def test_get_device_mps_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="mps")
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_device_cuda_available():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cuda"
|
||||
|
||||
|
||||
def test_get_device_cuda_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_device_auto_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device=None)
|
||||
assert tts._get_device() == "mps"
|
||||
|
||||
|
||||
def test_get_device_auto_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cuda"
|
||||
|
||||
|
||||
def test_get_device_auto_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None)
|
||||
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 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()
|
||||
def test_tts_request_defaults(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
req = tts.TTSRequest(text="hello")
|
||||
assert req.text == "hello"
|
||||
assert req.voice == "af_bella"
|
||||
assert req.rate == 1.0
|
||||
assert req.format == "wav"
|
||||
assert req.speaker == "Vivian"
|
||||
|
||||
def test_asr_request_defaults(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
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"
|
||||
|
||||
def test_model_status_defaults(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
status = tts.ModelStatus(tts_loaded=False, asr_loaded=True, device="cpu")
|
||||
assert not status.tts_loaded
|
||||
assert status.asr_loaded
|
||||
|
||||
|
||||
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"
|
||||
class TestDeviceDetection:
|
||||
"""设备检测测试"""
|
||||
|
||||
def test_device_map_returns_string(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
device = tts._get_device_map()
|
||||
assert isinstance(device, str)
|
||||
|
||||
|
||||
# --- 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
|
||||
class TestModelLoading:
|
||||
"""模型加载测试"""
|
||||
|
||||
def test_load_asr_skips_when_mlx_unavailable(self):
|
||||
"""mlx_audio 未安装时应跳过 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]
|
||||
|
||||
# Don't inject mlx stubs — simulate missing MLX
|
||||
import tts_asr # noqa: F811
|
||||
|
||||
assert tts_asr.Qwen3ASRModel is None
|
||||
tts_asr._load_asr_models() # should not crash
|
||||
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')
|
||||
|
||||
assert tts._asr_model is not None # type: ignore (MagicMock)
|
||||
|
||||
|
||||
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
|
||||
class TestWarmupFunctions:
|
||||
"""预热函数测试"""
|
||||
|
||||
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_warmup_asr_skips_when_mlx_unavailable(self):
|
||||
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_warmup_all_runs_without_error(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
|
||||
# Set global models so warmup returns immediately without actual loading
|
||||
tts._tts_model = MagicMock()
|
||||
|
||||
async def run(): # type: ignore (unused var)
|
||||
await tts._warmup_all()
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(run()) # type: ignore
|
||||
|
||||
|
||||
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
|
||||
class TestRouteRegistration:
|
||||
"""路由注册测试"""
|
||||
|
||||
def test_register_function_exists(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert callable(tts.register_tts_asr_routes)
|
||||
|
||||
def test_router_prefix(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert hasattr(tts.router, 'routes')
|
||||
|
||||
|
||||
# --- Apple Silicon check ---
|
||||
def test_is_apple_silicon_windows():
|
||||
tts = _reload_tts_asr()
|
||||
assert tts._is_apple_silicon() is False
|
||||
class TestModelConstants:
|
||||
"""模型常量测试"""
|
||||
|
||||
def test_asr_model_id(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert 'Qwen3-ASR' in tts.ASR_MODEL_ID_MS
|
||||
|
||||
# --- 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"
|
||||
def test_align_model_id(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert 'ForcedAligner' in tts.ALIGN_MODEL_ID_MS
|
||||
|
||||
@@ -1,231 +1,263 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
import io
|
||||
import types
|
||||
import wave
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
import numpy as np
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def _make_torch_stub(cuda_avail=False, mps_avail=False):
|
||||
class DummyTensor:
|
||||
def __matmul__(self, other):
|
||||
return self
|
||||
def matmul(self, other):
|
||||
return self
|
||||
def _make_mlx_stub():
|
||||
"""Create minimal MLX stub for testing without Apple Silicon"""
|
||||
mlx = types.SimpleNamespace()
|
||||
mlx.core = types.SimpleNamespace()
|
||||
mx_array = type('mx.array', (), {'item': lambda self: 1})
|
||||
mlx.core.array = mx_array
|
||||
|
||||
def dummy_randn(*args, **kwargs):
|
||||
return DummyTensor()
|
||||
def dummy_mm(a, b):
|
||||
return DummyTensor()
|
||||
def dummy_from_numpy(arr):
|
||||
return DummyTensor()
|
||||
def mock_load(path):
|
||||
return MagicMock()
|
||||
mlx.core.load = mock_load # type: ignore
|
||||
|
||||
stub = types.SimpleNamespace()
|
||||
stub.float32 = "float32"
|
||||
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
|
||||
mlx.nn = types.SimpleNamespace()
|
||||
return mlx
|
||||
|
||||
|
||||
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()):
|
||||
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]
|
||||
|
||||
torch_stub = _make_torch_stub(cuda_avail=cuda_avail, mps_avail=mps_avail)
|
||||
sys.modules["torch"] = torch_stub
|
||||
mlx_stub = _make_mlx_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:
|
||||
os.environ["TTS_ASR_DEVICE"] = env_device
|
||||
elif "TTS_ASR_DEVICE" in os.environ:
|
||||
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
|
||||
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, audio_stub
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env():
|
||||
"""Clean ASR-related env vars before/after each test"""
|
||||
saved = {}
|
||||
for k in ["TTS_ASR_DEVICE", "TTS_ASR_IDLE_TIMEOUT", "TTS_ASR_MODEL_SIZE",
|
||||
"TTS_ASR_QUANTIZE", "TTS_ASR_OFFLINE_MODE", "TTS_ASR_WARMUP",
|
||||
"TTS_ASR_MPS_MEMORY_LIMIT_MB"]:
|
||||
for k in ['HF_ENDPOINT']:
|
||||
saved[k] = os.environ.get(k)
|
||||
if k in os.environ:
|
||||
del os.environ[k]
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
elif k in os.environ:
|
||||
del os.environ[k]
|
||||
os.environ[k] = v # type: ignore
|
||||
|
||||
|
||||
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 _make_wav_bytes(sr=16000, duration_sec=1.0, channels=1):
|
||||
"""Helper: generate WAV bytes as base64"""
|
||||
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():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_device() == "mps"
|
||||
class TestASRLazyLoading:
|
||||
"""测试 ASR 模型懒加载"""
|
||||
|
||||
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():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="mps")
|
||||
assert tts._get_device() == "cpu"
|
||||
class TestASREndpoint:
|
||||
"""测试 ASR 端点逻辑"""
|
||||
|
||||
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():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cuda"
|
||||
class TestASRModelDownload:
|
||||
"""测试 ASR 模型下载路径"""
|
||||
|
||||
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():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cpu"
|
||||
class TestModelConstants:
|
||||
"""测试模型 ID 常量"""
|
||||
|
||||
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():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device=None)
|
||||
assert tts._get_device() == "mps"
|
||||
class TestHFEndpointMirror:
|
||||
"""测试镜像站配置"""
|
||||
|
||||
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():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cuda"
|
||||
def test_hf_endpoint_default(self):
|
||||
"""即使环境变量未设置,模块也应默认设置镜像"""
|
||||
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():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None)
|
||||
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("")
|
||||
import tts_asr # noqa: F811
|
||||
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TTS/ASR模块集成测试
|
||||
TTS/ASR模块集成测试 — MLX/Qwen3-ASR 版本
|
||||
测试API端点和完整流程(需要运行后端服务)
|
||||
|
||||
运行方式:
|
||||
# 方式1: 使用pytest
|
||||
pytest backend/tests/test_tts_asr_integration.py -v -s
|
||||
python backend/tests/test_tts_asr_integration.py --test asr
|
||||
|
||||
# 方式2: 直接运行
|
||||
python backend/tests/test_tts_asr_integration.py
|
||||
|
||||
# 方式3: 测试特定端点
|
||||
python backend/tests/test_tts_asr_integration.py --test config
|
||||
MLX 模型通过 ModelScope (aufklarer/Qwen3-ASR) + ForcedAligner
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
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_KEY = os.environ.get('API_KEY', 'your-secret-key-here')
|
||||
TEST_TIMEOUT = 120.0 # 2分钟超时
|
||||
TEST_TIMEOUT = 120.0
|
||||
|
||||
|
||||
class TTSASRIntegrationTest(unittest.TestCase):
|
||||
@@ -34,11 +38,9 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""测试类初始化"""
|
||||
cls.client = httpx.Client(timeout=TEST_TIMEOUT)
|
||||
cls.headers = {'X-API-Key': API_KEY}
|
||||
|
||||
# 检查服务是否运行
|
||||
try:
|
||||
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
|
||||
if response.status_code == 200:
|
||||
@@ -47,18 +49,15 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
else:
|
||||
cls.service_available = False
|
||||
print(f"\n✗ 服务返回非200状态码: {response.status_code}")
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: ANN001
|
||||
cls.service_available = False
|
||||
print(f"\n✗ 无法连接到服务: {e}")
|
||||
print(f" 请确保后端服务正在运行: python backend/main.py")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""测试类清理"""
|
||||
cls.client.close()
|
||||
|
||||
def setUp(self):
|
||||
"""每个测试前的检查"""
|
||||
if not self.service_available:
|
||||
self.skipTest("后端服务不可用")
|
||||
|
||||
@@ -72,36 +71,20 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
config = response.json()
|
||||
|
||||
# 验证配置结构
|
||||
self.assertIn('environment', config)
|
||||
self.assertIn('device', config)
|
||||
self.assertIn('model', 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']
|
||||
status = config['status']
|
||||
self.assertIn('tts', model)
|
||||
self.assertIn('asr_current_size', model)
|
||||
self.assertIn('available_sizes', model)
|
||||
self.assertIn('asr', model)
|
||||
|
||||
print(f"\n配置信息:")
|
||||
print(f" 设备: {device['current']}")
|
||||
print(f" Apple Silicon: {device['is_apple_silicon']}")
|
||||
print(f" MPS可用: {device['mps_available']}")
|
||||
print(f" ASR模型大小: {model['asr_current_size']}")
|
||||
print(f" TTS模型: {model['tts']}")
|
||||
print(f" ASR模型: {model.get('asr', 'N/A')}")
|
||||
print(f" TTS已加载: {status['tts_loaded']}")
|
||||
print(f" ASR已加载: {status['asr_loaded']}")
|
||||
|
||||
def test_02_status_endpoint(self):
|
||||
"""测试状态端点"""
|
||||
@@ -113,19 +96,14 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
status = response.json()
|
||||
|
||||
# 验证状态结构
|
||||
self.assertIn('tts_loaded', status)
|
||||
self.assertIn('asr_loaded', status)
|
||||
self.assertIn('device', status)
|
||||
self.assertIn('offline_mode', status)
|
||||
self.assertIn('quantize_enabled', status)
|
||||
|
||||
print(f"\n状态信息:")
|
||||
print(f" TTS已加载: {status['tts_loaded']}")
|
||||
print(f" ASR已加载: {status['asr_loaded']}")
|
||||
print(f" 设备: {status['device']}")
|
||||
print(f" 离线模式: {status['offline_mode']}")
|
||||
print(f" 量化启用: {status['quantize_enabled']}")
|
||||
|
||||
def test_03_warmup_endpoint(self):
|
||||
"""测试预热端点"""
|
||||
@@ -134,7 +112,7 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/warmup',
|
||||
headers=self.headers
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
@@ -144,34 +122,24 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
|
||||
self.assertIn('tts_warmup', result)
|
||||
self.assertIn('asr_warmup', result)
|
||||
self.assertIn('device', result)
|
||||
|
||||
print(f"\n预热完成 (耗时: {elapsed:.2f}秒):")
|
||||
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['asr_warmup']:
|
||||
if not result['tts_warmup'] or not result.get('asr_warmup'):
|
||||
print("\n⚠ 警告: 预热失败可能是因为模型未下载")
|
||||
print(" 请确保网络连接正常,或使用已下载的模型")
|
||||
|
||||
def test_04_tts_endpoint_basic(self):
|
||||
"""测试TTS基本功能"""
|
||||
# 简单的中文文本
|
||||
test_text = "这是一个测试"
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/tts',
|
||||
headers=self.headers,
|
||||
json={
|
||||
'text': test_text,
|
||||
'voice': 'af_bella',
|
||||
'rate': 1.0,
|
||||
'format': 'wav'
|
||||
}
|
||||
json={'text': test_text}
|
||||
)
|
||||
|
||||
# 检查响应
|
||||
if response.status_code == 500:
|
||||
error = response.json()
|
||||
print(f"\n⚠ TTS失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
|
||||
@@ -180,38 +148,27 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
# 验证响应结构
|
||||
self.assertIn('audio_base64', result)
|
||||
self.assertIn('format', result)
|
||||
self.assertIn('duration_ms', result)
|
||||
|
||||
# 验证音频数据
|
||||
audio_data = base64.b64decode(result['audio_base64'])
|
||||
self.assertGreater(len(audio_data), 0)
|
||||
self.assertGreater(result['duration_ms'], 0)
|
||||
|
||||
print(f"\nTTS测试成功:")
|
||||
print(f" 输入文本: {test_text}")
|
||||
print(f" 音频大小: {len(audio_data)} bytes")
|
||||
print(f" 时长: {result['duration_ms']} ms")
|
||||
|
||||
def test_05_asr_endpoint_basic(self):
|
||||
"""测试ASR基本功能"""
|
||||
# 创建一个简单的静音WAV文件(1秒,16kHz,单声道)
|
||||
sample_rate = 16000
|
||||
duration = 1.0
|
||||
samples = int(sample_rate * duration)
|
||||
|
||||
# 生成静音数据
|
||||
import numpy as np
|
||||
silence = np.zeros(samples, dtype=np.int16)
|
||||
|
||||
# 创建WAV文件字节流
|
||||
import io
|
||||
import wave
|
||||
|
||||
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.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
@@ -220,7 +177,6 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
audio_bytes = wav_buffer.getvalue()
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode()
|
||||
|
||||
# 发送ASR请求
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/asr',
|
||||
headers=self.headers,
|
||||
@@ -230,65 +186,31 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
# 检查响应
|
||||
if response.status_code == 500:
|
||||
error = response.json()
|
||||
print(f"\n⚠ ASR失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
|
||||
if response.status_code in (500, 501):
|
||||
detail = response.json().get('detail', 'Unknown')
|
||||
print(f"\n⚠ ASR失败: {detail}")
|
||||
self.skipTest("ASR模型未加载或不可用")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
# 验证响应结构
|
||||
self.assertIn('text', result)
|
||||
self.assertIn('language', result)
|
||||
|
||||
print(f"\nASR测试成功:")
|
||||
print(f" 识别文本: '{result['text']}'")
|
||||
print(f" 语言: {result['language']}")
|
||||
print(f" 注意: 静音音频应该返回空文本")
|
||||
|
||||
def test_06_api_key_validation(self):
|
||||
"""测试API密钥验证"""
|
||||
# 使用错误的API密钥
|
||||
wrong_headers = {'X-API-Key': 'wrong-api-key'}
|
||||
|
||||
response = self.client.get(
|
||||
f'{API_BASE_URL}/v1/tts-asr/status',
|
||||
headers=wrong_headers
|
||||
headers=wrong_headers,
|
||||
)
|
||||
|
||||
# 应该返回403 Forbidden
|
||||
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):
|
||||
@@ -302,7 +224,7 @@ class PerformanceTest(unittest.TestCase):
|
||||
try:
|
||||
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
|
||||
cls.service_available = response.status_code == 200
|
||||
except:
|
||||
except Exception: # noqa: ANN001, S110
|
||||
cls.service_available = False
|
||||
|
||||
@classmethod
|
||||
@@ -315,15 +237,13 @@ class PerformanceTest(unittest.TestCase):
|
||||
|
||||
def test_tts_latency(self):
|
||||
"""测试TTS延迟"""
|
||||
test_text = "测试延迟"
|
||||
|
||||
latencies = []
|
||||
for i in range(3):
|
||||
start = time.time()
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/tts',
|
||||
headers=self.headers,
|
||||
json={'text': test_text}
|
||||
json={'text': '测试延迟'}
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
@@ -331,62 +251,54 @@ class PerformanceTest(unittest.TestCase):
|
||||
latencies.append(elapsed)
|
||||
|
||||
if latencies:
|
||||
avg_latency = sum(latencies) / len(latencies)
|
||||
print(f"\nTTS延迟测试:")
|
||||
print(f" 平均延迟: {avg_latency:.3f}秒")
|
||||
print(f" 最小延迟: {min(latencies):.3f}秒")
|
||||
print(f" 最大延迟: {max(latencies):.3f}秒")
|
||||
print(f" 平均: {sum(latencies)/len(latencies):.3f}s")
|
||||
print(f" 最小: {min(latencies):.3f}s / 最大: {max(latencies):.3f}s")
|
||||
|
||||
|
||||
def run_tests(test_type: Optional[str] = None):
|
||||
def run_tests(test_type: Optional[str] = None) -> bool:
|
||||
"""运行测试"""
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
if test_type == 'config':
|
||||
suite.addTest(TTSASRIntegrationTest('test_01_config_endpoint'))
|
||||
elif test_type == 'status':
|
||||
suite.addTest(TTSASRIntegrationTest('test_02_status_endpoint'))
|
||||
elif test_type == 'warmup':
|
||||
suite.addTest(TTSASRIntegrationTest('test_03_warmup_endpoint'))
|
||||
elif test_type == 'tts':
|
||||
suite.addTest(TTSASRIntegrationTest('test_04_tts_endpoint_basic'))
|
||||
elif test_type == 'asr':
|
||||
suite.addTest(TTSASRIntegrationTest('test_05_asr_endpoint_basic'))
|
||||
elif test_type == 'perf':
|
||||
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest))
|
||||
TEST_MAP = {
|
||||
'config': ('TTSASRIntegrationTest', 'test_01_config_endpoint'),
|
||||
'status': ('TTSASRIntegrationTest', 'test_02_status_endpoint'),
|
||||
'warmup': ('TTSASRIntegrationTest', 'test_03_warmup_endpoint'),
|
||||
'tts': ('TTSASRIntegrationTest', 'test_04_tts_endpoint_basic'),
|
||||
'asr': ('TTSASRIntegrationTest', 'test_05_asr_endpoint_basic'),
|
||||
'perf': ('PerformanceTest', None),
|
||||
}
|
||||
|
||||
if test_type and test_type in TEST_MAP:
|
||||
cls_name, method = TEST_MAP[test_type]
|
||||
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:
|
||||
# 运行所有测试
|
||||
suite.addTests(loader.loadTestsFromTestCase(TTSASRIntegrationTest))
|
||||
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest))
|
||||
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='TTS/ASR集成测试')
|
||||
parser.add_argument('--test', choices=[
|
||||
'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密钥')
|
||||
parser = argparse.ArgumentParser(description='TTS/ASR 集成测试')
|
||||
parser.add_argument('--test', choices=['config', 'status', 'warmup', 'tts', 'asr', 'perf', 'api_key'])
|
||||
parser.add_argument('--url', default=API_BASE_URL)
|
||||
parser.add_argument('--key', default=API_KEY)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 更新配置
|
||||
API_BASE_URL = args.url
|
||||
API_KEY = args.key
|
||||
|
||||
print("=" * 70)
|
||||
print("TTS/ASR 集成测试")
|
||||
print("=" * 70)
|
||||
print(f"API URL: {API_BASE_URL}")
|
||||
print(f"测试类型: {args.test or '全部'}")
|
||||
print("TTS/ASR 集成测试 (MLX/Qwen3-ASR)")
|
||||
print("=" * 70)
|
||||
|
||||
success = run_tests(args.test)
|
||||
|
||||
+115
-335
@@ -1,376 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TTS/ASR模块单元测试
|
||||
测试核心功能,无需实际运行模型
|
||||
TTS/ASR模块单元测试 — 测试核心功能,无需实际运行模型
|
||||
|
||||
运行方式:
|
||||
pytest backend/tests/test_tts_asr_unit.py -v
|
||||
python backend/tests/test_tts_asr_unit.py
|
||||
MLX/Qwen3-ASR 版本:仅测试数据模型、设备检测等轻量逻辑
|
||||
运行方式: pytest backend/tests/test_tts_asr_unit.py -v --no-cov
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
import wave
|
||||
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__), '..')))
|
||||
|
||||
|
||||
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)
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TestRequestResponseModels(unittest.TestCase):
|
||||
"""测试请求/响应数据模型"""
|
||||
|
||||
def test_tts_request_model(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请求模型"""
|
||||
def test_asr_request_defaults(self):
|
||||
from backend.tts_asr import ASRRequest
|
||||
|
||||
req = ASRRequest(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):
|
||||
"""测试ModelStatus模型"""
|
||||
def test_asr_request_with_language(self):
|
||||
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
|
||||
|
||||
status = ModelStatus(
|
||||
tts_loaded=False,
|
||||
asr_loaded=False,
|
||||
device='cpu'
|
||||
)
|
||||
|
||||
status = ModelStatus(tts_loaded=False, asr_loaded=True, device="mps")
|
||||
self.assertFalse(status.tts_loaded)
|
||||
self.assertFalse(status.asr_loaded)
|
||||
self.assertEqual(status.device, 'cpu')
|
||||
self.assertIsNone(status.tts_last_used)
|
||||
self.assertIsNone(status.asr_last_used)
|
||||
self.assertTrue(status.asr_loaded)
|
||||
self.assertEqual(status.device, "mps")
|
||||
|
||||
|
||||
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():
|
||||
"""运行所有测试"""
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
# 添加所有测试类
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAppleSiliconDetection))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestEnvironmentVariables))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestModelSizeSelection))
|
||||
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))
|
||||
for cls in (TestRequestResponseModels, TestDeviceDetection,
|
||||
TestAudioDecoding, TestModelLoadingFunctions,
|
||||
TestWarmupFunctions, TestRouteRegistration):
|
||||
suite.addTests(loader.loadTestsFromTestCase(cls))
|
||||
|
||||
# 运行测试
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 直接运行时执行测试
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
+280
-45
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import wave
|
||||
from typing import Optional
|
||||
|
||||
# 设置 Hugging Face 镜像源为国内镜像
|
||||
# 设置 Hugging Face / ModelScope 镜像源为国内镜像
|
||||
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||
|
||||
import numpy as np
|
||||
@@ -18,18 +20,41 @@ logger = logging.getLogger(__name__)
|
||||
# New TTS model import
|
||||
try:
|
||||
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
|
||||
|
||||
# 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()
|
||||
|
||||
# Global TTS model instance
|
||||
# Global model instances
|
||||
_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_ID_HF = "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:
|
||||
"""设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU"""
|
||||
@@ -38,15 +63,14 @@ def _get_device_map() -> str:
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.debug("MPS check failed: %s", e)
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _download_model_from_modelscope() -> Optional[str]:
|
||||
"""从 ModelScope 下载模型到本地临时目录"""
|
||||
"""从 ModelScope 下载模型到本地缓存目录"""
|
||||
try:
|
||||
from modelscope import snapshot_download
|
||||
cache_dir = os.path.join(os.path.dirname(__file__), "models")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
model_dir = snapshot_download(
|
||||
@@ -56,7 +80,7 @@ def _download_model_from_modelscope() -> Optional[str]:
|
||||
)
|
||||
logger.info("ModelScope 模型下载完成: %s", model_dir)
|
||||
return model_dir
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.warning("ModelScope 下载失败: %s", e)
|
||||
return None
|
||||
|
||||
@@ -66,12 +90,22 @@ async def _warmup_tts():
|
||||
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():
|
||||
"""预热所有模型(TTS 和 ASR)"""
|
||||
logger.info("[Warmup] 开始预热 TTS 模型...")
|
||||
await _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":
|
||||
"""加载 TTS 模型,支持多个镜像源"""
|
||||
@@ -87,38 +121,136 @@ def _load_tts_model_with_retry(max_retries: int = 3) -> "Qwen3TTSModel":
|
||||
# 策略1: 尝试从 ModelScope 下载后加载
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info("尝试从 ModelScope 下载模型...")
|
||||
logger.info("尝试从 ModelScope 下载 TTS 模型...")
|
||||
model_path = _download_model_from_modelscope()
|
||||
if model_path and os.path.isdir(model_path):
|
||||
_tts_model = Qwen3TTSModel.from_pretrained(
|
||||
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
|
||||
model_path,
|
||||
device_map=device_map,
|
||||
dtype=torch.float16,
|
||||
)
|
||||
logger.info("ModelScope 模型加载成功: %s", model_path)
|
||||
logger.info("ModelScope TTS 模型加载成功: %s", model_path)
|
||||
return _tts_model
|
||||
except Exception as e:
|
||||
logger.warning("ModelScope 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.warning("ModelScope TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
|
||||
last_err = e
|
||||
|
||||
# 策略2: 尝试从 HuggingFace 镜像加载
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info("尝试从 HuggingFace 镜像加载模型...")
|
||||
_tts_model = Qwen3TTSModel.from_pretrained(
|
||||
logger.info("尝试从 HuggingFace 镜像加载 TTS...")
|
||||
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
|
||||
MODEL_ID_HF,
|
||||
device_map=device_map,
|
||||
dtype=torch.float16,
|
||||
)
|
||||
logger.info("HuggingFace 模型加载成功")
|
||||
logger.info("HuggingFace TTS 模型加载成功")
|
||||
return _tts_model
|
||||
except Exception as e:
|
||||
logger.warning("HuggingFace 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.warning("HuggingFace TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
|
||||
last_err = e
|
||||
|
||||
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):
|
||||
text: str
|
||||
instruct: str = ""
|
||||
@@ -132,28 +264,62 @@ class TTSResponse(BaseModel):
|
||||
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):
|
||||
tts_loaded: bool
|
||||
asr_loaded: bool = False
|
||||
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
|
||||
if _tts_model is None:
|
||||
_tts_model = _load_tts_model_with_retry()
|
||||
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)
|
||||
async def get_status():
|
||||
"""获取模型状态"""
|
||||
return ModelStatus(
|
||||
tts_loaded=_tts_model is not None,
|
||||
asr_loaded=False,
|
||||
asr_loaded=_asr_model is not None,
|
||||
device=_get_device_map(),
|
||||
)
|
||||
|
||||
@@ -163,13 +329,13 @@ async def get_config():
|
||||
"""获取配置信息"""
|
||||
return {
|
||||
"model": {
|
||||
"tts": "Qwen3-TTS-12Hz-1.7B-VoiceDesign",
|
||||
"asr": None,
|
||||
"tts": MODEL_ID_MS,
|
||||
"asr": ASR_MODEL_ID_MS if Qwen3ASRModel is not None else None,
|
||||
},
|
||||
"device": _get_device_map(),
|
||||
"status": {
|
||||
"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():
|
||||
"""手动触发模型预热"""
|
||||
await _warmup_tts()
|
||||
|
||||
if Qwen3ASRModel is not None:
|
||||
await _warmup_asr()
|
||||
|
||||
return {
|
||||
"tts_warmup": _tts_model is not None,
|
||||
"asr_warmup": _asr_model is not None if Qwen3ASRModel else False,
|
||||
"device": _get_device_map(),
|
||||
}
|
||||
|
||||
@@ -188,8 +359,8 @@ async def warmup_models():
|
||||
async def tts_endpoint(req: TTSRequest):
|
||||
"""TTS 文字转语音端点"""
|
||||
try:
|
||||
model = _ensure_model()
|
||||
except Exception as e:
|
||||
model = _ensure_tts_model()
|
||||
except Exception as e: # noqa: ANN001
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
text = req.text
|
||||
@@ -197,51 +368,47 @@ async def tts_endpoint(req: TTSRequest):
|
||||
|
||||
try:
|
||||
# VoiceDesign 模型使用 generate_voice_design 方法
|
||||
# 返回 (wavs, sr),其中 wavs 是列表,wavs[0] 是第一个音频数据
|
||||
wavs, sr = model.generate_voice_design(
|
||||
wavs, sr = model.generate_voice_design( # type: ignore
|
||||
text=text,
|
||||
language="Chinese",
|
||||
instruct=instruct,
|
||||
)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.exception("TTS 推理失败")
|
||||
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
|
||||
|
||||
# 获取第一个音频数据
|
||||
# Get first audio data
|
||||
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
|
||||
|
||||
# 转换为 numpy 数组
|
||||
if hasattr(wav_data, 'numpy'):
|
||||
wav_data = wav_data.cpu().numpy()
|
||||
# Convert to numpy array
|
||||
if hasattr(wav_data, 'numpy'): # type: ignore
|
||||
wav_data = wav_data.cpu().numpy() # type: ignore
|
||||
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)
|
||||
|
||||
# 编码 WAV 到内存
|
||||
# Encode WAV to memory
|
||||
tmp_path = None
|
||||
try:
|
||||
import soundfile as sf
|
||||
# 创建临时文件
|
||||
import soundfile as sf # type: ignore
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
|
||||
os.close(fd)
|
||||
os.close(fd) # type: ignore
|
||||
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()
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.exception("音频编码失败")
|
||||
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
if tmp_path and os.path.exists(tmp_path): # noqa: SIM201
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
except Exception as e: # noqa: ANN001
|
||||
pass
|
||||
|
||||
# 计算时长(毫秒)
|
||||
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0
|
||||
|
||||
# 返回 JSON 格式,包含 base64 编码的音频
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
|
||||
return TTSResponse(
|
||||
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):
|
||||
"""注册 TTS/ASR 路由到 FastAPI 应用"""
|
||||
app.include_router(router, prefix="/v1/tts-asr")
|
||||
|
||||
Generated
+69
-657
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@
|
||||
"tui-color-picker": "^2.2.8",
|
||||
"tui-image-editor": "^3.15.3",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView } from '../plugins/hiddenTextPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
|
||||
|
||||
const props = defineProps({
|
||||
docType: { type: String, default: 'txt' },
|
||||
@@ -81,13 +82,16 @@ const toggleCollapse = () => {
|
||||
|
||||
const syncContent = () => {
|
||||
if (!crepe) return
|
||||
// Skip content sync when tab is hidden (energy saving for nested editors)
|
||||
if (!isDocumentVisible()) return
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
const syncInterval = getRecommendedSyncInterval(120)
|
||||
syncTimer = setTimeout(async () => {
|
||||
if (!crepe || syncingExternal) return
|
||||
const markdown = await crepe.getMarkdown()
|
||||
currentContent.value = markdown
|
||||
props.onUpdateContent?.(markdown)
|
||||
}, 120)
|
||||
}, syncInterval)
|
||||
}
|
||||
|
||||
const syncExternalContent = async (nextValue) => {
|
||||
@@ -139,7 +143,7 @@ onMounted(async () => {
|
||||
if (payload?.blocked) return ''
|
||||
return fetchSuggestion(payload?.prefix ?? prefix, payload?.suffix ?? suffix, payload?.languageId ?? languageId, signal)
|
||||
},
|
||||
debounceMs: 900,
|
||||
debounceMs: getRecommendedDebounce(900),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@
|
||||
</button>
|
||||
<div v-if="showExportDropdown" class="export-dropdown">
|
||||
<button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button>
|
||||
<button type="button" @click="() => { exportDocx(); showExportDropdown = false; }">{{ 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('exportDocx') }}</button>
|
||||
<button type="button" class="disabled-export" :title="t('exportDisabledHint')" @click="showExportDisabled">{{ t('exportPdf') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,6 +324,7 @@ import TTSMenu from './TTSMenu.vue'
|
||||
import TTSPlayer from './TTSPlayer.vue'
|
||||
import { convertFileToMarkdown } from '../utils/convert.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 { isUploadBlockTypeAllowed } from '../utils/uploadBlock.js'
|
||||
|
||||
@@ -353,7 +354,6 @@ const canUndo = ref(false)
|
||||
const canRedo = ref(false)
|
||||
const isDocUploadDisabled = ref(false)
|
||||
const uploadProgress = ref(null)
|
||||
const proThinkingActive = ref(false)
|
||||
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
|
||||
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
|
||||
const undoLabel = computed(() => t('undo') || 'Undo')
|
||||
@@ -397,6 +397,7 @@ const acceptAll = computed(() => {
|
||||
|
||||
let crepe = null
|
||||
let markdownSyncTimer = null
|
||||
let historyUpdateTimer = null
|
||||
let editorCopyHandler = null
|
||||
let documentClickHandler = null
|
||||
const objectUrls = new Set()
|
||||
@@ -689,11 +690,16 @@ const refreshSizeAndLimit = (ctx) => {
|
||||
const scheduleMarkdownSync = () => {
|
||||
if (!crepe) return
|
||||
|
||||
// Skip markdown sync when tab is hidden (energy saving on mobile/PC)
|
||||
if (!isDocumentVisible()) return
|
||||
|
||||
if (markdownSyncTimer) {
|
||||
clearTimeout(markdownSyncTimer)
|
||||
markdownSyncTimer = null
|
||||
}
|
||||
|
||||
const syncInterval = getRecommendedSyncInterval(120)
|
||||
|
||||
markdownSyncTimer = setTimeout(async () => {
|
||||
markdownSyncTimer = null
|
||||
if (!crepe) return
|
||||
@@ -1267,21 +1273,17 @@ crepe = new Crepe({
|
||||
crepe.editor.config((ctx) => {
|
||||
ctx.set(copilotConfigCtx.key, {
|
||||
fetchSuggestion,
|
||||
debounceMs: settings.debounceMs
|
||||
debounceMs: getRecommendedDebounce(settings.debounceMs)
|
||||
})
|
||||
})
|
||||
|
||||
crepe.editor.config((ctx) => {
|
||||
ctx.set(proBlockConfigCtx.key, {
|
||||
fetchSuggestionStream: fetchProSuggestionStream,
|
||||
getProModel: () => settings.proModel,
|
||||
t,
|
||||
showError: (message) => {
|
||||
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)
|
||||
ctx.set(copilotConfigCtx.key, {
|
||||
...config,
|
||||
debounceMs: newVal
|
||||
debounceMs: getRecommendedDebounce(newVal)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1348,7 +1350,13 @@ crepe = new Crepe({
|
||||
const view = ctx.get(editorViewCtx)
|
||||
syncObjectUrls(doc)
|
||||
refreshSizeAndLimit(ctx)
|
||||
// Throttle history state updates (undo/redo depth) to save CPU on every keystroke
|
||||
if (!historyUpdateTimer) {
|
||||
historyUpdateTimer = setTimeout(() => {
|
||||
historyUpdateTimer = null
|
||||
updateHistoryState(view)
|
||||
}, 500)
|
||||
}
|
||||
refreshDocUploadState(view)
|
||||
scheduleMarkdownSync()
|
||||
})
|
||||
@@ -1402,6 +1410,10 @@ const exportMarkdown = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const showExportDisabled = () => {
|
||||
alert(t('exportDisabledHint') || 'DOCX/PDF 导出暂不可用。')
|
||||
}
|
||||
|
||||
const exportDocx = async () => {
|
||||
try {
|
||||
console.log('Exporting DOCX...')
|
||||
@@ -1619,11 +1631,14 @@ const insertMultipleDocBlocks = (blocks, replaceRange = null) => {
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
proThinkingActive.value = false
|
||||
if (markdownSyncTimer) {
|
||||
clearTimeout(markdownSyncTimer)
|
||||
markdownSyncTimer = null
|
||||
}
|
||||
clearTimeout(markdownSyncTimer)
|
||||
markdownSyncTimer = null
|
||||
}
|
||||
if (historyUpdateTimer) {
|
||||
clearTimeout(historyUpdateTimer)
|
||||
historyUpdateTimer = null
|
||||
}
|
||||
|
||||
for (const url of Array.from(objectUrls)) {
|
||||
revokeObjectUrl(url)
|
||||
@@ -1955,6 +1970,16 @@ for (const url of Array.from(objectUrls)) {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
@@ -2462,5 +2487,3 @@ for (const url of Array.from(objectUrls)) {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
+453
-160
@@ -1,51 +1,94 @@
|
||||
<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
|
||||
v-if="isIdle"
|
||||
type="button"
|
||||
class="pro-block-capsule"
|
||||
:disabled="isBusy"
|
||||
:title="props.title"
|
||||
:title="title"
|
||||
@mousedown.stop.prevent
|
||||
@click.stop="handleActivate"
|
||||
>
|
||||
<span class="pro-block-orbit" aria-hidden="true"></span>
|
||||
<span class="pro-block-badge">PRO</span>
|
||||
<span class="pro-block-label">模式思考</span>
|
||||
<span class="pro-block-label">{{ t('proStart') || '开始 Pro 生成' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-else class="pro-block-panel">
|
||||
<div class="pro-block-header">
|
||||
<div class="pro-block-heading">
|
||||
<span class="pro-block-badge">PRO</span>
|
||||
<div class="pro-block-heading-text">
|
||||
<h3>{{ props.title }}</h3>
|
||||
<span class="pro-block-orbit" aria-hidden="true"></span>
|
||||
<div>
|
||||
<h3>{{ title }}</h3>
|
||||
<p>{{ statusText }}</p>
|
||||
</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 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 class="pro-block-body">
|
||||
<div v-if="!displayContent" class="pro-block-placeholder">
|
||||
<strong>{{ isBusy ? '正在思考' : '等待结果' }}</strong>
|
||||
<span>{{ isBusy ? '流式结果会在这里展开。' : '点击重做重新发起一次深度思考。' }}</span>
|
||||
<strong>{{ placeholderTitle }}</strong>
|
||||
<span>{{ placeholderText }}</span>
|
||||
</div>
|
||||
<div v-else class="pro-block-preview">
|
||||
<MarkdownPreview :content="displayContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pro-block-footer">
|
||||
<div class="pro-version-controls" v-if="versionCount > 1">
|
||||
<button type="button" class="pro-icon-btn" :disabled="!canPrevVersion" @mousedown.stop.prevent @click.stop="handlePrevVersion">‹</button>
|
||||
<span>{{ activeVersion + 1 }} / {{ versionCount }}</span>
|
||||
<button type="button" class="pro-icon-btn" :disabled="!canNextVersion" @mousedown.stop.prevent @click.stop="handleNextVersion">›</button>
|
||||
</div>
|
||||
|
||||
<div class="pro-block-actions">
|
||||
<button type="button" class="pro-text-btn" @mousedown.stop.prevent @click.stop="handleDiscard">放弃</button>
|
||||
<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
|
||||
v-if="!isBusy"
|
||||
type="button"
|
||||
class="pro-secondary-btn"
|
||||
:disabled="!canRedo"
|
||||
@mousedown.stop.prevent
|
||||
@click.stop="handleRedo"
|
||||
>
|
||||
重做
|
||||
{{ t('proRetry') || '重试' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isBusy"
|
||||
@@ -55,280 +98,530 @@
|
||||
@mousedown.stop.prevent
|
||||
@click.stop="handleAccept"
|
||||
>
|
||||
接受
|
||||
{{ t('accept') || '接受' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import MarkdownPreview from './MarkdownPreview.vue'
|
||||
|
||||
const props = defineProps({
|
||||
stage: { type: String, default: 'idle' },
|
||||
previewContent: { type: String, default: '' },
|
||||
savedContent: { type: String, default: '' },
|
||||
activeContent: { type: String, default: '' },
|
||||
errorMessage: { type: String, default: '' },
|
||||
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 },
|
||||
cancelAction: { type: Function, default: null },
|
||||
discardAction: { type: Function, default: null },
|
||||
redoAction: { 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 displayContent = computed(() => props.previewContent || props.savedContent || '')
|
||||
const canAccept = computed(() => !props.isBusy && Boolean(displayContent.value))
|
||||
const canRedo = computed(() => !props.isBusy)
|
||||
const bodyRef = ref(null)
|
||||
const shouldAutoScroll = ref(true)
|
||||
|
||||
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(() => {
|
||||
if (props.stage === 'thinking') return '正在思考更强的答案'
|
||||
if (props.stage === 'streaming') return '结果正在逐步生成'
|
||||
return '接受会写入正文,放弃会回到胶囊态。'
|
||||
if (props.stage === 'queued') return t('proQueued') || '正在排队等待'
|
||||
if (props.stage === 'thinking') return t('proThinking') || '思考中'
|
||||
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 = () => {
|
||||
props.activateAction?.()
|
||||
const placeholderTitle = computed(() => {
|
||||
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 = () => {
|
||||
props.discardAction?.()
|
||||
}
|
||||
watch(
|
||||
() => props.previewContent,
|
||||
async () => {
|
||||
if (!shouldAutoScroll.value) return
|
||||
await nextTick()
|
||||
const el = bodyRef.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
)
|
||||
|
||||
const handleRedo = () => {
|
||||
props.redoAction?.()
|
||||
}
|
||||
|
||||
const handleAccept = () => {
|
||||
props.acceptAction?.()
|
||||
}
|
||||
const handleActivate = () => props.activateAction?.()
|
||||
const handleCancel = () => props.cancelAction?.()
|
||||
const handleDiscard = () => props.discardAction?.()
|
||||
const handleRedo = () => props.redoAction?.()
|
||||
const handleAccept = () => props.acceptAction?.()
|
||||
const handlePrevVersion = () => props.prevVersionAction?.()
|
||||
const handleNextVersion = () => props.nextVersionAction?.()
|
||||
const handleToggleInstruction = () => props.toggleInstructionAction?.()
|
||||
const handleInstructionInput = (event) => props.updateInstructionAction?.(event.target.value)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pro-block-shell {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border: 1px solid #1d4ed8;
|
||||
padding: 11px 18px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.58);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
background: linear-gradient(135deg, #08111f 0%, #143a63 52%, #3b1d74 100%);
|
||||
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;
|
||||
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);
|
||||
box-shadow: 0 22px 44px rgba(29, 78, 216, 0.24);
|
||||
}
|
||||
|
||||
.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;
|
||||
box-shadow: 0 20px 42px rgba(14, 165, 233, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.pro-block-panel {
|
||||
border: 1px solid rgba(37, 99, 235, 0.22);
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98) 0%, rgba(219, 234, 254, 0.88) 100%);
|
||||
box-shadow: 0 22px 48px rgba(37, 99, 235, 0.14);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(14, 165, 233, 0.32);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 0%, rgba(56, 189, 248, 0.22), transparent 30%),
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px 14px;
|
||||
padding: 18px 18px 10px;
|
||||
}
|
||||
|
||||
.pro-block-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pro-block-heading-text h3 {
|
||||
.pro-block-heading h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.pro-block-heading-text p {
|
||||
margin: 4px 0 0;
|
||||
color: #334155;
|
||||
.pro-block-heading p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pro-block-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
padding: 6px 12px;
|
||||
.pro-block-orbit {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: rgba(29, 78, 216, 0.14);
|
||||
color: #1d4ed8;
|
||||
background: conic-gradient(from 90deg, #38bdf8, #a855f7, #22c55e, #38bdf8);
|
||||
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-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.pro-block-label {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pro-block-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.pro-block-status,
|
||||
.pro-thinking-pill {
|
||||
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: 2px solid rgba(29, 78, 216, 0.18);
|
||||
border-top-color: #1d4ed8;
|
||||
animation: pro-spin 0.9s linear infinite;
|
||||
border: 2px solid rgba(56, 189, 248, 0.18);
|
||||
border-top-color: #38bdf8;
|
||||
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 {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 18px 20px 22px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
color: #334155;
|
||||
padding: 18px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.pro-block-placeholder strong {
|
||||
font-size: 16px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pro-block-placeholder span {
|
||||
font-size: 13px;
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.pro-block-preview {
|
||||
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) {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 18px 22px;
|
||||
padding: 16px 18px;
|
||||
overflow: visible;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 0 18px 18px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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-secondary-btn,
|
||||
.pro-primary-btn {
|
||||
min-width: 88px;
|
||||
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;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.pro-primary-btn {
|
||||
border: 1px solid #1d4ed8;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: #eff6ff;
|
||||
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.22);
|
||||
border-color: rgba(56, 189, 248, 0.58);
|
||||
background: linear-gradient(135deg, #0284c7, #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pro-text-btn:hover,
|
||||
.pro-secondary-btn:hover,
|
||||
.pro-primary-btn:hover {
|
||||
.pro-secondary-btn:hover:not(:disabled),
|
||||
.pro-text-btn:hover:not(:disabled),
|
||||
.pro-icon-btn:hover:not(:disabled),
|
||||
.pro-primary-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(56, 189, 248, 0.5);
|
||||
}
|
||||
|
||||
.pro-text-btn:disabled,
|
||||
.pro-secondary-btn:disabled,
|
||||
.pro-primary-btn:disabled {
|
||||
opacity: 0.58;
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
:global(.pro-block-node-view) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
.pro-fold-enter-active,
|
||||
.pro-fold-leave-active,
|
||||
.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) {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
:global(.pro-block-node-view.ProseMirror-selectednode)::after {
|
||||
display: none !important;
|
||||
.pro-fold-enter-from,
|
||||
.pro-fold-leave-to,
|
||||
.pro-thinking-fade-enter-from,
|
||||
.pro-thinking-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@keyframes pro-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pro-block-header {
|
||||
padding: 18px 16px 12px;
|
||||
@keyframes pro-scan {
|
||||
0%, 40% { transform: translateX(-100%); opacity: 0; }
|
||||
55% { opacity: 1; }
|
||||
100% { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pro-block-shell {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.pro-block-body {
|
||||
padding: 0 14px 10px;
|
||||
.pro-block-capsule {
|
||||
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 {
|
||||
padding: 0 14px 16px;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
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>
|
||||
@@ -241,14 +241,13 @@ const switchView = (view) => {
|
||||
<h3>{{ t('proMode') || 'PRO 模式' }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('proModel') }}</label>
|
||||
<input
|
||||
v-model="store.proModel"
|
||||
type="text"
|
||||
class="select-input"
|
||||
:placeholder="t('proModelPlaceholder')"
|
||||
/>
|
||||
<p class="help-text">{{ t('proModelDesc') }} {{ t('proModelEmptyHint') || '留空则使用后端默认 PRO 模型。' }}</p>
|
||||
<label>{{ t('proThinkingLevel') || t('thinkingLevel') }}</label>
|
||||
<div class="segment-control">
|
||||
<button :class="{ active: store.proThinking === 'low' }" @click="store.proThinking = 'low'">{{ t('low') }}</button>
|
||||
<button :class="{ active: store.proThinking === 'medium' }" @click="store.proThinking = 'medium'">{{ t('medium') }}</button>
|
||||
<button :class="{ active: store.proThinking === 'high' }" @click="store.proThinking = 'high'">{{ t('high') }}</button>
|
||||
</div>
|
||||
<p class="help-text">{{ t('proThinkingDesc') || 'PRO 模式使用独立思考强度,普通补全设置不会影响它。' }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -1,9 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
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/frame.css'
|
||||
|
||||
@@ -11,3 +15,4 @@ const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Ctx } from '@milkdown/kit/core'
|
||||
import { Decoration, DecorationSet, type EditorView } from '@milkdown/prose/view'
|
||||
import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js'
|
||||
import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache'
|
||||
import { isDocumentVisible } from '../composables/useVisibility.js'
|
||||
|
||||
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
||||
const DEBOUNCE_MS = 1000
|
||||
@@ -341,6 +342,9 @@ function doFetchSuggestion(
|
||||
) {
|
||||
const config = runtime.ctx.get(copilotConfigCtx.key)
|
||||
|
||||
// Skip AI completion when tab is hidden (energy saving)
|
||||
if (!isDocumentVisible()) return
|
||||
|
||||
if (runtime.abortController) {
|
||||
runtime.abortController.abort('superseded')
|
||||
runtime.abortController = null
|
||||
|
||||
+184
-79
@@ -22,21 +22,20 @@ interface ProBlockConfig {
|
||||
prefix: string
|
||||
suffix: string
|
||||
languageId: string
|
||||
instruction: string
|
||||
signal?: AbortSignal
|
||||
model?: string
|
||||
temperature?: number
|
||||
onChunk?: (chunk: string) => void
|
||||
onEvent?: (event: string, data?: Record<string, any>) => void
|
||||
}) => Promise<string>
|
||||
getProModel: () => string
|
||||
t: (key: string) => string
|
||||
showError: (message: string) => void
|
||||
isThinkingActive: () => boolean
|
||||
setThinkingActive: (value: boolean) => void
|
||||
}
|
||||
|
||||
interface ProRequestPayload {
|
||||
prefix: string
|
||||
suffix: string
|
||||
languageId: string
|
||||
instruction?: string
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
@@ -85,13 +84,13 @@ function parseProParagraphNode(node: any) {
|
||||
|
||||
return {
|
||||
type: PRO_BLOCK_NODE_TYPE,
|
||||
content: parsed.content,
|
||||
instruction: parsed.instruction,
|
||||
autoStart: false,
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (node.type.name !== 'paragraph' || node.childCount !== 1) return true
|
||||
@@ -105,7 +104,7 @@ function findProBlockReplacements(doc: ProseNode) {
|
||||
replacements.push({
|
||||
from: pos,
|
||||
to: pos + node.nodeSize,
|
||||
content: parsed.content,
|
||||
instruction: parsed.instruction,
|
||||
autoStart: parsed.autoStart,
|
||||
})
|
||||
|
||||
@@ -122,10 +121,9 @@ function buildOcrContext(doc: ProseNode) {
|
||||
if (!IMAGE_NODE_TYPES.has(node.type.name)) return true
|
||||
const src = typeof node.attrs?.src === 'string' ? node.attrs.src : ''
|
||||
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
|
||||
|
||||
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) {
|
||||
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
|
||||
|
||||
const beforeSize = tr.doc.content.size
|
||||
const deleteSize = Math.max(0, to - from)
|
||||
try {
|
||||
tr.replaceRange(from, to, parsedSlice)
|
||||
const startPos = tr.mapping.map(from, -1)
|
||||
const endPos = tr.mapping.map(to, 1)
|
||||
} 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
|
||||
return { from: startPos, to: endPos }
|
||||
}
|
||||
@@ -174,7 +179,7 @@ function insertProBlockNode(view: EditorView, autoStart: boolean) {
|
||||
if (!proBlockType) return false
|
||||
|
||||
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 nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
|
||||
@@ -187,15 +192,15 @@ function replaceParagraphWithProBlock(
|
||||
view: EditorView,
|
||||
from: number,
|
||||
to: number,
|
||||
content: string,
|
||||
instruction: string,
|
||||
autoStart: boolean
|
||||
) {
|
||||
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
|
||||
if (!proBlockType) return false
|
||||
|
||||
const blockNode = proBlockType.create({
|
||||
content,
|
||||
autoStart: autoStart && content === '',
|
||||
instruction,
|
||||
autoStart,
|
||||
})
|
||||
const tr = view.state.tr.replaceWith(from, to, blockNode)
|
||||
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 nextText = `${paragraph.textContent.slice(0, startOffset)}${insertedText}${paragraph.textContent.slice(endOffset)}`
|
||||
const parsed = parseProBlockSyntax(nextText)
|
||||
if (!parsed?.autoStart || parsed.content) return false
|
||||
if (!parsed || !insertedText.includes(']')) return false
|
||||
|
||||
const blockFrom = $from.before(paragraphDepth)
|
||||
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) {
|
||||
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'>({
|
||||
fetchSuggestionStream: async () => '',
|
||||
getProModel: () => '',
|
||||
t: (key: string) => key,
|
||||
showError: () => {},
|
||||
isThinkingActive: () => false,
|
||||
setThinkingActive: () => {},
|
||||
}, 'proBlockConfig')
|
||||
|
||||
class ProBlockNodeView implements NodeView {
|
||||
@@ -252,7 +259,8 @@ class ProBlockNodeView implements NodeView {
|
||||
config: ProBlockConfig
|
||||
abortController: AbortController | null = null
|
||||
requestSeq = 0
|
||||
redoCount = 0
|
||||
candidates: string[] = []
|
||||
activeCandidateIndex = -1
|
||||
destroyed = false
|
||||
highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -273,14 +281,25 @@ class ProBlockNodeView implements NodeView {
|
||||
this.dom = document.createElement('div')
|
||||
this.dom.className = 'pro-block-node-view'
|
||||
this.props = reactive({
|
||||
stage: node.attrs.content ? 'done' : 'idle',
|
||||
stage: 'idle',
|
||||
previewContent: '',
|
||||
savedContent: node.attrs.content || '',
|
||||
activeContent: '',
|
||||
errorMessage: '',
|
||||
isBusy: false,
|
||||
isThinking: false,
|
||||
title: PRO_DISPLAY_LABEL,
|
||||
instruction: node.attrs.instruction || '',
|
||||
instructionExpanded: false,
|
||||
activeVersion: 0,
|
||||
versionCount: 0,
|
||||
canAccept: false,
|
||||
t: (key: string) => this.config.t(key),
|
||||
activateAction: () => {
|
||||
void this.startThinking(false)
|
||||
},
|
||||
cancelAction: () => {
|
||||
this.cancelThinking()
|
||||
},
|
||||
discardAction: () => {
|
||||
this.discardResult()
|
||||
},
|
||||
@@ -290,6 +309,18 @@ class ProBlockNodeView implements NodeView {
|
||||
acceptAction: () => {
|
||||
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()
|
||||
queueMicrotask(() => {
|
||||
@@ -331,10 +362,44 @@ class ProBlockNodeView implements NodeView {
|
||||
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.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 {
|
||||
@@ -355,7 +420,6 @@ class ProBlockNodeView implements NodeView {
|
||||
const suffixStart = Math.min(pos + this.node.nodeSize, doc.content.size)
|
||||
const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, this.serializer)
|
||||
|| doc.textBetween(suffixStart, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
||||
|
||||
const ocrContext = buildOcrContext(doc)
|
||||
const docContext = extractDocBlockContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600)
|
||||
const fullPrefix = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n')
|
||||
@@ -364,6 +428,7 @@ class ProBlockNodeView implements NodeView {
|
||||
prefix: fullPrefix,
|
||||
suffix: suffixMarkdown,
|
||||
languageId: 'markdown',
|
||||
instruction: this.props.instruction || '',
|
||||
blocked: fullPrefix.length + suffixMarkdown.length > PRO_CONTEXT_LIMIT,
|
||||
}
|
||||
}
|
||||
@@ -374,37 +439,47 @@ class ProBlockNodeView implements NodeView {
|
||||
void this.startThinking(false)
|
||||
}
|
||||
|
||||
stopThinkingLock() {
|
||||
this.config.setThinkingActive(false)
|
||||
}
|
||||
|
||||
abortActive(reason = 'abort') {
|
||||
if (!this.abortController) return
|
||||
this.abortController.abort(reason)
|
||||
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() {
|
||||
this.abortActive('discard')
|
||||
this.redoCount = 0
|
||||
this.props.savedContent = ''
|
||||
this.clearResult()
|
||||
this.props.errorMessage = ''
|
||||
this.setStage('idle', '')
|
||||
this.updateAttrs({ content: '', autoStart: false })
|
||||
this.updateAttrs({ autoStart: false })
|
||||
}
|
||||
|
||||
async startThinking(isRedo: boolean) {
|
||||
updateInstruction(value: string) {
|
||||
if (this.props.isBusy) return
|
||||
const previousContent = this.node.attrs.content || ''
|
||||
let requestSeq = this.requestSeq
|
||||
let requestStarted = false
|
||||
|
||||
try {
|
||||
if (this.config.isThinkingActive()) {
|
||||
this.config.showError('当前已有一个 PRO 思考正在进行,请先等待它完成。')
|
||||
return
|
||||
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
|
||||
try {
|
||||
const payload = this.buildRequestPayload()
|
||||
if (payload.blocked) {
|
||||
this.config.showError('PRO 模式上下文超过 32KB,无法继续思考。')
|
||||
@@ -414,42 +489,68 @@ class ProBlockNodeView implements NodeView {
|
||||
requestSeq = this.requestSeq + 1
|
||||
this.requestSeq = requestSeq
|
||||
this.abortController = new AbortController()
|
||||
this.config.setThinkingActive(true)
|
||||
requestStarted = true
|
||||
this.setStage('thinking', '')
|
||||
|
||||
const temperature = Math.min(1.2, 0.7 + (isRedo ? (this.redoCount + 1) * 0.2 : 0))
|
||||
this.props.errorMessage = ''
|
||||
this.setStage('queued', '')
|
||||
|
||||
const result = await this.config.fetchSuggestionStream({
|
||||
...payload,
|
||||
signal: this.abortController.signal,
|
||||
model: this.config.getProModel(),
|
||||
temperature,
|
||||
instruction: this.props.instruction || '',
|
||||
onChunk: (chunk) => {
|
||||
if (this.destroyed || this.requestSeq !== requestSeq) return
|
||||
const nextContent = `${this.props.previewContent || ''}${chunk}`
|
||||
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
|
||||
|
||||
const content = String(result || this.props.previewContent || '').trim()
|
||||
this.redoCount = isRedo ? this.redoCount + 1 : 0
|
||||
this.props.savedContent = content
|
||||
this.setStage(content ? 'done' : 'idle', '')
|
||||
this.updateAttrs({ content, autoStart: false })
|
||||
} catch (error) {
|
||||
if (!isAbortError(error)) {
|
||||
this.config.showError(error instanceof Error ? error.message : 'PRO 模式思考失败')
|
||||
const content = normalizeProMarkdown(result || this.props.previewContent || '')
|
||||
if (!content) {
|
||||
this.props.errorMessage = this.config.t('proEmptyResult') || 'PRO 返回空结果,请重试或缩短上下文。'
|
||||
this.setStage('error', '')
|
||||
return
|
||||
}
|
||||
this.setResult(content)
|
||||
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 {
|
||||
if (requestStarted && this.requestSeq === requestSeq) {
|
||||
if (this.requestSeq === requestSeq) {
|
||||
this.abortController = null
|
||||
this.stopThinkingLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,7 +558,7 @@ class ProBlockNodeView implements NodeView {
|
||||
async acceptResult() {
|
||||
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
|
||||
|
||||
const pos = this.getPosValue()
|
||||
@@ -476,8 +577,9 @@ class ProBlockNodeView implements NodeView {
|
||||
}
|
||||
|
||||
if (!insertedRange) {
|
||||
tr.insertText(source, from, to)
|
||||
insertedRange = { from, to: from + source.length }
|
||||
this.props.errorMessage = this.config.t('proAcceptParseError') || '无法把当前结果解析为 Markdown,请重试生成。'
|
||||
this.setStage('error', source)
|
||||
return
|
||||
}
|
||||
|
||||
const endPos = Math.min(insertedRange.to, tr.doc.content.size)
|
||||
@@ -498,11 +600,14 @@ class ProBlockNodeView implements NodeView {
|
||||
update(node: ProseNode) {
|
||||
if (node.type !== this.node.type) return false
|
||||
this.node = node
|
||||
this.props.instruction = node.attrs.instruction || ''
|
||||
|
||||
if (!this.props.isBusy) {
|
||||
this.props.savedContent = node.attrs.content || ''
|
||||
this.refreshCandidateProps()
|
||||
if (!this.props.activeContent && !this.props.errorMessage) {
|
||||
this.props.previewContent = ''
|
||||
this.props.stage = node.attrs.content ? 'done' : 'idle'
|
||||
this.props.stage = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -543,14 +648,14 @@ export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
|
||||
draggable: false,
|
||||
marks: '',
|
||||
attrs: {
|
||||
content: { default: '' },
|
||||
instruction: { default: '' },
|
||||
autoStart: { default: false },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'div[data-pro-block="true"]',
|
||||
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',
|
||||
}),
|
||||
},
|
||||
@@ -559,7 +664,7 @@ export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
|
||||
'div',
|
||||
{
|
||||
'data-pro-block': 'true',
|
||||
'data-pro-content': node.attrs.content || '',
|
||||
'data-pro-instruction': node.attrs.instruction || '',
|
||||
'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,
|
||||
runner: (state, node, type) => {
|
||||
state.addNode(type, {
|
||||
content: String(node.content || ''),
|
||||
instruction: String(node.instruction || ''),
|
||||
autoStart: false,
|
||||
})
|
||||
},
|
||||
@@ -577,11 +682,11 @@ export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
|
||||
runner: (state, node) => {
|
||||
state.addNode('paragraph', [{
|
||||
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) => {
|
||||
@@ -623,8 +728,8 @@ export const proBlockInputPlugin = $prose(() => {
|
||||
replacement.from,
|
||||
replacement.to,
|
||||
proBlockType.create({
|
||||
content: replacement.content,
|
||||
autoStart: replacement.autoStart && replacement.content === '',
|
||||
instruction: replacement.instruction,
|
||||
autoStart: replacement.autoStart,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
// 2. Model Behavior
|
||||
const modelThinking = ref('low') // 'low' | 'medium' | 'high'
|
||||
const debounceMs = ref(1000) // 1000 - 5000
|
||||
const proModel = ref('')
|
||||
const proThinking = ref('medium') // 'low' | 'medium' | 'high'
|
||||
|
||||
// 3. Privacy
|
||||
const privacyMode = ref(true)
|
||||
@@ -67,7 +67,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
if (data.theme) theme.value = data.theme
|
||||
if (data.modelThinking) modelThinking.value = data.modelThinking
|
||||
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 (data.language) language.value = data.language
|
||||
if (data.currency) currency.value = data.currency
|
||||
@@ -91,7 +91,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
theme: theme.value,
|
||||
modelThinking: modelThinking.value,
|
||||
debounceMs: debounceMs.value,
|
||||
proModel: proModel.value,
|
||||
proThinking: proThinking.value,
|
||||
privacyMode: privacyMode.value,
|
||||
language: language.value,
|
||||
currency: currency.value,
|
||||
@@ -111,7 +111,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
theme.value = 'system'
|
||||
modelThinking.value = 'low'
|
||||
debounceMs.value = 1000
|
||||
proModel.value = ''
|
||||
proThinking.value = 'medium'
|
||||
privacyMode.value = false
|
||||
language.value = 'auto'
|
||||
currency.value = 'auto'
|
||||
@@ -128,7 +128,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
theme,
|
||||
modelThinking,
|
||||
debounceMs,
|
||||
proModel,
|
||||
proThinking,
|
||||
privacyMode,
|
||||
language,
|
||||
currency,
|
||||
@@ -149,7 +149,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
theme,
|
||||
modelThinking,
|
||||
debounceMs,
|
||||
proModel,
|
||||
proThinking,
|
||||
privacyMode,
|
||||
language,
|
||||
currency,
|
||||
|
||||
+119
@@ -549,3 +549,122 @@ body {
|
||||
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 ===== */
|
||||
|
||||
+197
-50
@@ -82,6 +82,8 @@ export const translations = {
|
||||
cancel: 'Cancel',
|
||||
imgTooLarge: 'Image too large',
|
||||
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...',
|
||||
view: 'View',
|
||||
editor: 'Editor',
|
||||
@@ -89,34 +91,33 @@ export const translations = {
|
||||
docsManagement: 'Document Management',
|
||||
docsEmptyDesc: 'Document management interface is under development...',
|
||||
files: 'Files',
|
||||
noFiles: '暂无文件',
|
||||
newFile: '新建文件',
|
||||
newFolder: '新建文件夹',
|
||||
noFiles: 'No files yet',
|
||||
newFile: 'New File',
|
||||
newFolder: 'New Folder',
|
||||
untitledFile: 'untitled.md',
|
||||
untitledFolder: '新建文件夹',
|
||||
rename: '重命名',
|
||||
delete: '删除',
|
||||
copy: '复制',
|
||||
cut: '剪切',
|
||||
paste: '粘贴',
|
||||
confirmDelete: '确认删除',
|
||||
confirmDeleteDesc: '确定要删除',
|
||||
confirmDeleteFolderDesc: '此操作将删除文件夹内的所有内容。',
|
||||
confirmDeleteFileDesc: '此操作不可撤销。',
|
||||
cancel: '取消',
|
||||
rootDir: '根目录',
|
||||
expandSidebar: '展开侧边栏',
|
||||
collapseSidebar: '收起侧边栏',
|
||||
fileLimitReached: '文件数量已达上限',
|
||||
folderLimitReached: '文件夹数量已达上限',
|
||||
fileSizeLimit: '文件大小不能超过 50MB',
|
||||
storageError: '存储空间不足',
|
||||
selectFileToView: '选择一个文件以查看内容',
|
||||
folderContains: '包含',
|
||||
items: '个项目',
|
||||
unsupportedPreview: '暂不支持预览此文件类型',
|
||||
fileNamePlaceholder: '文件名.md',
|
||||
folderNamePlaceholder: '文件夹名'
|
||||
untitledFolder: 'New Folder',
|
||||
rename: 'Rename',
|
||||
delete: 'Delete',
|
||||
copy: 'Copy',
|
||||
cut: 'Cut',
|
||||
paste: 'Paste',
|
||||
confirmDelete: 'Confirm Delete',
|
||||
confirmDeleteDesc: 'Are you sure to delete',
|
||||
confirmDeleteFolderDesc: 'This will delete all contents in the folder.',
|
||||
confirmDeleteFileDesc: 'This action cannot be undone.',
|
||||
rootDir: 'Root',
|
||||
expandSidebar: 'Expand Sidebar',
|
||||
collapseSidebar: 'Collapse Sidebar',
|
||||
fileLimitReached: 'Maximum number of files reached',
|
||||
folderLimitReached: 'Maximum number of folders reached',
|
||||
fileSizeLimit: 'File size cannot exceed 50MB',
|
||||
storageError: 'Insufficient storage space',
|
||||
selectFileToView: 'Select a file to view content',
|
||||
folderContains: 'contains',
|
||||
items: 'items',
|
||||
unsupportedPreview: 'This file type is not supported for preview',
|
||||
fileNamePlaceholder: 'filename.md',
|
||||
folderNamePlaceholder: 'Folder name'
|
||||
},
|
||||
zh: {
|
||||
settings: '设置',
|
||||
@@ -201,6 +202,7 @@ export const translations = {
|
||||
cancel: '取消',
|
||||
imgTooLarge: '图片过大',
|
||||
docTooLarge: '文档过大,AI已禁用',
|
||||
exportDisabledHint: 'DOCX/PDF 导出暂不可用。',
|
||||
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...',
|
||||
view: '视图',
|
||||
editor: '编辑器',
|
||||
@@ -260,6 +262,12 @@ export const translations = {
|
||||
mediumDesc: '提案前に文脈を簡単に分析',
|
||||
highDesc: '深く段階的に分析(遅いが最高品質)',
|
||||
debounceTime: 'デバウンス時間',
|
||||
proModeThinking: 'PRO 思考中',
|
||||
proMode: 'PRO モード',
|
||||
proModel: 'PRO モデル',
|
||||
proModelPlaceholder: '例: qwen3:32b',
|
||||
proModelDesc: 'PRO モードのみで使用されるオプションの強力なモデル名。',
|
||||
proModelEmptyHint: '空白のままにするとバックエンドのデフォルト PRO モデルが使用されます。',
|
||||
privacyPreferences: 'プライバシーと設定',
|
||||
privacyMode: 'プライバシーモード',
|
||||
privacyDesc: 'AIにIPアドレスと設定を送信しない',
|
||||
@@ -267,20 +275,47 @@ export const translations = {
|
||||
auto: '自動検出',
|
||||
currency: '通貨',
|
||||
about: '私たちについて',
|
||||
template: 'テンプレート',
|
||||
presetTemplates: 'プリセットテンプレート',
|
||||
customTemplates: 'カスタムテンプレート',
|
||||
newTemplate: '新しいテンプレート',
|
||||
previewTemplate: 'プレビュー',
|
||||
applyTemplate: '適用',
|
||||
copyAsTemplate: 'カスタムテンプレートとしてコピー',
|
||||
editTemplate: '編集',
|
||||
saveTemplate: '保存',
|
||||
templateName: 'テンプレート名',
|
||||
templateContent: '内容',
|
||||
templateNamePlaceholder: '例: 議事録',
|
||||
templateContentPlaceholder: 'テンプレート内容を入力...',
|
||||
noTemplates: 'カスタムテンプレートはありません',
|
||||
templateNameRequired: 'テンプレート名を入力してください',
|
||||
templateContentRequired: '内容を入力してください',
|
||||
templateNameDuplicate: 'テンプレート名は既に存在します',
|
||||
templateDeleteConfirm: 'このテンプレートを削除しますか?',
|
||||
templateSaved: '保存しました',
|
||||
templateUpdated: '更新しました',
|
||||
templateDeleted: '削除しました',
|
||||
importMd: 'Markdownをインポート',
|
||||
exportMd: 'Markdownをエクスポート',
|
||||
exportDocx: 'DOCXをエクスポート',
|
||||
exportPdf: 'PDFをエクスポート',
|
||||
upload: 'アップロード',
|
||||
uploadImg: '画像をアップロード',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
uploadBatchLimit: 'Maximum 10 files at once',
|
||||
uploadSizeLimit: 'File exceeds 50MB limit',
|
||||
uploading: 'Uploading files...',
|
||||
uploadFile: 'ファイルをアップロード',
|
||||
uploadDoc: 'ドキュメントをアップロード',
|
||||
exportDisabledHint: 'DOCX/PDFのエクスポートは一時的に利用できません。',
|
||||
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を有効化',
|
||||
disableAI: 'AIを無効化',
|
||||
insertUrl: 'URLから画像を挿入',
|
||||
@@ -347,6 +382,13 @@ export const translations = {
|
||||
mediumDesc: '제안 전 문맥 간단 분석',
|
||||
highDesc: '심층 단계별 분석 (가장 느리지만 최고 품질)',
|
||||
debounceTime: '디바운스 시간',
|
||||
proModeThinking: 'PRO 생각 중',
|
||||
|
||||
proMode: 'PRO 모드',
|
||||
proModel: 'PRO 모델',
|
||||
proModelPlaceholder: '예: qwen3:32b',
|
||||
proModelDesc: 'PRO 모드에서만 사용되는 선택적 강력한 모델 이름.',
|
||||
proModelEmptyHint: '비워두면 백엔드 기본 PRO 모델을 사용합니다.',
|
||||
privacyPreferences: '개인정보 및 환경설정',
|
||||
privacyMode: '개인정보 모드',
|
||||
privacyDesc: 'AI에 IP 주소 및 설정 전송 안 함',
|
||||
@@ -354,17 +396,48 @@ export const translations = {
|
||||
auto: '자동 감지',
|
||||
currency: '통화',
|
||||
about: '회사 소개',
|
||||
template: '템플릿',
|
||||
presetTemplates: '사전 정의된 템플릿',
|
||||
customTemplates: '사용자 정의 템플릿',
|
||||
newTemplate: '새 템플릿',
|
||||
previewTemplate: '미리보기',
|
||||
applyTemplate: '적용',
|
||||
copyAsTemplate: '사용자 정의 템플릿으로 복사',
|
||||
editTemplate: '편집',
|
||||
saveTemplate: '저장',
|
||||
templateName: '템플릿 이름',
|
||||
templateContent: '내용',
|
||||
templateNamePlaceholder: '예: 회의록',
|
||||
templateContentPlaceholder: '템플릿 내용을 입력...',
|
||||
noTemplates: '사용자 정의 템플릿이 없습니다',
|
||||
templateNameRequired: '템플릿 이름을 입력하세요',
|
||||
templateContentRequired: '내용을 입력하세요',
|
||||
templateNameDuplicate: '템플릿 이름이 이미 존재합니다',
|
||||
templateDeleteConfirm: '이 템플릿을 삭제하시겠습니까?',
|
||||
templateSaved: '저장되었습니다',
|
||||
templateUpdated: '업데이트되었습니다',
|
||||
templateDeleted: '삭제되었습니다',
|
||||
importMd: 'Markdown 가져오기',
|
||||
exportMd: 'Markdown 내보내기',
|
||||
exportDocx: 'DOCX 내보내기',
|
||||
exportPdf: 'PDF 내보내기',
|
||||
upload: '업로드',
|
||||
uploadImg: '이미지 업로드',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
uploadFile: '파일 업로드',
|
||||
exportDisabledHint: 'DOCX/PDF 내보내기는 일시적으로 사용할 수 없습니다.',
|
||||
uploadDoc: '문서 업로드',
|
||||
|
||||
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 활성화',
|
||||
disableAI: 'AI 비활성화',
|
||||
insertUrl: 'URL로 이미지 삽입',
|
||||
@@ -431,6 +504,13 @@ export const translations = {
|
||||
mediumDesc: 'Kurze Analyse vor Vorschlag',
|
||||
highDesc: 'Tiefe schrittweise Analyse (Langsam, aber höchste Qualität)',
|
||||
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',
|
||||
privacyMode: 'Datenschutzmodus',
|
||||
privacyDesc: 'Sende keine IP und Einstellungen an KI',
|
||||
@@ -438,17 +518,47 @@ export const translations = {
|
||||
auto: 'Automatisch',
|
||||
currency: 'Währung',
|
||||
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',
|
||||
exportMd: 'Markdown exportieren',
|
||||
exportDocx: 'DOCX exportieren',
|
||||
exportPdf: 'PDF exportieren',
|
||||
upload: 'Hochladen',
|
||||
uploadImg: 'Bild hochladen',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
uploadFile: 'Datei hochladen',
|
||||
uploadDoc: 'Dokument hochladen',
|
||||
exportDisabledHint: 'DOCX/PDF-Export ist vorübergehend nicht verfügbar.',
|
||||
uploadDocTypeWarning: 'Nur txt, json, toml, yaml, docx, pptx, pdf Formate werden unterstützt.',
|
||||
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',
|
||||
disableAI: 'KI deaktivieren',
|
||||
insertUrl: 'Bild per URL einfügen',
|
||||
@@ -515,6 +625,13 @@ export const translations = {
|
||||
mediumDesc: 'Analyse brève avant suggestion',
|
||||
highDesc: 'Analyse approfondie étape par étape (Le plus lent)',
|
||||
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',
|
||||
privacyMode: 'Mode confidentialité',
|
||||
privacyDesc: 'Ne pas envoyer IP et préférences à l\'IA',
|
||||
@@ -522,17 +639,47 @@ export const translations = {
|
||||
auto: 'Détection auto',
|
||||
currency: 'Devise',
|
||||
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',
|
||||
exportMd: 'Exporter Markdown',
|
||||
exportDocx: 'Exporter DOCX',
|
||||
exportPdf: 'Exporter PDF',
|
||||
upload: 'Télécharger',
|
||||
uploadImg: 'Télécharger image',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
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',
|
||||
|
||||
+13
-13
@@ -1,9 +1,9 @@
|
||||
export const PRO_BLOCK_NODE_TYPE = 'pro_block'
|
||||
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_PENDING_SUFFIX = '}]'
|
||||
const PRO_INSTRUCTION_PREFIX = '[PRO]{'
|
||||
const PRO_INSTRUCTION_SUFFIX = '}'
|
||||
const PRO_TRIGGER_RE = /^\[pro\]$/i
|
||||
|
||||
function normalizeMarkdownText(value = '') {
|
||||
@@ -41,10 +41,10 @@ export function unescapeProBlockContent(value = '') {
|
||||
return result
|
||||
}
|
||||
|
||||
export function serializeProBlockSyntax(content = '') {
|
||||
const normalized = normalizeMarkdownText(content)
|
||||
export function serializeProBlockSyntax(instruction = '') {
|
||||
const normalized = normalizeMarkdownText(instruction).trim()
|
||||
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 = '') {
|
||||
@@ -53,17 +53,17 @@ export function parseProBlockSyntax(value = '') {
|
||||
|
||||
if (PRO_TRIGGER_RE.test(text)) {
|
||||
return {
|
||||
content: '',
|
||||
autoStart: true,
|
||||
instruction: '',
|
||||
autoStart: false,
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = text.slice(0, PRO_PENDING_PREFIX.length)
|
||||
if (prefix.toLowerCase() === PRO_PENDING_PREFIX.toLowerCase() && text.endsWith(PRO_PENDING_SUFFIX)) {
|
||||
const prefix = text.slice(0, PRO_INSTRUCTION_PREFIX.length)
|
||||
if (prefix.toLowerCase() === PRO_INSTRUCTION_PREFIX.toLowerCase() && text.endsWith(PRO_INSTRUCTION_SUFFIX)) {
|
||||
return {
|
||||
content: unescapeProBlockContent(
|
||||
text.slice(PRO_PENDING_PREFIX.length, text.length - PRO_PENDING_SUFFIX.length)
|
||||
),
|
||||
instruction: unescapeProBlockContent(
|
||||
text.slice(PRO_INSTRUCTION_PREFIX.length, text.length - PRO_INSTRUCTION_SUFFIX.length)
|
||||
).trim(),
|
||||
autoStart: false,
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -53,17 +53,7 @@ export default defineConfig({
|
||||
include: [
|
||||
'@milkdown/crepe',
|
||||
'@milkdown/vue',
|
||||
'@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'
|
||||
'@milkdown/kit'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user