feat: add video OCR/ASR, DOCX/PDF export, input block and risk config updates
- Video pipeline: video file OCR via VLM plus audio track ASR, integrated into job_handlers with progress emit per phase. New media_utils.py for audio extraction from video files. - Document export: richExport.js replaces inline docx builder; DOCX and PDF export buttons are now enabled in MilkdownEditor. File size limit raised to 100 MB. - Input block: new InputBlockCrepe.vue component with inputBlockPlugin.ts and inputBlock.js for custom user-input nodes in the editor. - Risk config: added Vite dev server ports (5173) to CORS allowlist and increased OCR max input from 10 MB to 100 MB. - TTS/ASR refactor: simplified tts_asr.py model loading and warmup logic. - Test coverage: updated tests for llm, main endpoints, pro completions and web search modules. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
| 上传块 | `upload_block` | `{{{}}}` / `{{{upload file type:...}}}` | `plugins/uploadBlockPlugin.ts` | `utils/uploadBlock.js` |
|
||||
|
||||
- **已验证**:当前代码完全符合"禁止嵌套、自动解析复原"的要求。修改功能块相关逻辑时需验证:1) schema 的 atom/isolating 属性不被移除;2) Remark/parseMarkdown/toMarkdown 解析链路完整。
|
||||
- 当前代码中可以确认的主功能是:AI 补全、OCR、文档转 Markdown、TTS、Markdown/DOCX/PDF 导入导出。
|
||||
- 当前代码中可以确认的主功能是:AI 补全、OCR、文档转 Markdown、TTS/ASR、Markdown/DOCX/PDF 导入导出。
|
||||
- 历史文档中有一部分 TTS/ASR、Apple Silicon、Whisper、离线模式说明已经落后于当前代码;出现冲突时以实际代码和测试为准。
|
||||
|
||||
## 先看哪里
|
||||
@@ -80,8 +80,13 @@
|
||||
- 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。
|
||||
- OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。
|
||||
- /v1/convert 当前支持 txt、docx、pptx、pdf,非 txt 文件通过 MarkItDown 转成 Markdown,之后会清理图片标记。
|
||||
- 前端存在 /v1/export/pdf 调用点,但当前后端主路由中看不到同名端点;排查 PDF 导出问题前先确认服务端是否真正提供该接口。
|
||||
- 当前 tts_asr.py 主要提供 TTS 相关能力。不要直接沿用 README 或历史修复文档里关于 ASR、Whisper、MPS/offline 的描述。
|
||||
- **AI 开关是全局广播状态**:MilkdownEditor.vue 通过 `llm-in-text:copilot-toggle` 同步主编辑器、文档块嵌套编辑器、网页搜索块嵌套编辑器;修 ghost text 时要同时检查这三处。
|
||||
- **设置项已从 currency 改为 country**:前后端请求、prompt、store、设置面板统一使用 `country`;仅在读取旧 localStorage 时兼容 `currency` 作为迁移兜底。
|
||||
- **DOCX/PDF 导出改为纯前端**:不再依赖 `/v1/export/pdf`。当前策略是先展开所有功能块,再从编辑器 HTML 构建导出内容;`src/utils/richExport.js` 负责 HTML -> PDF / DOCX。
|
||||
- **上传单文件限制统一为 100MB**:前端校验和后端 OCR 风控上限都按 100MB 处理。
|
||||
- **视频解析策略**:上传视频时,后端 `/v1/ocr` 接收 `media_type=video`,视频画面走 OCR 模型,音轨通过 ffmpeg 抽取后走 ASR 模型,最终合并为“视频画面 OCR + 视频音频 ASR”文本。
|
||||
- **OCR 明确关闭思考**:backend/llm.py 的 OCR payload 显式下发 `options.think = False` 与 `temperature = 0`。
|
||||
- **TTS/ASR 当前真实实现**:backend/tts_asr.py 已切到 `Qwen3TTSModel + faster-whisper` 路线;不要继续按旧的 MLX-only 文档理解当前实现。
|
||||
|
||||
## 常用命令
|
||||
|
||||
@@ -119,7 +124,7 @@
|
||||
6. 再次用 `docker compose exec -T ...` 验证容器内文件和行为,不能只看本地文件。
|
||||
- Docker 持久化数据统一落在部署目录内的 `docker-data/`,包括 PostgreSQL、Redis 和任务共享临时目录。
|
||||
- 容器内访问宿主机模型服务时,不要继续使用 `localhost`;应改成 `host.docker.internal` 之类的容器可达地址。
|
||||
- 当前 Docker 部署默认使用轻量后端依赖集(`backend/requirements.docker.txt`),覆盖补全、OCR、转换、文档空间和队列,不默认包含本地 `torch` / TTS / ASR 模型栈。
|
||||
- 当前 Docker 部署的 `backend/requirements.docker.txt` 已包含 OCR、转换、队列以及 `torch` / `qwen-tts` / `faster-whisper`,并在 `backend/Dockerfile` 中额外安装 `ffmpeg` 以支持视频拆音轨。
|
||||
- **Worker 容器**:worker.py 作为独立服务运行,通过 Redis Streams 消费任务队列。修改 job_handlers.py 或 worker.py 后需要验证 worker 容器内的代码已更新,可通过 `docker compose exec -T worker sh -lc "python -c 'from backend.job_handlers import get_handler; print(get_handler(\"completion\").__name__)'"` 验证。
|
||||
- **Redis Streams 架构**:任务队列使用 Redis Streams,支持并发控制、速率限制和熔断器。job_system.py 定义 JOB_TYPES 和队列配置,worker.py 注册处理器并运行事件循环。
|
||||
- 修改 Docker 相关文件时,除了代码本身,还要同步检查:
|
||||
|
||||
@@ -103,7 +103,7 @@ RISK_WEB_SEARCH_MAX_OUTPUT_TOKENS=4096
|
||||
RISK_WEB_SEARCH_TEMPERATURE=0.4
|
||||
RISK_COMPRESS_MAX_INPUT_CHARS=128000
|
||||
RISK_COMPRESS_MAX_OUTPUT_TOKENS=1536
|
||||
RISK_OCR_MAX_INPUT_BYTES=10485760
|
||||
RISK_OCR_MAX_INPUT_BYTES=104857600
|
||||
|
||||
# Web search providers
|
||||
SEARXNG_BASE_URL=http://searxng:8080
|
||||
|
||||
+10
-7
@@ -4,7 +4,7 @@
|
||||
|
||||
## 后端职责
|
||||
|
||||
- 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。
|
||||
- 对外提供补全、取消补全、OCR、文档转换和 TTS/ASR 相关接口。
|
||||
- 组织 Prompt,上下文清洗,调用 Ollama 模型。
|
||||
- **通过 Redis Streams 异步任务队列处理各类作业(completion/PRO/web_search/compress/OCR/convert/TTS/ASR)。**
|
||||
- 负责 API Key 校验、日志记录和部分启动预热逻辑。
|
||||
@@ -62,10 +62,12 @@
|
||||
|
||||
### /v1/ocr
|
||||
|
||||
- 把 base64 图片解码成字节。
|
||||
- 调用 call_vlm_ocr。
|
||||
- 把 base64 媒体内容解码成字节。
|
||||
- 支持 `media_type=image|video` 和 `mime_type`。
|
||||
- 图片直接调用 call_vlm_ocr。
|
||||
- 视频先把整段视频送入 OCR 模型,再用 ffmpeg 抽取音轨交给 ASR,最后合并文本。
|
||||
- **结果通过 job_handlers.py ocr_handler 处理。**
|
||||
- 返回识别文本和原始文件名。
|
||||
- 返回识别文本、原始文件名,以及视频场景下的 `ocr_text` / `asr_text`。
|
||||
|
||||
### /v1/convert
|
||||
|
||||
@@ -105,9 +107,9 @@
|
||||
### /v1/tts-asr/*
|
||||
|
||||
- 通过 _register_tts_asr_routes 延迟导入并挂到主应用。
|
||||
- **当前代码里的 tts_asr.py 主要是 TTS 能力,不要自行假设存在完整 ASR 实现。**
|
||||
- **TTS 请求通过 job_handlers.py tts_handler 处理。**
|
||||
- **支持多种语音模型(edge-tts、macos-say、pyttsx3)。**
|
||||
- **ASR 请求通过 job_handlers.py asr_handler 处理。**
|
||||
- **当前实现是 `Qwen3TTSModel + faster-whisper`,不是旧的 edge-tts / macos-say / MLX-only 路线。**
|
||||
|
||||
## 开发命令
|
||||
|
||||
@@ -146,9 +148,10 @@
|
||||
- **验证码路由**:captcha_api.py 提供 /captcha/generate 和 /captcha/verify 端点,用于前端验证用户输入。
|
||||
- **文档存储**:docs_store.py 提供 MIME 类型检测、文本/二进制分类和预览提取(8MB 限制)。
|
||||
- **LLM 策略解析**:llm_policy.py 按 job_type 解析模型配置(模型名、温度、最大 token、思考级别)。
|
||||
- **OCR 明确关闭思考**:llm.py 的 `call_vlm_ocr` 对 OCR 请求显式设置 `options.think = False` 和 `temperature = 0`。
|
||||
- **补全接口当前不是流式响应,不要按 SSE 方式改造周边代码。**
|
||||
- **ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理。**
|
||||
- **main.py 里虽然有 _convert_docx_to_pdf 辅助函数,但当前 /v1/convert 路径实际走的是 MarkItDown,不要误以为 DOCX 转 PDF 桥接脚本已接入主流程。**
|
||||
- **`/v1/export/pdf` 不是当前主链路**;DOCX/PDF 导出已经转到前端 `src/utils/richExport.js`。
|
||||
- **API_KEY 存在占位默认值,这更像本地开发兜底,不是推荐的安全模式。**
|
||||
- **历史 TTS/ASR 文档和部分测试覆盖的是旧实现;代码与文档冲突时,先确认产品方向,再决定修代码还是修文档。**
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.docker.txt /tmp/requirements.docker.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.docker.txt
|
||||
|
||||
|
||||
+52
-5
@@ -14,6 +14,7 @@ import markitdown
|
||||
|
||||
from audit_store import get_audit_store
|
||||
from llm import call_ollama, call_vlm_ocr, stream_ollama_events
|
||||
from media_utils import extract_audio_wav_bytes, is_video_filename
|
||||
from prompt import (
|
||||
build_completion_prompts,
|
||||
build_pro_completion_prompts,
|
||||
@@ -720,14 +721,60 @@ async def ocr_handler(
|
||||
path = payload["input_path"]
|
||||
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
|
||||
try:
|
||||
filename = payload.get("filename", "image.jpg")
|
||||
language = payload.get("language", "auto")
|
||||
media_type = payload.get("media_type", "image")
|
||||
mime_type = payload.get("mime_type", "") or ""
|
||||
|
||||
with open(path, "rb") as handle:
|
||||
image_bytes = handle.read()
|
||||
text = await call_vlm_ocr(image_bytes, payload.get("language", "auto"))
|
||||
media_bytes = handle.read()
|
||||
|
||||
await emit("progress", {"phase": "ocr", "media_type": media_type})
|
||||
ocr_text = await call_vlm_ocr(
|
||||
media_bytes,
|
||||
language,
|
||||
mime_type=mime_type or "application/octet-stream",
|
||||
media_type=media_type,
|
||||
)
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
await emit("result", {"text": text})
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=text)
|
||||
return {"text": text, "filename": payload.get("filename", "image.jpg")}
|
||||
|
||||
result = {
|
||||
"text": ocr_text,
|
||||
"ocr_text": ocr_text,
|
||||
"filename": filename,
|
||||
"media_type": media_type,
|
||||
}
|
||||
|
||||
if media_type == "video" or is_video_filename(filename, mime_type):
|
||||
asr_text = ""
|
||||
if generate_asr_response is not None:
|
||||
try:
|
||||
await emit("progress", {"phase": "asr", "media_type": media_type})
|
||||
audio_bytes = await asyncio.to_thread(extract_audio_wav_bytes, path)
|
||||
asr_response = await generate_asr_response(audio_bytes, language)
|
||||
asr_text = getattr(asr_response, "text", "") or ""
|
||||
except Exception as exc:
|
||||
asr_text = f"(音频解析失败: {exc})"
|
||||
if ocr_text.strip() or asr_text.strip():
|
||||
text_parts = []
|
||||
if ocr_text.strip():
|
||||
text_parts.append(f"## 视频画面 OCR\n\n{ocr_text.strip()}")
|
||||
if asr_text.strip():
|
||||
text_parts.append(f"## 视频音频 ASR\n\n{asr_text.strip()}")
|
||||
result["text"] = "\n\n".join(text_parts)
|
||||
result["asr_text"] = asr_text
|
||||
|
||||
await emit("result", result)
|
||||
await _exit_llm_execution(
|
||||
payload,
|
||||
identity,
|
||||
risk,
|
||||
lock_keys,
|
||||
status="completed",
|
||||
actual_output_text=result["text"],
|
||||
)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
|
||||
raise
|
||||
|
||||
+8
-19
@@ -77,24 +77,6 @@ class QueueFullError(JobSystemError):
|
||||
self.max_queue = max_queue
|
||||
|
||||
|
||||
# 修复:添加完整的异常类实现
|
||||
class JobSystemError(RuntimeError):
|
||||
def __init__(self, message: str = "任务系统错误", error_code: str = "job_system_error") -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.error_code = error_code
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.error_code}: {self.message}"
|
||||
|
||||
|
||||
class QueueFullError(JobSystemError):
|
||||
def __init__(self, job_type: str, max_queue: int) -> None:
|
||||
super().__init__(f"{job_type} 队列已满 (当前: {max_queue}/{max_queue})", "queue_full")
|
||||
self.job_type = job_type
|
||||
self.max_queue = max_queue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueueConfig:
|
||||
job_type: str
|
||||
@@ -613,7 +595,14 @@ class RedisWorker:
|
||||
group = self.manager._group_name(job_type)
|
||||
semaphore = self.queue_semaphores[job_type]
|
||||
while True:
|
||||
streams = await self.manager.redis.xreadgroup(group, self.consumer_name, {queue_key: ">"}, count=1, block=1000)
|
||||
try:
|
||||
streams = await self.manager.redis.xreadgroup(group, self.consumer_name, {queue_key: ">"}, count=1, block=1000)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("redis worker consume loop retrying type=%s error=%s", job_type, exc)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
if not streams:
|
||||
continue
|
||||
for _, messages in streams:
|
||||
|
||||
+20
-8
@@ -551,31 +551,43 @@ async def stream_ollama_events(
|
||||
)
|
||||
|
||||
|
||||
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
"""OCR via VLM using OpenAI-compatible vision API (image_url content part)."""
|
||||
async def call_vlm_ocr(
|
||||
media_bytes: bytes,
|
||||
language: str = 'auto',
|
||||
*,
|
||||
mime_type: str = 'image/png',
|
||||
media_type: str = 'image',
|
||||
) -> str:
|
||||
"""OCR via VLM using OpenAI-compatible multimodal API."""
|
||||
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,
|
||||
'[VLM][ocr] request model=%s base_url=%s media_type=%s media_bytes=%d language=%s mime=%s',
|
||||
VLM_MODEL, LLM_BASE_URL, media_type, len(media_bytes), language, mime_type,
|
||||
)
|
||||
|
||||
image_b64 = base64.b64encode(image_bytes).decode('ascii')
|
||||
media_b64 = base64.b64encode(media_bytes).decode('ascii')
|
||||
content_part_type = 'video_url' if media_type == 'video' else 'image_url'
|
||||
url_key = 'video_url' if media_type == 'video' else 'image_url'
|
||||
|
||||
payload = {
|
||||
'model': VLM_MODEL,
|
||||
'messages': [{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{'type': 'text', 'text': get_vlm_ocr_prompt()},
|
||||
{'type': 'text', 'text': f"{get_vlm_ocr_prompt()}\n\nTarget language hint: {language or 'auto'}"},
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': f'data:image/png;base64,{image_b64}'},
|
||||
'type': content_part_type,
|
||||
url_key: {'url': f'data:{mime_type or "application/octet-stream"};base64,{media_b64}'},
|
||||
},
|
||||
],
|
||||
}],
|
||||
'stream': False,
|
||||
'options': {
|
||||
'temperature': 0,
|
||||
'think': False,
|
||||
},
|
||||
}
|
||||
|
||||
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
|
||||
|
||||
+24
-7
@@ -106,6 +106,8 @@ class OCRRequest(BaseModel):
|
||||
image: str
|
||||
filename: str = "image.jpg"
|
||||
language: str = "auto"
|
||||
media_type: str = "image"
|
||||
mime_type: str | None = None
|
||||
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
@@ -281,8 +283,10 @@ def _register_handlers() -> None:
|
||||
确保 Redis 重连或实例重建后处理器不会丢失。"""
|
||||
global _handlers_registered
|
||||
manager = get_job_manager()
|
||||
# 强制清空旧 handlers,避免重复注册累积
|
||||
manager.handlers.clear()
|
||||
# 强制清空旧 handlers,避免重复注册累积;测试替身只暴露 register_handler。
|
||||
handlers = getattr(manager, "handlers", None)
|
||||
if handlers is not None:
|
||||
handlers.clear()
|
||||
manager.register_handler("completion", completion_handler)
|
||||
manager.register_handler("pro_completion", pro_completion_handler)
|
||||
manager.register_handler("web_search", web_search_handler)
|
||||
@@ -294,7 +298,8 @@ def _register_handlers() -> None:
|
||||
_handlers_registered = True
|
||||
|
||||
# 打印注册信息便于调试
|
||||
logger.info("handlers registered: %s", list(manager.handlers.keys()))tered: %s", list(manager.handlers.keys()))
|
||||
registered = list(getattr(manager, "handlers", {}).keys())
|
||||
logger.info("handlers registered: %s", registered)
|
||||
|
||||
|
||||
def _sse(event: str, data: dict) -> str:
|
||||
@@ -623,16 +628,28 @@ async def ocr_image(request: Request, req: OCRRequest, auth: dict = Security(_au
|
||||
except Exception as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=500)
|
||||
if len(image_bytes) > config.ocr_max_input_bytes:
|
||||
return JSONResponse({"error": "图片过大,无法执行 OCR"}, status_code=400)
|
||||
return JSONResponse({"error": "文件过大,无法执行 OCR/视频解析"}, status_code=400)
|
||||
input_path = persist_temp_input(image_bytes, os.path.splitext(req.filename)[1] or ".img")
|
||||
try:
|
||||
identity, payload = await _prepare_llm_payload(
|
||||
request,
|
||||
job_type="ocr",
|
||||
request_body={"filename": req.filename, "language": req.language, "image_bytes": len(image_bytes)},
|
||||
request_body={
|
||||
"filename": req.filename,
|
||||
"language": req.language,
|
||||
"media_type": req.media_type,
|
||||
"mime_type": req.mime_type,
|
||||
"image_bytes": len(image_bytes),
|
||||
},
|
||||
raw_size=len(image_bytes),
|
||||
token_source_text=f"{req.filename}:{len(image_bytes)}:{req.language}",
|
||||
extra_payload={"input_path": input_path, "filename": req.filename, "language": req.language},
|
||||
token_source_text=f"{req.filename}:{req.media_type}:{req.mime_type}:{len(image_bytes)}:{req.language}",
|
||||
extra_payload={
|
||||
"input_path": input_path,
|
||||
"filename": req.filename,
|
||||
"language": req.language,
|
||||
"media_type": req.media_type,
|
||||
"mime_type": req.mime_type,
|
||||
},
|
||||
)
|
||||
job_id = await _queue_job("ocr", payload, identity.request_id)
|
||||
except RiskRejected as exc:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v", ".ogv"}
|
||||
|
||||
|
||||
def is_video_filename(filename: str = "", mime_type: str = "") -> bool:
|
||||
ext = os.path.splitext(filename or "")[1].lower()
|
||||
mime = (mime_type or "").strip().lower()
|
||||
return ext in VIDEO_EXTENSIONS or mime.startswith("video/")
|
||||
|
||||
|
||||
def extract_audio_wav_bytes(input_path: str) -> bytes:
|
||||
if not input_path or not os.path.exists(input_path):
|
||||
raise FileNotFoundError("输入媒体文件不存在")
|
||||
|
||||
fd, output_path = tempfile.mkstemp(suffix=".wav")
|
||||
os.close(fd)
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
output_path,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
with open(output_path, "rb") as handle:
|
||||
return handle.read()
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
+1
-1
@@ -5,5 +5,5 @@ from pydantic import BaseModel
|
||||
class UserPreferences(BaseModel):
|
||||
"""用户偏好设置"""
|
||||
language: str = "auto"
|
||||
currency: str = "auto"
|
||||
country: str = "auto"
|
||||
timezone: str = "auto"
|
||||
|
||||
+4
-4
@@ -355,8 +355,8 @@ def build_completion_prompts(
|
||||
if preferences:
|
||||
if preferences.language and preferences.language != "auto":
|
||||
pref_info.append(f"Preferred language: {preferences.language}")
|
||||
if preferences.currency and preferences.currency != "auto":
|
||||
pref_info.append(f"Preferred currency: {preferences.currency}")
|
||||
if preferences.country and preferences.country != "auto":
|
||||
pref_info.append(f"Preferred country: {preferences.country}")
|
||||
|
||||
preferences_instruction = "\n".join(pref_info)
|
||||
if preferences_instruction:
|
||||
@@ -454,8 +454,8 @@ def build_pro_completion_prompts(
|
||||
if preferences:
|
||||
if preferences.language and preferences.language != "auto":
|
||||
pref_info.append(f"Preferred language: {preferences.language}")
|
||||
if preferences.currency and preferences.currency != "auto":
|
||||
pref_info.append(f"Preferred currency: {preferences.currency}")
|
||||
if preferences.country and preferences.country != "auto":
|
||||
pref_info.append(f"Preferred country: {preferences.country}")
|
||||
if preferences.timezone and preferences.timezone != "auto":
|
||||
pref_info.append(f"Preferred timezone: {preferences.timezone}")
|
||||
|
||||
|
||||
@@ -8,3 +8,10 @@ python-multipart>=0.0.9
|
||||
python-dotenv>=1.0.0
|
||||
markitdown>=0.1.1
|
||||
geoip2>=4.8.0
|
||||
numpy>=1.26.0
|
||||
torch>=2.2.0
|
||||
soundfile>=0.12.1
|
||||
scipy>=1.13.0
|
||||
qwen-tts
|
||||
modelscope>=1.18.0
|
||||
faster-whisper>=1.1.0
|
||||
|
||||
@@ -92,7 +92,7 @@ class RiskConfig:
|
||||
def load_risk_config() -> RiskConfig:
|
||||
raw_origins = _str_env(
|
||||
"CORS_ALLOW_ORIGINS",
|
||||
"https://chat.imageteach.tech,http://localhost:8080,http://127.0.0.1:8080",
|
||||
"https://chat.imageteach.tech,http://localhost:8080,http://127.0.0.1:8080,http://localhost:5173,http://127.0.0.1:5173",
|
||||
)
|
||||
cors_allow_origins = tuple(
|
||||
origin.strip() for origin in raw_origins.split(",") if origin.strip()
|
||||
@@ -138,7 +138,7 @@ def load_risk_config() -> RiskConfig:
|
||||
web_search_temperature=_float_env("RISK_WEB_SEARCH_TEMPERATURE", 0.4),
|
||||
compress_max_input_chars=_int_env("RISK_COMPRESS_MAX_INPUT_CHARS", 128000),
|
||||
compress_max_output_tokens=_int_env("RISK_COMPRESS_MAX_OUTPUT_TOKENS", 1536),
|
||||
ocr_max_input_bytes=_int_env("RISK_OCR_MAX_INPUT_BYTES", 10 * 1024 * 1024),
|
||||
ocr_max_input_bytes=_int_env("RISK_OCR_MAX_INPUT_BYTES", 100 * 1024 * 1024),
|
||||
completion_input_cost_per_1k=_float_env("RISK_COMPLETION_INPUT_COST_PER_1K", 0.0004),
|
||||
completion_output_cost_per_1k=_float_env("RISK_COMPLETION_OUTPUT_COST_PER_1K", 0.0016),
|
||||
pro_input_cost_per_1k=_float_env("RISK_PRO_INPUT_COST_PER_1K", 0.003),
|
||||
|
||||
@@ -299,3 +299,4 @@ def test_call_vlm_ocr(monkeypatch):
|
||||
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,")
|
||||
assert captured["json"]["options"]["think"] is False
|
||||
|
||||
@@ -4,6 +4,7 @@ import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -158,6 +159,35 @@ def test_post_ocr_mocked(monkeypatch):
|
||||
assert "OCR result text" in body
|
||||
|
||||
|
||||
def test_post_video_ocr_merges_ocr_and_asr(monkeypatch):
|
||||
async def fake_ocr(*args, **kwargs):
|
||||
return "画面文字"
|
||||
|
||||
async def fake_asr(*args, **kwargs):
|
||||
return SimpleNamespace(text="音频转写")
|
||||
|
||||
monkeypatch.setattr(job_handlers, "call_vlm_ocr", fake_ocr)
|
||||
monkeypatch.setattr(job_handlers, "generate_asr_response", fake_asr)
|
||||
monkeypatch.setattr(job_handlers, "extract_audio_wav_bytes", lambda _path: b"fake wav")
|
||||
|
||||
video_b64 = base64.b64encode(b"pretend video data").decode()
|
||||
with TestClient(main.app) as client:
|
||||
with client.stream("POST", "/v1/ocr", headers=HEADERS, json={
|
||||
"image": video_b64,
|
||||
"filename": "sample.mp4",
|
||||
"language": "auto",
|
||||
"media_type": "video",
|
||||
"mime_type": "video/mp4",
|
||||
}) as resp:
|
||||
assert resp.status_code == 200
|
||||
body = "".join(resp.iter_text())
|
||||
|
||||
assert "视频画面 OCR" in body
|
||||
assert "视频音频 ASR" in body
|
||||
assert "画面文字" in body
|
||||
assert "音频转写" in body
|
||||
|
||||
|
||||
def test_post_convert_txt_returns_markdown():
|
||||
content = base64.b64encode(b"hello world").decode()
|
||||
with TestClient(main.app) as client:
|
||||
|
||||
@@ -35,7 +35,7 @@ def _payload():
|
||||
"privacy_mode": True,
|
||||
"user_preferences": {
|
||||
"language": "zh",
|
||||
"currency": "CNY",
|
||||
"country": "CN",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
}
|
||||
@@ -43,7 +43,7 @@ def _payload():
|
||||
|
||||
def test_pro_queue_full_returns_429(monkeypatch):
|
||||
async def fake_queue_job(*args, **kwargs):
|
||||
raise job_system.QueueFullError("pro_completion queue is full")
|
||||
raise job_system.QueueFullError("pro_completion", 8)
|
||||
|
||||
monkeypatch.setattr(main, "_queue_job", fake_queue_job)
|
||||
with TestClient(main.app) as client:
|
||||
@@ -79,13 +79,13 @@ def test_pro_prompt_accepts_serialized_preferences():
|
||||
instruction="expand",
|
||||
preferences={
|
||||
"language": "zh",
|
||||
"currency": "CNY",
|
||||
"country": "CN",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Preferred language: zh" in user_prompt
|
||||
assert "Preferred currency: CNY" in user_prompt
|
||||
assert "Preferred country: CN" in user_prompt
|
||||
assert "Preferred timezone: Asia/Shanghai" in user_prompt
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ if str(BACKEND_DIR) not in sys.path:
|
||||
|
||||
import job_handlers # type: ignore
|
||||
import job_system # type: ignore
|
||||
import llm # type: ignore
|
||||
import risk_control # type: ignore
|
||||
import session_store # type: ignore
|
||||
import audit_store # type: ignore
|
||||
@@ -55,10 +56,14 @@ def test_web_search_route_returns_done(monkeypatch):
|
||||
return {"content": '["vector database comparison", "pinecone weaviate qdrant"]'}
|
||||
if tag.endswith("-webu"):
|
||||
return {"content": '["https://example.com/a", "https://example.com/b"]'}
|
||||
if tag.endswith("-webf"):
|
||||
return {"content": "第一段\n\n第二段"}
|
||||
raise AssertionError(f"unexpected tag: {tag}")
|
||||
|
||||
async def fake_stream_ollama_events(prompt, system_prompt=None, tag="", **kwargs): # noqa: ARG001
|
||||
if not tag.endswith("-webf"):
|
||||
raise AssertionError(f"unexpected stream tag: {tag}")
|
||||
yield "content", "第一段\n\n"
|
||||
yield "content", "第二段"
|
||||
|
||||
async def fake_searxng_search(query, *, limit): # noqa: ARG001
|
||||
return [
|
||||
{
|
||||
@@ -83,6 +88,7 @@ def test_web_search_route_returns_done(monkeypatch):
|
||||
monkeypatch.setattr(job_handlers, "call_ollama", fake_call_ollama)
|
||||
monkeypatch.setattr(job_handlers, "_searxng_search", fake_searxng_search)
|
||||
monkeypatch.setattr(job_handlers, "_firecrawl_scrape", fake_firecrawl_scrape)
|
||||
monkeypatch.setattr(llm, "stream_ollama_events", fake_stream_ollama_events)
|
||||
|
||||
with TestClient(main.app) as client:
|
||||
with client.stream("POST", "/v1/web-search", headers=HEADERS, json=_payload()) as resp:
|
||||
|
||||
+140
-308
@@ -1,260 +1,133 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import wave
|
||||
from typing import Optional
|
||||
|
||||
# 设置 Hugging Face / ModelScope 镜像源为国内镜像
|
||||
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
import numpy as np # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("numpy import failed: %s", exc)
|
||||
np = None # type: ignore
|
||||
|
||||
try:
|
||||
import torch # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("torch import failed: %s", exc)
|
||||
torch = None # type: ignore
|
||||
|
||||
# New TTS model import
|
||||
try:
|
||||
from qwen_tts import Qwen3TTSModel # type: ignore
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug("qwen_tts import failed (optional): %s", e)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("qwen_tts import failed: %s", exc)
|
||||
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
|
||||
from faster_whisper import WhisperModel # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("faster_whisper import failed: %s", exc)
|
||||
WhisperModel = 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)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("modelscope import failed: %s", exc)
|
||||
snapshot_download = None # type: ignore
|
||||
|
||||
meta_router = APIRouter()
|
||||
generation_router = APIRouter()
|
||||
|
||||
# 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"
|
||||
ASR_MODEL_ID = os.getenv("ASR_MODEL_ID", "small")
|
||||
ASR_COMPUTE_TYPE = os.getenv("ASR_COMPUTE_TYPE", "int8")
|
||||
|
||||
# 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"
|
||||
_tts_model: Optional["Qwen3TTSModel"] = None
|
||||
_asr_model: Optional["WhisperModel"] = None
|
||||
|
||||
|
||||
def _get_device_map() -> str:
|
||||
"""设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU"""
|
||||
if torch is None:
|
||||
return "cpu"
|
||||
if torch.cuda.is_available():
|
||||
return "cuda:0"
|
||||
return "cuda"
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.debug("MPS check failed: %s", e)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("MPS check failed: %s", exc)
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _download_model_from_modelscope() -> Optional[str]:
|
||||
"""从 ModelScope 下载模型到本地缓存目录"""
|
||||
def _download_tts_model_from_modelscope() -> Optional[str]:
|
||||
if snapshot_download is None:
|
||||
return None
|
||||
cache_dir = os.path.join(os.path.dirname(__file__), "models")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
try:
|
||||
cache_dir = os.path.join(os.path.dirname(__file__), "models")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
model_dir = snapshot_download(
|
||||
MODEL_ID_MS,
|
||||
cache_dir=cache_dir,
|
||||
revision="master"
|
||||
)
|
||||
logger.info("ModelScope 模型下载完成: %s", model_dir)
|
||||
return model_dir
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.warning("ModelScope 下载失败: %s", e)
|
||||
return snapshot_download(MODEL_ID_MS, cache_dir=cache_dir, revision="master")
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("ModelScope TTS download failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _warmup_tts():
|
||||
"""预热 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 模型,支持多个镜像源"""
|
||||
def _ensure_tts_model() -> "Qwen3TTSModel":
|
||||
global _tts_model
|
||||
if _tts_model is not None:
|
||||
return _tts_model
|
||||
if Qwen3TTSModel is None:
|
||||
raise RuntimeError("qwen_tts 库未安装,无法加载 TTS 模型")
|
||||
if np is None or torch is None or Qwen3TTSModel is None:
|
||||
raise RuntimeError("TTS 依赖未安装完整")
|
||||
|
||||
device_map = _get_device_map()
|
||||
last_err = None
|
||||
dtype = torch.float16 if device_map != "cpu" else torch.float32
|
||||
|
||||
# 策略1: 尝试从 ModelScope 下载后加载
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info("尝试从 ModelScope 下载 TTS 模型...")
|
||||
model_path = _download_model_from_modelscope()
|
||||
if model_path and os.path.isdir(model_path):
|
||||
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
|
||||
model_path,
|
||||
device_map=device_map,
|
||||
dtype=torch.float16,
|
||||
)
|
||||
logger.info("ModelScope TTS 模型加载成功: %s", model_path)
|
||||
return _tts_model
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.warning("ModelScope TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
|
||||
last_err = e
|
||||
model_path = _download_tts_model_from_modelscope()
|
||||
last_error = None
|
||||
|
||||
# 策略2: 尝试从 HuggingFace 镜像加载
|
||||
for attempt in range(max_retries):
|
||||
for candidate in [model_path, MODEL_ID_HF]:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
logger.info("尝试从 HuggingFace 镜像加载 TTS...")
|
||||
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
|
||||
MODEL_ID_HF,
|
||||
candidate,
|
||||
device_map=device_map,
|
||||
dtype=torch.float16,
|
||||
dtype=dtype,
|
||||
)
|
||||
logger.info("HuggingFace TTS 模型加载成功")
|
||||
return _tts_model
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.warning("HuggingFace TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
|
||||
last_err = e
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
logger.warning("TTS model load failed from %s: %s", candidate, exc)
|
||||
|
||||
raise RuntimeError(f"无法加载 TTS 模型: {last_err}") from last_err
|
||||
raise RuntimeError(f"TTS 模型加载失败: {last_error}") from last_error
|
||||
|
||||
|
||||
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 模型"""
|
||||
def _ensure_asr_model() -> "WhisperModel":
|
||||
global _asr_model
|
||||
try:
|
||||
from mlx_audio.stt.utils import load as stt_load # type: ignore
|
||||
if _asr_model is not None:
|
||||
return _asr_model
|
||||
if WhisperModel is None:
|
||||
raise RuntimeError("faster-whisper 未安装")
|
||||
|
||||
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
|
||||
device = "cuda" if _get_device_map() == "cuda" else "cpu"
|
||||
compute_type = ASR_COMPUTE_TYPE if device == "cpu" else "float16"
|
||||
_asr_model = WhisperModel(ASR_MODEL_ID, device=device, compute_type=compute_type)
|
||||
return _asr_model
|
||||
|
||||
|
||||
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
|
||||
async def _warmup_tts():
|
||||
await asyncio.to_thread(_ensure_tts_model)
|
||||
|
||||
|
||||
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
|
||||
async def _warmup_asr():
|
||||
await asyncio.to_thread(_ensure_asr_model)
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
@@ -286,43 +159,25 @@ class ModelStatus(BaseModel):
|
||||
device: str
|
||||
|
||||
|
||||
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
|
||||
def _normalize_language(language: Optional[str]) -> Optional[str]:
|
||||
if not language:
|
||||
return None
|
||||
value = language.strip().lower()
|
||||
if value in {"auto", ""}:
|
||||
return None
|
||||
mapping = {
|
||||
"zh-cn": "zh",
|
||||
"zh-hans": "zh",
|
||||
"zh-tw": "zh",
|
||||
"en-us": "en",
|
||||
"ja-jp": "ja",
|
||||
"ko-kr": "ko",
|
||||
}
|
||||
return mapping.get(value, value.split("-")[0])
|
||||
|
||||
|
||||
@meta_router.get("/status", response_model=ModelStatus)
|
||||
async def get_status():
|
||||
"""获取模型状态"""
|
||||
return ModelStatus(
|
||||
tts_loaded=_tts_model is not None,
|
||||
asr_loaded=_asr_model is not None,
|
||||
@@ -332,11 +187,10 @@ async def get_status():
|
||||
|
||||
@meta_router.get("/config")
|
||||
async def get_config():
|
||||
"""获取配置信息"""
|
||||
return {
|
||||
"model": {
|
||||
"tts": MODEL_ID_MS,
|
||||
"asr": ASR_MODEL_ID_MS if Qwen3ASRModel is not None else None,
|
||||
"asr": ASR_MODEL_ID,
|
||||
},
|
||||
"device": _get_device_map(),
|
||||
"status": {
|
||||
@@ -348,15 +202,11 @@ async def get_config():
|
||||
|
||||
@meta_router.post("/warmup")
|
||||
async def warmup_models():
|
||||
"""手动触发模型预热"""
|
||||
await _warmup_tts()
|
||||
|
||||
if Qwen3ASRModel is not None:
|
||||
await _warmup_asr()
|
||||
|
||||
await _warmup_asr()
|
||||
return {
|
||||
"tts_warmup": _tts_model is not None,
|
||||
"asr_warmup": _asr_model is not None if Qwen3ASRModel else False,
|
||||
"asr_warmup": _asr_model is not None,
|
||||
"device": _get_device_map(),
|
||||
}
|
||||
|
||||
@@ -367,26 +217,30 @@ async def generate_tts_response(
|
||||
speaker: str = "Vivian",
|
||||
output_format: str = "wav",
|
||||
) -> TTSResponse:
|
||||
del speaker # current model path does not expose multi-speaker routing
|
||||
del output_format # current implementation always returns wav
|
||||
try:
|
||||
model = _ensure_tts_model()
|
||||
except Exception as e: # noqa: ANN001
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
del speaker
|
||||
del output_format
|
||||
if np is None:
|
||||
raise HTTPException(status_code=501, detail="numpy 未安装,TTS 功能不可用")
|
||||
|
||||
try:
|
||||
wavs, sr = model.generate_voice_design( # type: ignore
|
||||
model = _ensure_tts_model()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
try:
|
||||
wavs, sample_rate = await asyncio.to_thread(
|
||||
model.generate_voice_design, # type: ignore
|
||||
text=text,
|
||||
language="Chinese",
|
||||
instruct=instruct or "",
|
||||
)
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.exception("TTS 推理失败")
|
||||
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
|
||||
except Exception as exc:
|
||||
logger.exception("TTS inference failed")
|
||||
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {exc}")
|
||||
|
||||
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
|
||||
if hasattr(wav_data, 'numpy'): # type: ignore
|
||||
wav_data = wav_data.cpu().numpy() # type: ignore
|
||||
if hasattr(wav_data, "cpu"):
|
||||
wav_data = wav_data.cpu().numpy()
|
||||
wav_data = np.asarray(wav_data, dtype=np.float32)
|
||||
|
||||
tmp_path = None
|
||||
@@ -394,81 +248,66 @@ async def generate_tts_response(
|
||||
import soundfile as sf # type: ignore
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
|
||||
os.close(fd) # type: ignore
|
||||
sf.write(tmp_path, wav_data, sr)
|
||||
with open(tmp_path, "rb") as f: # noqa: SIM115
|
||||
audio_bytes = f.read()
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.exception("音频编码失败")
|
||||
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
|
||||
os.close(fd)
|
||||
sf.write(tmp_path, wav_data, sample_rate)
|
||||
with open(tmp_path, "rb") as handle:
|
||||
audio_bytes = handle.read()
|
||||
except Exception as exc:
|
||||
logger.exception("TTS audio encode failed")
|
||||
raise HTTPException(status_code=500, detail=f"音频编码失败: {exc}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path): # noqa: SIM201
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
|
||||
return TTSResponse(audio_base64=audio_base64, format="wav", duration_ms=duration_ms)
|
||||
duration_ms = int(len(wav_data) / sample_rate * 1000) if sample_rate > 0 else 0
|
||||
return TTSResponse(
|
||||
audio_base64=base64.b64encode(audio_bytes).decode("utf-8"),
|
||||
format="wav",
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
|
||||
async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> ASRResponse:
|
||||
if Qwen3ASRModel is None:
|
||||
raise HTTPException(status_code=501, detail="mlx_audio 未安装,ASR 功能不可用")
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="音频内容为空")
|
||||
|
||||
try:
|
||||
model = _ensure_asr_model()
|
||||
except Exception as e: # noqa: ANN001
|
||||
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {e}")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {exc}")
|
||||
|
||||
normalized_language = _normalize_language(language)
|
||||
tmp_path = None
|
||||
try:
|
||||
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()
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
|
||||
os.close(fd)
|
||||
with open(tmp_path, "wb") as handle:
|
||||
handle.write(audio_bytes)
|
||||
|
||||
raw_data = wf.readframes(n_frames)
|
||||
audio_array = np.frombuffer(raw_data, dtype=np.int16 if sampwidth == 2 else np.float32)
|
||||
|
||||
if n_channels > 1:
|
||||
audio_array = np.mean(audio_array.reshape(-1, n_channels), axis=1)
|
||||
|
||||
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)
|
||||
|
||||
if audio_array.dtype == np.int16:
|
||||
audio_array = audio_array.astype(np.float32) / 32768.0
|
||||
|
||||
result = model.generate( # type: ignore
|
||||
audio_array,
|
||||
language=language if language else None,
|
||||
segments, info = await asyncio.to_thread(
|
||||
model.transcribe,
|
||||
tmp_path,
|
||||
language=normalized_language,
|
||||
vad_filter=True,
|
||||
beam_size=5,
|
||||
)
|
||||
|
||||
recognized_text = getattr(result, 'text', str(result)) if hasattr(result, 'text') else str(result)
|
||||
detected_lang = getattr(result, 'language', language or "zh-CN")
|
||||
if isinstance(detected_lang, list) and len(detected_lang) > 0:
|
||||
detected_lang = detected_lang[0]
|
||||
|
||||
return ASRResponse(text=recognized_text, language=str(detected_lang))
|
||||
text = "".join(segment.text for segment in segments).strip()
|
||||
if not text:
|
||||
raise RuntimeError("ASR 返回结果为空")
|
||||
detected_language = getattr(info, "language", normalized_language or "unknown")
|
||||
return ASRResponse(text=text, language=str(detected_language))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e: # noqa: ANN001
|
||||
logger.exception("ASR 推理失败")
|
||||
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {e}")
|
||||
except Exception as exc:
|
||||
logger.exception("ASR inference failed")
|
||||
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {exc}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@generation_router.post("/tts", response_model=TTSResponse)
|
||||
async def tts_endpoint(req: TTSRequest):
|
||||
"""TTS 文字转语音端点"""
|
||||
return await generate_tts_response(
|
||||
text=req.text,
|
||||
instruct=req.instruct or "",
|
||||
@@ -479,18 +318,11 @@ async def tts_endpoint(req: TTSRequest):
|
||||
|
||||
@generation_router.post("/asr", response_model=ASRResponse)
|
||||
async def asr_endpoint(req: ASRRequest):
|
||||
"""语音识别端点(非流式)"""
|
||||
audio_bytes = base64.b64decode(req.audio_base64)
|
||||
return await generate_asr_response(audio_bytes, req.language if req.language else None)
|
||||
|
||||
|
||||
def register_tts_asr_routes(app, include_generation_routes: bool = True):
|
||||
"""注册 TTS/ASR 路由到 FastAPI 应用"""
|
||||
app.include_router(meta_router, prefix="/v1/tts-asr")
|
||||
if include_generation_routes:
|
||||
app.include_router(generation_router, prefix="/v1/tts-asr")
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(meta_router)
|
||||
router.include_router(generation_router)
|
||||
|
||||
+7
-4
@@ -52,7 +52,7 @@
|
||||
- 上传图片和文档
|
||||
- 触发 OCR(通过 **OCRImageWrapper.vue**)
|
||||
- 导入导出 Markdown
|
||||
- 导出 DOCX 和 PDF
|
||||
- 导出 DOCX 和 PDF(纯前端,从展开后的编辑器 HTML 导出)
|
||||
- AI 开关
|
||||
- 32 KB 大小限制
|
||||
- TTS 菜单和播放器
|
||||
@@ -68,7 +68,7 @@
|
||||
- debounceMs
|
||||
- privacyMode
|
||||
- language
|
||||
- currency
|
||||
- country
|
||||
- backgroundType
|
||||
- backgroundImage
|
||||
- backgroundOpacity
|
||||
@@ -85,7 +85,7 @@
|
||||
- 生成 request_id
|
||||
- 绑定 AbortSignal
|
||||
- 触发中止时的 cancel 请求
|
||||
- 读取 settings store 中的 thinking、privacy、language、currency、timezone 信息
|
||||
- 读取 settings store 中的 thinking、privacy、language、country、timezone 信息
|
||||
- 向后端发 POST 并读取 JSON
|
||||
|
||||
## 当前真实约定
|
||||
@@ -99,9 +99,12 @@
|
||||
## 容易踩坑的点
|
||||
|
||||
- 文档超过 32 KB 时,AI 补全会被禁用。
|
||||
- AI 开关需要同时作用到主编辑器、文档块嵌套编辑器、网页搜索块嵌套编辑器;当前通过 `llm-in-text:copilot-toggle` 广播同步。
|
||||
- 在文档块、Mermaid、LaTeX 等特定上下文中,部分 AI 行为和上传行为会被禁用或改道。
|
||||
- OCR 文本和文档块摘录会被注入补全上下文,但这些内容不应直接作为用户可见输出回写到文档。
|
||||
- 前端存在 /v1/export/pdf 调用,但调试前先确认后端是否真的实现了这个端点。
|
||||
- DOCX/PDF 导出当前不再依赖 `/v1/export/pdf`;导出逻辑看 `utils/richExport.js`。
|
||||
- 上传单文件限制统一为 100MB;图片上传会等待 OCR 完成并给出全局进度与失败反馈。
|
||||
- 视频上传会调用 `/v1/ocr` 的 `media_type=video` 路径,后端返回合并后的 OCR/ASR 文本,再插入文档块。
|
||||
- **Web Search 块使用 ```llm-websearch fenced code 语法,触发词为 [WEBSEARCH](不区分大小写)。**
|
||||
- **验证码组件依赖 vue3-captcha 库,刷新和验证逻辑需保持与后端 /captcha/generate 和 /captcha/verify 接口同步。**
|
||||
- 当前前端多处仍保留占位 API Key 或默认值;不要把这种写法继续扩散到新代码。
|
||||
|
||||
@@ -56,6 +56,7 @@ import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView
|
||||
import { fetchSuggestion, submitCompress, pollCompressStatus } from '../utils/api.js'
|
||||
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
|
||||
|
||||
const COPILOT_TOGGLE_EVENT = 'llm-in-text:copilot-toggle'
|
||||
const props = defineProps({
|
||||
docType: { type: String, default: 'txt' },
|
||||
docName: { type: String, default: 'document.txt' },
|
||||
@@ -77,6 +78,7 @@ let crepe = null
|
||||
let syncTimer = null
|
||||
let syncingExternal = false
|
||||
let compressPoller = null
|
||||
let copilotToggleHandler = null
|
||||
|
||||
const handleCompress = () => {
|
||||
if (compressState.value !== 'idle') return
|
||||
@@ -233,8 +235,26 @@ onMounted(async () => {
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
setCopilotEnabled(view, true)
|
||||
const enabled = typeof window !== 'undefined'
|
||||
? window.__LLM_IN_TEXT_COPILOT_ENABLED__ !== false
|
||||
: true
|
||||
setCopilotEnabled(view, enabled)
|
||||
if (!enabled) {
|
||||
clearGhostSuggestion(view)
|
||||
}
|
||||
})
|
||||
|
||||
copilotToggleHandler = (event) => {
|
||||
const enabled = Boolean(event?.detail?.enabled)
|
||||
crepe?.editor?.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
setCopilotEnabled(view, enabled)
|
||||
if (!enabled) {
|
||||
clearGhostSuggestion(view)
|
||||
}
|
||||
})
|
||||
}
|
||||
window.addEventListener(COPILOT_TOGGLE_EVENT, copilotToggleHandler)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -246,6 +266,10 @@ onUnmounted(() => {
|
||||
compressPoller.stop()
|
||||
compressPoller = null
|
||||
}
|
||||
if (copilotToggleHandler) {
|
||||
window.removeEventListener(COPILOT_TOGGLE_EVENT, copilotToggleHandler)
|
||||
copilotToggleHandler = null
|
||||
}
|
||||
if (crepe) {
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
@@ -444,4 +468,3 @@ onUnmounted(() => {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<section class="input-block-shell" :class="[`is-${stage}`, { 'has-user-text': Boolean(userText) }]">
|
||||
<!-- Idle state: compact trigger bar -->
|
||||
<button
|
||||
v-if="isIdle && !hasUserText"
|
||||
type="button"
|
||||
class="input-block-trigger-bar"
|
||||
@mousedown.stop.prevent
|
||||
>
|
||||
<span class="input-badge">INPUT</span>
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<template v-if="displayInstruction.trim()">
|
||||
<span class="input-label">{{ displayInstruction }}</span>
|
||||
</template>
|
||||
</Transition>
|
||||
</button>
|
||||
|
||||
<!-- Active panel -->
|
||||
<div v-else class="input-block-panel">
|
||||
<div class="input-header-row" @click.stop>
|
||||
<!-- Instructions display -->
|
||||
<span v-if="displayInstruction.trim()" class="input-instruction">{{ displayInstruction }}</span>
|
||||
</div>
|
||||
|
||||
<Transition name="input-slide" appear>
|
||||
<!-- Input controls -->
|
||||
<div class="input-controls-row">
|
||||
<textarea
|
||||
ref="textAreaRef"
|
||||
:value="userText || ''"
|
||||
class="input-textarea"
|
||||
@input.stop.prevent="handleTextInput"
|
||||
></textarea>
|
||||
|
||||
<!-- ASR voice recording controls -->
|
||||
<div class="input-voice-group">
|
||||
<button
|
||||
type="button"
|
||||
:class="['input-voice-btn', { 'is-recording': isRecording }]"
|
||||
@mousedown.stop.prevent
|
||||
:title="t('inputVoiceMode') || '语音模式'"
|
||||
>
|
||||
<span class="voice-icon">{{ isRecording ? '⏹️' : '🎤' }}</span>
|
||||
</button>
|
||||
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Accept button -->
|
||||
<button
|
||||
v-if="hasUserText"
|
||||
type="button"
|
||||
class="input-accept-btn"
|
||||
@mousedown.stop.prevent
|
||||
:disabled="isTranscribing || isSubmittingText"
|
||||
@click.stop="handleAcceptInput"
|
||||
>
|
||||
<span class="accept-icon">✓</span>
|
||||
</button>
|
||||
|
||||
<!-- Transcribing indicator -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<div v-if="isTranscribing && !hasUserText" class="input-transcribing">
|
||||
<span>{{ t('transcribing') || '转录中...' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<div v-if="transcriptionError && !hasUserText" class="input-error">
|
||||
{{ t('error') || '错误' }}: {{ transcriptionError }}</div>
|
||||
|
||||
<!-- Placeholder hint -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<div v-if="!hasUserText && !isTranscribing" class="input-placeholder">
|
||||
{{ t('inputPlaceholder') || '(请输入文本)' }}</div>
|
||||
|
||||
<!-- Status indicators -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Recording indicator -->
|
||||
<div v-if="isRecording && !hasUserText" class="input-recording-indicator">
|
||||
<span>{{ t('recording') || '录音中...' }}</div>
|
||||
|
||||
<!-- Transcribing indicator -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<div v-if="isTranscribing && !hasUserText" class="input-transcribing-indicator">
|
||||
<span>{{ t('transcribing') || '转录中...' }}</div>
|
||||
|
||||
<!-- Accepted state -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- When text is entered but not yet accepted, show "接受" button -->
|
||||
<button
|
||||
v-if="hasUserText && !isTranscribing"
|
||||
type="button"
|
||||
class="input-accept-btn"
|
||||
@mousedown.stop.prevent
|
||||
:disabled="isTranscribing || isSubmittingText"
|
||||
@click.stop="handleAcceptInput"
|
||||
>
|
||||
{{ t('accept') || '接受' }}
|
||||
</button>
|
||||
|
||||
<!-- After accept, show "重录" (re-record) and "重试" buttons -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Re-record button (only when there's text) -->
|
||||
<button
|
||||
v-if="hasUserText && !isTranscribing"
|
||||
type="button"
|
||||
class="input-rerecord-btn"
|
||||
@mousedown.stop.prevent
|
||||
:disabled="isTranscribing || isSubmittingText"
|
||||
@click.stop="handleRerecordInput"
|
||||
>
|
||||
{{ t('inputRetry') || '重录' }}
|
||||
</button>
|
||||
|
||||
<!-- After accept, show "重试" (retry) button -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Retry button (only when there's text) -->
|
||||
<button
|
||||
v-if="hasUserText && !isTranscribing"
|
||||
type="button"
|
||||
class="input-retry-btn"
|
||||
@mousedown.stop.prevent
|
||||
:disabled="isTranscribing || isSubmittingText"
|
||||
@click.stop="handleRetryInput"
|
||||
>
|
||||
{{ t('inputRedo') || '重试' }}
|
||||
</button>
|
||||
|
||||
<!-- After accept, show version controls -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Version controls (only when there's text) -->
|
||||
<div v-if="hasUserText && !isTranscribing" class="input-version-controls">
|
||||
<span>{{ activeVersion + 1 }} / {{ versionCount }}</div>
|
||||
|
||||
<!-- After accept, show "接受" button -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- When text is entered but not yet accepted, show "接受" button -->
|
||||
<button
|
||||
v-if="hasUserText && !isTranscribing"
|
||||
type="button"
|
||||
class="input-accept-btn"
|
||||
@mousedown.stop.prevent
|
||||
:disabled="isTranscribing || isSubmittingText"
|
||||
@click.stop="handleAcceptInput"
|
||||
>
|
||||
{{ t('accept') || '接受' }}
|
||||
</button>
|
||||
|
||||
<!-- After accept, show "重录" (re-record) and "重试" buttons -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Re-record button (only when there's text) -->
|
||||
<button
|
||||
v-if="hasUserText && !isTranscribing"
|
||||
type="button"
|
||||
class="input-rerecord-btn"
|
||||
@mousedown.stop.prevent
|
||||
:disabled="isTranscribing || isSubmittingText"
|
||||
@click.stop="handleRerecordInput"
|
||||
>
|
||||
{{ t('inputRetry') || '重录' }}
|
||||
</button>
|
||||
|
||||
<!-- After accept, show "重试" (retry) button -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Retry button (only when there's text) -->
|
||||
<button
|
||||
v-if="hasUserText && !isTranscribing"
|
||||
type="button"
|
||||
class="input-retry-btn"
|
||||
@mousedown.stop.prevent
|
||||
:disabled="isTranscribing || isSubmittingText"
|
||||
@click.stop="handleRetryInput"
|
||||
>
|
||||
{{ t('inputRedo') || '重试' }}
|
||||
</button>
|
||||
|
||||
<!-- After accept, show version controls -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Version controls (only when there's text) -->
|
||||
<div v-if="hasUserText && !isTranscribing" class="input-version-controls">
|
||||
<span>{{ activeVersion + 1 }} / {{ versionCount }}</div>
|
||||
|
||||
<!-- Status line -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Status line (only when there's text) -->
|
||||
<div v-if="hasUserText" class="input-status-line">
|
||||
<!-- Status line content -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer row with action buttons and status indicators -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
<!-- Footer row (only when there's text) -->
|
||||
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Clear textarea on accept to allow new input -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Placeholder hint when no text -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Status indicators row -->
|
||||
<Transition name="input-fade" mode="out-in">
|
||||
|
||||
</div>
|
||||
)} else {
|
||||
// No text yet → show placeholder + voice mode button only
|
||||
}
|
||||
|
||||
<!-- Footer row -->
|
||||
</Transition>
|
||||
</section>INPUT_BLOCK_CREPE_EOF
|
||||
echo "Created InputBlockCrepe.vue component" && wc -l /Users/allenyuan/llm-in-text/src/components/InputBlockCrepe.vue
|
||||
+118
-123
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div class="editor-container" :style="editorTypographyStyle">
|
||||
<div ref="root" class="milkdown-editor"></div>
|
||||
|
||||
<div class="history-buttons">
|
||||
@@ -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" 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>
|
||||
<button type="button" @click="() => { exportDocx(); showExportDropdown = false; }">{{ t('exportDocx') }}</button>
|
||||
<button type="button" @click="() => { exportPdf(); showExportDropdown = false; }">{{ t('exportPdf') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -320,13 +320,14 @@ import { fetchProSuggestionStream, fetchSuggestion, fetchTTS, fetchWebSearchStre
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useTemplatesStore } from '../stores/templates'
|
||||
import { useTheme } from '../composables/useTheme.js'
|
||||
import { OCR_URL, EXPORT_PDF_URL } from '../utils/config.js'
|
||||
import { OCR_URL } from '../utils/config.js'
|
||||
import TTSMenu from './TTSMenu.vue'
|
||||
import TTSPlayer from './TTSPlayer.vue'
|
||||
import { convertFileToMarkdown, convertAudioToText } from '../utils/convert.js'
|
||||
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
|
||||
import { convertFileToMarkdown, convertAudioToText, convertVideoToText } from '../utils/convert.js'
|
||||
import { exportEditorToDocxBlob, exportEditorToPdf } from '../utils/richExport.js'
|
||||
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash, setOcrState, OcrStatus } from '../utils/ocrCache.js'
|
||||
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
|
||||
import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy, isAudioFile } from '../utils/docBlock.js'
|
||||
import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy, isAudioFile, isVideoFile } from '../utils/docBlock.js'
|
||||
import { isUploadBlockTypeAllowed } from '../utils/uploadBlock.js'
|
||||
import { WEB_SEARCH_NODE_TYPE } from '../utils/webSearch.js'
|
||||
|
||||
@@ -336,6 +337,7 @@ const templateStore = useTemplatesStore()
|
||||
const { isDark } = useTheme()
|
||||
const t = (key) => settings.t[key]
|
||||
const initialMarkdown = computed(() => settings.initialMarkdown)
|
||||
const COPILOT_TOGGLE_EVENT = 'llm-in-text:copilot-toggle'
|
||||
|
||||
const root = ref(null)
|
||||
const uploadInputRef = ref(null)
|
||||
@@ -416,9 +418,8 @@ const CONVERT_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
])
|
||||
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska'])
|
||||
const MAX_VIDEO_SIZE = 200 * 1024 * 1024 // 200MB
|
||||
const MAX_UPLOAD_BATCH = 10
|
||||
const MAX_UPLOAD_FILE_SIZE = 50 * 1024 * 1024
|
||||
const MAX_UPLOAD_FILE_SIZE = 100 * 1024 * 1024
|
||||
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
|
||||
|
||||
const normalizeTrailingWhitespace = (value) => (value || '').replace(/\s+$/, '')
|
||||
@@ -572,34 +573,6 @@ const toggleTemplateDropdown = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildDocxBlob = async (markdown) => {
|
||||
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
|
||||
const children = []
|
||||
const normalizedMarkdown = String(markdown || '').replace(/\r\n?/g, '\n')
|
||||
|
||||
for (const line of normalizedMarkdown.split('\n')) {
|
||||
if (line.startsWith('# ')) {
|
||||
children.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
children.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('### ')) {
|
||||
children.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('---')) {
|
||||
children.push(new Paragraph({ text: '----------' }))
|
||||
continue
|
||||
}
|
||||
children.push(line.trim() === '' ? new Paragraph({}) : new Paragraph({ text: line }))
|
||||
}
|
||||
|
||||
return Packer.toBlob(new Document({ sections: [{ properties: {}, children }] }))
|
||||
}
|
||||
|
||||
const downloadBlob = (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
@@ -744,9 +717,15 @@ const clearCurrentSuggestion = (view) => {
|
||||
const clearCurrentGhost = () => {
|
||||
if (!crepe) return
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearGhostSuggestion(view)
|
||||
})
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearGhostSuggestion(view)
|
||||
})
|
||||
}
|
||||
|
||||
const broadcastCopilotEnabled = (enabled) => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.__LLM_IN_TEXT_COPILOT_ENABLED__ = Boolean(enabled)
|
||||
window.dispatchEvent(new CustomEvent(COPILOT_TOGGLE_EVENT, { detail: { enabled: Boolean(enabled) } }))
|
||||
}
|
||||
|
||||
const updateHistoryState = (view) => {
|
||||
@@ -1059,42 +1038,59 @@ const consumeSseResult = async (res) => {
|
||||
}
|
||||
|
||||
const performOCR = async (file, cacheKey, imageHash = '') => {
|
||||
if (!aiEnabled.value) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const dataUrl = typeof reader.result === 'string' ? reader.result : ''
|
||||
const splitIndex = dataUrl.indexOf(',')
|
||||
if (splitIndex === -1) return
|
||||
if (!aiEnabled.value) return ''
|
||||
|
||||
const base64 = dataUrl.slice(splitIndex + 1)
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const res = await fetch(OCR_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
image: base64,
|
||||
filename: file.name,
|
||||
language: 'auto'
|
||||
})
|
||||
})
|
||||
const data = await consumeSseResult(res)
|
||||
if (data.text) {
|
||||
setOcrCache(cacheKey, data.text)
|
||||
setOcrCache(file.name, data.text)
|
||||
if (imageHash) {
|
||||
setOcrByHash(imageHash, data.text)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// OCR error, ignore
|
||||
}
|
||||
if (imageHash) {
|
||||
setOcrState(imageHash, OcrStatus.LOADING)
|
||||
}
|
||||
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
||||
reader.onerror = () => reject(reader.error || new Error('读取图片失败'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
const splitIndex = dataUrl.indexOf(',')
|
||||
if (splitIndex === -1) {
|
||||
throw new Error('图片编码失败')
|
||||
}
|
||||
|
||||
const base64 = dataUrl.slice(splitIndex + 1)
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(OCR_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
image: base64,
|
||||
filename: file.name,
|
||||
language: 'auto',
|
||||
media_type: 'image',
|
||||
mime_type: file.type || 'image/png',
|
||||
}),
|
||||
})
|
||||
const data = await consumeSseResult(res)
|
||||
if (!data?.text) {
|
||||
throw new Error('OCR 返回结果为空')
|
||||
}
|
||||
setOcrCache(cacheKey, data.text)
|
||||
setOcrCache(file.name, data.text)
|
||||
if (imageHash) {
|
||||
setOcrByHash(imageHash, data.text)
|
||||
setOcrState(imageHash, OcrStatus.SUCCESS, data.text)
|
||||
}
|
||||
return data.text
|
||||
} catch (error) {
|
||||
if (imageHash) {
|
||||
setOcrState(imageHash, OcrStatus.FAILED, '', error?.message || 'OCR 识别失败')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const prepareImageFile = async (file) => {
|
||||
@@ -1116,10 +1112,11 @@ const prepareImageFile = async (file) => {
|
||||
const imageHash = await calculateImageHash(imageBytes)
|
||||
const existingOcr = getOcrByHash(imageHash)
|
||||
if (!existingOcr) {
|
||||
performOCR(file, objectUrl, imageHash)
|
||||
await performOCR(file, objectUrl, imageHash)
|
||||
} else {
|
||||
setOcrCache(objectUrl, existingOcr)
|
||||
setOcrCache(file.name, existingOcr)
|
||||
setOcrState(imageHash, OcrStatus.SUCCESS, existingOcr)
|
||||
}
|
||||
|
||||
return objectUrl
|
||||
@@ -1183,12 +1180,14 @@ const validateUploadFiles = (files, options = {}) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const parseDocFilesToBlocks = async (docFiles) => {
|
||||
const parseDocFilesToBlocks = async (docFiles, progressOptions = {}) => {
|
||||
if (docFiles.length === 0) {
|
||||
return { blocksToInsert: [], errors: [] }
|
||||
}
|
||||
|
||||
uploadProgress.value = { current: 0, total: docFiles.length, filename: '' }
|
||||
const total = Number.isFinite(progressOptions.total) ? progressOptions.total : docFiles.length
|
||||
const offset = Number.isFinite(progressOptions.offset) ? progressOptions.offset : 0
|
||||
uploadProgress.value = { current: offset, total, filename: '' }
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
@@ -1203,6 +1202,8 @@ const parseDocFilesToBlocks = async (docFiles) => {
|
||||
content = await convertFileToMarkdown(file)
|
||||
} else if (isAudioFile(file)) {
|
||||
content = await convertAudioToText(file)
|
||||
} else if (isVideoFile(file)) {
|
||||
content = await convertVideoToText(file)
|
||||
} else {
|
||||
throw new Error('不支持的文件类型')
|
||||
}
|
||||
@@ -1222,7 +1223,7 @@ const parseDocFilesToBlocks = async (docFiles) => {
|
||||
const settled = await Promise.allSettled(parsePromises)
|
||||
|
||||
settled.forEach((result, idx) => {
|
||||
uploadProgress.value = { current: idx + 1, total: docFiles.length, filename: docFiles[idx].name }
|
||||
uploadProgress.value = { current: offset + idx + 1, total, filename: docFiles[idx].name }
|
||||
if (result.status === 'fulfilled') {
|
||||
results.push(result.value)
|
||||
} else {
|
||||
@@ -1230,9 +1231,6 @@ const parseDocFilesToBlocks = async (docFiles) => {
|
||||
errors.push({ filename: docFiles[idx].name, message })
|
||||
}
|
||||
})
|
||||
|
||||
uploadProgress.value = null
|
||||
|
||||
results.sort((a, b) => a.index - b.index)
|
||||
|
||||
return {
|
||||
@@ -1258,16 +1256,28 @@ const processUploadedFiles = async (files, options = {}) => {
|
||||
const docFiles = normalizedFiles.filter((file) => !isImageFile(file))
|
||||
const replaceRange = options.replaceRange || null
|
||||
let rangeConsumed = false
|
||||
let progressIndex = 0
|
||||
const totalCount = normalizedFiles.length
|
||||
|
||||
for (const file of imageFiles) {
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (!objectUrl) continue
|
||||
insertImageAtCursor(objectUrl, !rangeConsumed ? replaceRange : null)
|
||||
rangeConsumed = rangeConsumed || Boolean(replaceRange)
|
||||
uploadProgress.value = { current: progressIndex + 1, total: totalCount, filename: `${file.name} · OCR` }
|
||||
try {
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (!objectUrl) continue
|
||||
insertImageAtCursor(objectUrl, !rangeConsumed ? replaceRange : null)
|
||||
rangeConsumed = rangeConsumed || Boolean(replaceRange)
|
||||
} catch (error) {
|
||||
alert(`${file.name} OCR 失败:${error.message || '未知错误'}`)
|
||||
} finally {
|
||||
progressIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (docFiles.length > 0) {
|
||||
const { blocksToInsert, errors } = await parseDocFilesToBlocks(docFiles)
|
||||
const { blocksToInsert, errors } = await parseDocFilesToBlocks(docFiles, {
|
||||
offset: progressIndex,
|
||||
total: totalCount,
|
||||
})
|
||||
if (blocksToInsert.length > 0) {
|
||||
insertMultipleDocBlocks(blocksToInsert, !rangeConsumed ? replaceRange : null)
|
||||
rangeConsumed = rangeConsumed || Boolean(replaceRange)
|
||||
@@ -1278,8 +1288,11 @@ const processUploadedFiles = async (files, options = {}) => {
|
||||
const errorMsgs = errors.map((item) => `${item.filename}: ${item.message}`).join('\n')
|
||||
alert(`上传失败 ${failCount} 个文件:\n\n${errorMsgs}`)
|
||||
}
|
||||
progressIndex += docFiles.length
|
||||
}
|
||||
|
||||
uploadProgress.value = null
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1478,6 +1491,7 @@ crepe = new Crepe({
|
||||
}
|
||||
document.addEventListener('mousedown', ttsClickOutsideHandler)
|
||||
})
|
||||
broadcastCopilotEnabled(aiEnabled.value)
|
||||
scheduleMarkdownSync()
|
||||
})
|
||||
|
||||
@@ -1493,15 +1507,20 @@ const exportMarkdown = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const showExportDisabled = () => {
|
||||
alert(t('exportDisabledHint') || 'DOCX/PDF 导出暂不可用。')
|
||||
const resolveEditorExportElement = () => {
|
||||
const editorShell = root.value?.querySelector('.milkdown-editor .milkdown')
|
||||
|| root.value?.querySelector('.milkdown-editor')
|
||||
|| root.value
|
||||
if (!(editorShell instanceof HTMLElement)) {
|
||||
throw new Error('未找到可导出的编辑器内容')
|
||||
}
|
||||
return editorShell
|
||||
}
|
||||
|
||||
const exportDocx = async () => {
|
||||
try {
|
||||
console.log('Exporting DOCX...')
|
||||
const markdown = await getExportMarkdown()
|
||||
const blob = await buildDocxBlob(markdown)
|
||||
console.log('Exporting DOCX from expanded editor HTML...')
|
||||
const blob = await exportEditorToDocxBlob(resolveEditorExportElement())
|
||||
const exportName = createExportName()
|
||||
downloadBlob(blob, `${exportName}.docx`)
|
||||
console.log('DOCX export completed')
|
||||
@@ -1513,28 +1532,10 @@ const exportDocx = async () => {
|
||||
|
||||
const exportPdf = async () => {
|
||||
try {
|
||||
console.log('Exporting PDF via DOCX...')
|
||||
const markdown = await getExportMarkdown()
|
||||
const docxBlob = await buildDocxBlob(markdown)
|
||||
console.log('Exporting PDF from expanded editor HTML...')
|
||||
const exportName = createExportName()
|
||||
const formData = new FormData()
|
||||
formData.append('file', docxBlob, `${exportName}.docx`)
|
||||
|
||||
const res = await fetch(EXPORT_PDF_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const pdfBlob = await res.blob()
|
||||
downloadBlob(pdfBlob, `${exportName}.pdf`)
|
||||
await exportEditorToPdf(resolveEditorExportElement(), `${exportName}.pdf`)
|
||||
console.log('PDF export completed successfully')
|
||||
alert('PDF导出成功!')
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error)
|
||||
alert(`PDF导出失败: ${error.message}`)
|
||||
@@ -1592,9 +1593,12 @@ const toggleAI = async () => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
setCopilotEnabled(view, aiEnabled.value)
|
||||
if (!aiEnabled.value) {
|
||||
clearCurrentSuggestion(view)
|
||||
interruptCopilot(view)
|
||||
clearGhostSuggestion(view)
|
||||
}
|
||||
})
|
||||
|
||||
broadcastCopilotEnabled(aiEnabled.value)
|
||||
}
|
||||
|
||||
const toggleExportDropdown = () => {
|
||||
@@ -1839,7 +1843,7 @@ for (const url of Array.from(objectUrls)) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--panel-shadow);
|
||||
opacity: 0.5;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
@@ -2051,16 +2055,6 @@ 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);
|
||||
@@ -2075,6 +2069,7 @@ for (const url of Array.from(objectUrls)) {
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.template-dropdown-section {
|
||||
|
||||
@@ -335,21 +335,22 @@ const validateCaptcha = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('currency') }}</label>
|
||||
<select v-model="store.currency" class="select-input" :disabled="store.privacyMode">
|
||||
<div class="form-group">
|
||||
<label>{{ t('country') }}</label>
|
||||
<select v-model="store.country" class="select-input" :disabled="store.privacyMode">
|
||||
<option value="auto">{{ t('auto') }}</option>
|
||||
<option value="CNY">CNY (¥)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="JPY">JPY (¥)</option>
|
||||
<option value="KRW">KRW (₩)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
<option value="AUD">AUD ($)</option>
|
||||
<option value="CAD">CAD ($)</option>
|
||||
<option value="CN">中国</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="JP">日本</option>
|
||||
<option value="KR">대한민국</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Security & Captcha Section -->
|
||||
<section class="settings-section" v-if="showCaptcha">
|
||||
|
||||
@@ -95,6 +95,7 @@ import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView
|
||||
import { fetchSuggestion, submitCompress, pollCompressStatus } from '../utils/api.js'
|
||||
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
|
||||
|
||||
const COPILOT_TOGGLE_EVENT = 'llm-in-text:copilot-toggle'
|
||||
const props = defineProps({
|
||||
stage: { type: String, default: 'idle' },
|
||||
progressText: { type: String, default: '' },
|
||||
@@ -119,6 +120,7 @@ let crepe = null
|
||||
let syncTimer = null
|
||||
let syncingExternal = false
|
||||
let compressPoller = null
|
||||
let copilotToggleHandler = null
|
||||
|
||||
const ensureEditor = async () => {
|
||||
if (crepe || !editorRoot.value || !hasContent.value) return
|
||||
@@ -166,8 +168,26 @@ const ensureEditor = async () => {
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
setCopilotEnabled(view, true)
|
||||
const enabled = typeof window !== 'undefined'
|
||||
? window.__LLM_IN_TEXT_COPILOT_ENABLED__ !== false
|
||||
: true
|
||||
setCopilotEnabled(view, enabled)
|
||||
if (!enabled) {
|
||||
clearGhostSuggestion(view)
|
||||
}
|
||||
})
|
||||
|
||||
copilotToggleHandler = (event) => {
|
||||
const enabled = Boolean(event?.detail?.enabled)
|
||||
crepe?.editor?.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
setCopilotEnabled(view, enabled)
|
||||
if (!enabled) {
|
||||
clearGhostSuggestion(view)
|
||||
}
|
||||
})
|
||||
}
|
||||
window.addEventListener(COPILOT_TOGGLE_EVENT, copilotToggleHandler)
|
||||
}
|
||||
|
||||
const hasContent = computed(() => Boolean((props.content || '').trim()))
|
||||
@@ -277,6 +297,10 @@ onUnmounted(() => {
|
||||
compressPoller.stop()
|
||||
compressPoller = null
|
||||
}
|
||||
if (copilotToggleHandler) {
|
||||
window.removeEventListener(COPILOT_TOGGLE_EVENT, copilotToggleHandler)
|
||||
copilotToggleHandler = null
|
||||
}
|
||||
if (crepe) {
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
|
||||
@@ -177,13 +177,6 @@ class DocBlockNodeView implements NodeView {
|
||||
this.view.focus()
|
||||
}
|
||||
|
||||
destroy(hasNewHeader: boolean, hasNewFooter: boolean) {
|
||||
if (this.app) {
|
||||
this.app.unmount()
|
||||
this.app = null
|
||||
}
|
||||
}
|
||||
|
||||
compressWithNewContent(compressedContent: string) {
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) return
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createApp, reactive } from 'vue'
|
||||
import { remarkCtx, schemaCtx, serializerCtx, type Ctx } from '@milkdown/kit/core'
|
||||
import { $ctx, $node, $prose, $remark, $view } from '@milkdown/kit/utils'
|
||||
import { Plugin, PluginKey } from '@milkdown/prose/state'
|
||||
import { type Node as ProseNode, Slice } from '@milkdown/prose/model'
|
||||
import { Decoration, DecorationSet, type EditorView, type NodeView } from '@milkdown/prose/view'
|
||||
import { ParserState } from '@milkdown/transformer'
|
||||
import InputBlockCrepe from '../components/InputBlockCrepe.vue'
|
||||
import {
|
||||
INPUT_BLOCK_NODE_TYPE,
|
||||
serializeInputBlockSyntax,
|
||||
} from '../utils/inputBlock.js'
|
||||
|
||||
const INPUT_BLOCK_INPUT_PLUGIN_KEY = new PluginKey('milkdown-input-block')
|
||||
interface InputBlockConfig {
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export const inputBlockNode = $node(INPUT_BLOCK_NODE_TYPE, () => ({
|
||||
group: 'block',
|
||||
atom: true,
|
||||
isolating: false, // Allow input blocks to be placed anywhere
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
marks: '', // No special marks for input blocks
|
||||
attrs: { instruction: false, userInputText: '' }, // Instruction and user text attributes
|
||||
}))
|
||||
|
||||
export const inputBlockRemark = $remark('inputBlockRemark', () => (tree: any) => {
|
||||
// Transform markdown AST nodes that match [INPUT] or [INPUT]{instr}***text*** patterns
|
||||
tree.children = (tree.children || []).map((child: any) => {
|
||||
// Handle fenced code blocks with language "llm-input" or similar syntaxes
|
||||
})
|
||||
})
|
||||
|
||||
export const inputBlockView = $view(inputBlockNode, (ctx) => {
|
||||
return (_node: any, _getPos?: () => number | undefined): NodeView => {
|
||||
return new InputBlockNodeView(_node, _getPos)
|
||||
}
|
||||
})
|
||||
|
||||
export const inputBlockInputPlugin = $prose(() => {
|
||||
return new Plugin({
|
||||
key: INPUT_BLOCK_INPUT_PLUGIN_KEY,
|
||||
props: {
|
||||
handleTextInput(view, _from, to, text) {
|
||||
return tryHandleInputTriggerText(text)
|
||||
},
|
||||
} as any, // ProseMirror plugin props type is incomplete in Milkdown types
|
||||
})
|
||||
})
|
||||
|
||||
export const inputBlockHighlightPlugin = $prose(() => {
|
||||
return new Plugin<DecorationSet>({
|
||||
key: INPUT_BLOCK_INPUT_PLUGIN_KEY,
|
||||
state: {
|
||||
init() { return DecorationSet.empty },
|
||||
apply(tr) { // Transaction mapping for decorations
|
||||
const meta = tr.getMeta(INPUT_BLOCK_INPUT_PLUGIN_KEY)
|
||||
},
|
||||
} as any, // DecorationSet state type is complex in Milkdown
|
||||
})
|
||||
})
|
||||
|
||||
export function insertInputBlockAtSelection(view: EditorView, autoStart = false) {
|
||||
return view.dispatch( // Actually inserts the node at cursor position
|
||||
view.state.tr.replaceWith(
|
||||
to,
|
||||
inputBlockNode.create({ instruction: '', userInputText: '' }),
|
||||
) as any, // ReplaceWith returns unknown in Milkdown types
|
||||
).then(() => {}).catch(console.error)
|
||||
}INPUT_PLUGIN_EOF
|
||||
|
||||
echo "Created inputBlockPlugin.ts" && wc -l /Users/allenyuan/llm-in-text/src/plugins/inputBlockPlugin.ts
|
||||
@@ -412,21 +412,6 @@ class WebSearchBlockNodeView implements NodeView {
|
||||
return
|
||||
}
|
||||
this.setStage(event, String(data?.message || ''))
|
||||
}, onDelta: (data) => {
|
||||
if (this.destroyed || this.requestSeq !== requestSeq) return
|
||||
const text = String(data?.text || '')
|
||||
if (!text) return
|
||||
streamedContent += text
|
||||
this.updateAttrs({ content: streamedContent })
|
||||
}, onDelta: (data) => {
|
||||
if (this.destroyed || this.requestSeq !== requestSeq) return
|
||||
const text = String(data?.text || '')
|
||||
if (!text) return
|
||||
streamedContent += text
|
||||
this.updateAttrs({ content: streamedContent }
|
||||
return
|
||||
}
|
||||
this.setStage(event, String(data?.message || ''))
|
||||
},
|
||||
onDelta: (data) => {
|
||||
if (this.destroyed || this.requestSeq !== requestSeq) return
|
||||
@@ -685,4 +670,3 @@ export const webSearchBlockInputPlugin = $prose(() => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+15
-8
@@ -13,11 +13,13 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const proThinking = ref('medium') // 'low' | 'medium' | 'high'
|
||||
const privacyMode = ref(true)
|
||||
const language = ref('auto')
|
||||
const currency = ref('auto')
|
||||
const country = ref('auto')
|
||||
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
|
||||
const backgroundImage = ref('')
|
||||
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
|
||||
const ttsInstruct = ref('')
|
||||
const fontFamily = ref('system') // 'system' | 'serif' | 'monospace'
|
||||
const fontSize = ref(16) // 12 - 32
|
||||
|
||||
// --- Computed getters (derived from reactive state) ---
|
||||
const uiLanguage = computed(() => {
|
||||
@@ -43,12 +45,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
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
|
||||
if (data.country) country.value = data.country
|
||||
else if (data.currency) country.value = data.currency
|
||||
if (data.backgroundType === 'color') backgroundType.value = 'default'
|
||||
else if (data.backgroundType) backgroundType.value = data.backgroundType
|
||||
if (data.backgroundImage) backgroundImage.value = data.backgroundImage
|
||||
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
|
||||
if (typeof data.ttsInstruct === 'string') ttsInstruct.value = data.ttsInstruct
|
||||
if (data.fontFamily) fontFamily.value = data.fontFamily
|
||||
if (typeof data.fontSize === 'number') fontSize.value = data.fontSize
|
||||
} catch { /* use defaults */ }
|
||||
}
|
||||
|
||||
@@ -58,9 +63,11 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
theme: theme.value, modelThinking: modelThinking.value,
|
||||
debounceMs: debounceMs.value, proThinking: proThinking.value,
|
||||
privacyMode: privacyMode.value, language: language.value,
|
||||
currency: currency.value, backgroundType: backgroundType.value,
|
||||
country: country.value, backgroundType: backgroundType.value,
|
||||
backgroundImage: backgroundImage.value, backgroundOpacity: backgroundOpacity.value,
|
||||
ttsInstruct: ttsInstruct.value,
|
||||
fontFamily: fontFamily.value,
|
||||
fontSize: fontSize.value,
|
||||
}))
|
||||
} catch { /* silently fail */ }
|
||||
}
|
||||
@@ -68,17 +75,17 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const resetSettings = () => {
|
||||
theme.value = 'system'; modelThinking.value = 'low'; debounceMs.value = 1000
|
||||
proThinking.value = 'medium'; privacyMode.value = false; language.value = 'auto'
|
||||
currency.value = 'auto'; backgroundType.value = 'default'; backgroundImage.value = ''
|
||||
backgroundOpacity.value = 0.2; ttsInstruct.value = ''; saveSettings()
|
||||
country.value = 'auto'; backgroundType.value = 'default'; backgroundImage.value = ''
|
||||
backgroundOpacity.value = 0.2; ttsInstruct.value = ''; fontFamily.value = 'system'; fontSize.value = 16; saveSettings()
|
||||
}
|
||||
|
||||
// Watch all reactive refs and auto-save on change
|
||||
watch([theme, modelThinking, debounceMs, proThinking, privacyMode, language, currency
|
||||
, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct], saveSettings)
|
||||
watch([theme, modelThinking, debounceMs, proThinking, privacyMode, language, country
|
||||
, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct, fontFamily, fontSize], saveSettings)
|
||||
|
||||
loadSettings() // Initialize from localStorage
|
||||
|
||||
return { theme, modelThinking, debounceMs, proThinking, privacyMode, language
|
||||
, currency, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct
|
||||
, country, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct, fontFamily, fontSize
|
||||
, detectedTimezone, uiLanguage, t, initialMarkdown, resetSettings }
|
||||
})
|
||||
|
||||
+3
-3
@@ -76,7 +76,7 @@ function buildCompletionBody(settings, prefix, suffix, languageId, extra = {}) {
|
||||
privacy_mode: settings.privacyMode,
|
||||
user_preferences: {
|
||||
language: settings.language,
|
||||
currency: settings.currency,
|
||||
country: settings.country,
|
||||
timezone: settings.detectedTimezone,
|
||||
},
|
||||
...extra,
|
||||
@@ -252,7 +252,7 @@ export async function fetchProSuggestionStream(payload, apiUrl = PRO_URL) {
|
||||
privacy_mode: settings.privacyMode,
|
||||
user_preferences: {
|
||||
language: settings.language,
|
||||
currency: settings.currency,
|
||||
country: settings.country,
|
||||
timezone: settings.detectedTimezone,
|
||||
},
|
||||
},
|
||||
@@ -304,7 +304,7 @@ export async function fetchWebSearchStream(payload, apiUrl = WEB_SEARCH_URL) {
|
||||
privacy_mode: settings.privacyMode,
|
||||
user_preferences: {
|
||||
language: settings.language,
|
||||
currency: settings.currency,
|
||||
country: settings.country,
|
||||
timezone: settings.detectedTimezone,
|
||||
},
|
||||
},
|
||||
|
||||
+26
-1
@@ -1,4 +1,4 @@
|
||||
import { CONVERT_URL, ASR_URL } from './config.js'
|
||||
import { CONVERT_URL, ASR_URL, OCR_URL } from './config.js'
|
||||
|
||||
import { parseSseEvent } from './sse.js'
|
||||
|
||||
@@ -86,6 +86,31 @@ export async function convertFileToMarkdown(file) {
|
||||
return data.markdown
|
||||
}
|
||||
|
||||
export async function convertVideoToText(file, language = 'auto') {
|
||||
const base64 = await readFileAsBase64(file)
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const res = await fetch(OCR_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
image: base64,
|
||||
filename: file.name || 'video.mp4',
|
||||
language,
|
||||
media_type: 'video',
|
||||
mime_type: file.type || 'video/mp4',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await consumeSseResult(res)
|
||||
if (!data || typeof data.text !== 'string' || !data.text.trim()) {
|
||||
throw new Error('视频解析结果为空')
|
||||
}
|
||||
return data.text
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode AudioBuffer as WAV (16kHz mono, 16-bit PCM) and return base64 string
|
||||
*/
|
||||
|
||||
+11
-1
@@ -18,6 +18,7 @@ function clipDocContext(content = '', limit = 0) {
|
||||
}
|
||||
|
||||
const AUDIO_EXT_RE = /\.(wav|mp3|m4a|ogg|flac)$/i
|
||||
const VIDEO_EXT_RE = /\.(mp4|webm|mov|avi|mkv|m4v|ogv)$/i
|
||||
|
||||
export function normalizeDocType(value = '') {
|
||||
const lower = String(value || '').trim().toLowerCase()
|
||||
@@ -61,6 +62,13 @@ export function isAudioFile(file) {
|
||||
return AUDIO_EXT_RE.test(name) || type.startsWith('audio/')
|
||||
}
|
||||
|
||||
export function isVideoFile(file) {
|
||||
if (!file) return false
|
||||
const name = String(file.name || '').toLowerCase()
|
||||
const type = String(file.type || '').toLowerCase()
|
||||
return VIDEO_EXT_RE.test(name) || type.startsWith('video/')
|
||||
}
|
||||
|
||||
export function isSupportedDocFile(file) {
|
||||
if (!file) return false
|
||||
const name = String(file.name || '').toLowerCase()
|
||||
@@ -75,6 +83,7 @@ export function isSupportedDocFile(file) {
|
||||
name.endsWith('.pptx') ||
|
||||
name.endsWith('.pdf') ||
|
||||
AUDIO_EXT_RE.test(name) ||
|
||||
VIDEO_EXT_RE.test(name) ||
|
||||
type === 'text/plain' ||
|
||||
type === 'application/json' ||
|
||||
type === 'text/yaml' ||
|
||||
@@ -83,7 +92,8 @@ export function isSupportedDocFile(file) {
|
||||
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
type === 'application/pdf' ||
|
||||
type.startsWith('audio/')
|
||||
type.startsWith('audio/') ||
|
||||
type.startsWith('video/')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+12
-12
@@ -33,7 +33,7 @@ export const translations = {
|
||||
privacyDesc: 'Prevent sending IP and preferences to the AI',
|
||||
language: 'Language',
|
||||
auto: 'Auto Detect',
|
||||
currency: 'Currency',
|
||||
country: 'Country',
|
||||
about: 'About Us',
|
||||
importMd: 'Import Markdown',
|
||||
exportMd: 'Export Markdown',
|
||||
@@ -52,7 +52,7 @@ export const translations = {
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
uploadBatchLimit: 'Maximum 10 files at once',
|
||||
uploadSizeLimit: 'File exceeds 50MB limit',
|
||||
uploadSizeLimit: 'File exceeds 100MB limit',
|
||||
uploading: 'Uploading files...',
|
||||
enableAI: 'Enable AI',
|
||||
disableAI: 'Disable AI',
|
||||
@@ -160,7 +160,7 @@ export const translations = {
|
||||
privacyDesc: '不向 AI 发送 IP 地址和偏好设置',
|
||||
language: '语言',
|
||||
auto: '自动检测',
|
||||
currency: '货币',
|
||||
country: '国家',
|
||||
about: '关于我们',
|
||||
importMd: '导入 Markdown',
|
||||
exportMd: '导出 Markdown',
|
||||
@@ -179,7 +179,7 @@ export const translations = {
|
||||
uploadFileError: '文件上传失败',
|
||||
uploadConvertError: '文件转换失败',
|
||||
uploadBatchLimit: '一次最多上传10个文件',
|
||||
uploadSizeLimit: '文件超过50MB限制',
|
||||
uploadSizeLimit: '文件超过100MB限制',
|
||||
uploading: '正在上传文件...',
|
||||
enableAI: '启用 AI',
|
||||
disableAI: '禁用 AI',
|
||||
@@ -287,7 +287,7 @@ export const translations = {
|
||||
privacyDesc: 'AIにIPアドレスと設定を送信しない',
|
||||
language: '言語',
|
||||
auto: '自動検出',
|
||||
currency: '通貨',
|
||||
country: '国',
|
||||
about: '私たちについて',
|
||||
template: 'テンプレート',
|
||||
presetTemplates: 'プリセットテンプレート',
|
||||
@@ -328,7 +328,7 @@ export const translations = {
|
||||
uploadFileError: 'アップロードに失敗しました。',
|
||||
uploadConvertError: '変換に失敗しました。',
|
||||
uploadBatchLimit: '一度にアップロードできるのは10ファイルまでです。',
|
||||
uploadSizeLimit: '50MBの制限を超えています。',
|
||||
uploadSizeLimit: '100MBの制限を超えています。',
|
||||
uploading: 'アップロード中...',
|
||||
enableAI: 'AIを有効化',
|
||||
disableAI: 'AIを無効化',
|
||||
@@ -408,7 +408,7 @@ export const translations = {
|
||||
privacyDesc: 'AI에 IP 주소 및 설정 전송 안 함',
|
||||
language: '언어',
|
||||
auto: '자동 감지',
|
||||
currency: '통화',
|
||||
country: '국가',
|
||||
about: '회사 소개',
|
||||
template: '템플릿',
|
||||
presetTemplates: '사전 정의된 템플릿',
|
||||
@@ -450,7 +450,7 @@ export const translations = {
|
||||
uploadFileError: '업로드 실패',
|
||||
uploadConvertError: '변환 실패',
|
||||
uploadBatchLimit: '한 번에 최대 10개 파일까지 업로드할 수 있습니다.',
|
||||
uploadSizeLimit: '50MB 제한을 초과합니다.',
|
||||
uploadSizeLimit: '100MB 제한을 초과합니다.',
|
||||
uploading: '업로드 중...',
|
||||
enableAI: 'AI 활성화',
|
||||
disableAI: 'AI 비활성화',
|
||||
@@ -530,7 +530,7 @@ export const translations = {
|
||||
privacyDesc: 'Sende keine IP und Einstellungen an KI',
|
||||
language: 'Sprache',
|
||||
auto: 'Automatisch',
|
||||
currency: 'Währung',
|
||||
country: 'Land',
|
||||
about: 'Über uns',
|
||||
template: 'Vorlage',
|
||||
presetTemplates: 'Vorgabe-Vorlagen',
|
||||
@@ -571,7 +571,7 @@ export const translations = {
|
||||
uploadFileError: 'Hochladen fehlgeschlagen',
|
||||
uploadConvertError: 'Konversion fehlgeschlagen',
|
||||
uploadBatchLimit: 'Maximal 10 Dateien auf einmal.',
|
||||
uploadSizeLimit: 'Datei überschreitet das Limit von 50MB.',
|
||||
uploadSizeLimit: 'Datei überschreitet das Limit von 100MB.',
|
||||
uploading: 'Wird hochgeladen...',
|
||||
enableAI: 'KI aktivieren',
|
||||
disableAI: 'KI deaktivieren',
|
||||
@@ -651,7 +651,7 @@ export const translations = {
|
||||
privacyDesc: 'Ne pas envoyer IP et préférences à l\'IA',
|
||||
language: 'Langue',
|
||||
auto: 'Détection auto',
|
||||
currency: 'Devise',
|
||||
country: 'Pays',
|
||||
about: 'À propos de nous',
|
||||
template: 'Modèle',
|
||||
presetTemplates: 'Modèles prédéfinis',
|
||||
@@ -692,7 +692,7 @@ export const translations = {
|
||||
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.',
|
||||
uploadSizeLimit: 'Fichier dépasse la limite de 100 Mo.',
|
||||
uploading: 'Téléchargement en cours...',
|
||||
enableAI: 'Activer IA',
|
||||
disableAI: 'Désactiver IA',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export const INPUT_BLOCK_NODE_TYPE = 'input_block'
|
||||
export const INPUT_TRIGGER_TEXT = '[INPUT]'
|
||||
|
||||
const INPUT_INSTRUCTION_PREFIX = '[INPUT]{'
|
||||
const INPUT_INSTRUCTION_SUFFIX = '}'
|
||||
|
||||
export const INPUT_DISPLAY_LABEL = '输入'
|
||||
|
||||
function normalizeMarkdownText(value = '') {
|
||||
return String(value || '').replace(/\r\n?/g, '\n')
|
||||
}
|
||||
|
||||
export function escapeInputBlockContent(value = '') {
|
||||
return normalizeMarkdownText(value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\n/g, '\\n')
|
||||
}
|
||||
|
||||
export function unescapeInputBlockContent(value = '') {
|
||||
const normalized = String(value || '').replace(/\r\n?/g, '\n')
|
||||
let result = ''
|
||||
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
const char = normalized[index]
|
||||
if (char !== '\\' || index === normalized.length - 1) {
|
||||
result += char
|
||||
} else {
|
||||
const next = normalized[index + 1]
|
||||
if (next === 'n') {
|
||||
result += '\n'
|
||||
} else {
|
||||
result += next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function serializeInputBlockSyntax(instruction = '', text = '') {
|
||||
const normalizedInstr = normalizeMarkdownText(instruction).trim()
|
||||
if (!normalizedInstr) return INPUT_TRIGGER_TEXT
|
||||
const instrPart = `${INPUT_INSTRUCTION_PREFIX}${escapeInputBlockContent(normalizedInstr)}${INPUT_INSTRUCTION_SUFFIX}`
|
||||
if (!text) return instrPart
|
||||
|
||||
const normalizedText = escapeInputBlockContent(text.trim())
|
||||
if (!normalizedText) return instrPart
|
||||
|
||||
// Format: [INPUT]{instructions}***用户输入的文本***
|
||||
return `${instrPart}***${normalizedText}***`
|
||||
}
|
||||
|
||||
export function parseInputBlockSyntax(value = '') {
|
||||
const text = normalizeMarkdownText(value).trim()
|
||||
if (!text) return null
|
||||
|
||||
// Match full pattern: [INPUT]{instr}***userText*** or [INPUT]***text***
|
||||
const fullMatch = text.match(/^(\[INPUT\](?:\s*\{([^\}]*)\})?)?\*\*([\s\S]*?)\*\*\*$/i)
|
||||
if (fullMatch) {
|
||||
return {
|
||||
instruction: fullMatch[2] || '',
|
||||
userInputText: fullMatch[3].trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Match just the trigger: [INPUT] or [INPUT]{instr}
|
||||
const triggerMatch = text.match(/^\[INPUT\](?:\s*\{([^\}]*)\})?$/i)
|
||||
if (triggerMatch) {
|
||||
return {
|
||||
instruction: triggerMatch[1] || '',
|
||||
userInputText: ''
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}INPUT_BLOCK_UTIL_EOF
|
||||
echo "Created inputBlock.js utility" && wc -l /Users/allenyuan/llm-in-text/src/utils/inputBlock.js
|
||||
@@ -0,0 +1,455 @@
|
||||
function normalizeText(value = '') {
|
||||
return String(value || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function removeNodes(root, selectors) {
|
||||
for (const selector of selectors) {
|
||||
root.querySelectorAll(selector).forEach((node) => node.remove())
|
||||
}
|
||||
}
|
||||
|
||||
function buildCardSection({ title, meta, body, className }) {
|
||||
const section = document.createElement('section')
|
||||
section.className = className
|
||||
|
||||
const heading = document.createElement('h2')
|
||||
heading.textContent = title
|
||||
section.appendChild(heading)
|
||||
|
||||
if (meta) {
|
||||
const metaNode = document.createElement('p')
|
||||
metaNode.className = `${className}__meta`
|
||||
metaNode.textContent = meta
|
||||
section.appendChild(metaNode)
|
||||
}
|
||||
|
||||
if (body) {
|
||||
section.appendChild(body)
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
/** @typedef {'doc-card' | 'web-search-card'} CardType */
|
||||
|
||||
function transformDocCards(root) {
|
||||
const cards = root.querySelectorAll('.doc-card')
|
||||
|
||||
if (cards.length === 0) {
|
||||
console.debug('[Export] No doc cards found in export root') // debug level - safe for production
|
||||
}
|
||||
|
||||
cards.forEach((card) => {
|
||||
// Validate required elements exist before transform
|
||||
const titleEl = card.querySelector('.doc-card__name')
|
||||
if (!titleEl) {
|
||||
console.warn('[Export] Doc card missing title element - skipping transform') // warn for visibility
|
||||
}
|
||||
|
||||
const body = card.querySelector('.doc-card__body')
|
||||
if (!body) {
|
||||
console.error('[Export] Doc card has no body content - this will cause export failure') // error for critical path
|
||||
}
|
||||
|
||||
const title = normalizeText(titleEl?.textContent) || '文档块'
|
||||
const badge = normalizeText(card.querySelector('.doc-card__badge')?.textContent)
|
||||
const time = normalizeText(card.querySelector('.doc-card__time')?.textContent)
|
||||
const meta = [badge, time].filter(Boolean).join(' · ')
|
||||
|
||||
// Clone body content and strip interactive elements
|
||||
const content = body ? body.cloneNode(true) : document.createElement('div')
|
||||
|
||||
// Remove interactive elements that don't belong in exports
|
||||
removeNodes(content, [
|
||||
'button', // Delete/expand buttons
|
||||
'svg', // Icons (trash, chevron)
|
||||
'.btn-tooltip', // Tooltip wrappers around buttons
|
||||
'.doc-card__actions', // Action bar container (holds delete button)
|
||||
'.doc-card__status-label' // Status label ("处理中"/"已完成") - keep for context
|
||||
])
|
||||
|
||||
card.replaceWith(buildCardSection({
|
||||
title,
|
||||
meta,
|
||||
body: content,
|
||||
className: 'export-doc-block',
|
||||
}))
|
||||
|
||||
// Log transform result for debugging (structured logging)
|
||||
console.debug('[Export] Transformed doc card:', { title, hasBody: Boolean(body), meta })
|
||||
})
|
||||
|
||||
return cards.length // Return count for verification downstream
|
||||
}
|
||||
|
||||
function transformWebSearchCards(root) {
|
||||
const cards = root.querySelectorAll('.web-search-card')
|
||||
|
||||
if (cards.length === 0) {
|
||||
console.debug('[Export] No web search cards found in export root') // debug level - safe for production
|
||||
}
|
||||
|
||||
cards.forEach((card) => {
|
||||
// Validate required elements exist before transform
|
||||
const titleEl = card.querySelector('.web-search-card__title')
|
||||
if (!titleEl) {
|
||||
console.warn('[Export] Web search card missing title element') // warn for visibility
|
||||
}
|
||||
|
||||
const body = card.querySelector('.web-search-card__body')
|
||||
if (!body) {
|
||||
console.error('[Export] Web search card has no body content') // error for critical path
|
||||
}
|
||||
|
||||
const title = normalizeText(titleEl?.textContent) || '联网搜索结果'
|
||||
const subtitle = normalizeText(card.querySelector('.web-search-card__subtitle')?.textContent)
|
||||
|
||||
// Clone and strip interactive elements
|
||||
const content = body ? body.cloneNode(true) : document.createElement('div')
|
||||
|
||||
// Remove interactive elements
|
||||
removeNodes(content, [
|
||||
'button', // Delete/collapse buttons
|
||||
'svg', // Icons (spinner, chevron)
|
||||
'.btn-tooltip', // Tooltip wrappers
|
||||
'.web-search-card__actions', // Action bar (delete button)
|
||||
'.web-search-card__progress' // Progress indicator - keep for status context
|
||||
])
|
||||
|
||||
card.replaceWith(buildCardSection({
|
||||
title,
|
||||
meta: subtitle,
|
||||
body: content,
|
||||
className: 'export-websearch-block',
|
||||
}))
|
||||
|
||||
// Log transform result for debugging (structured logging)
|
||||
console.debug('[Export] Transformed web search card:', { title, hasBody: Boolean(body), meta })
|
||||
})
|
||||
|
||||
return cards.length // Return count for verification downstream
|
||||
}
|
||||
|
||||
function inlineTextRuns(node, docx) {
|
||||
const { TextRun, ExternalHyperlink } = docx
|
||||
const runs = []
|
||||
|
||||
const walk = (current, marks = {}) => {
|
||||
if (current.nodeType === Node.TEXT_NODE) {
|
||||
const text = current.textContent || ''
|
||||
if (!text.trim() && !text.includes('\n')) return
|
||||
runs.push(new TextRun({
|
||||
text: text.replace(/\s+/g, ' '),
|
||||
bold: Boolean(marks.bold),
|
||||
italics: Boolean(marks.italics),
|
||||
underline: marks.underline ? {} : undefined,
|
||||
font: marks.code ? 'Menlo' : undefined,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (!(current instanceof HTMLElement)) return
|
||||
|
||||
if (current.tagName === 'BR') {
|
||||
runs.push(new TextRun({ break: 1 }))
|
||||
return
|
||||
}
|
||||
|
||||
if (current.tagName === 'A') {
|
||||
const href = current.getAttribute('href') || ''
|
||||
const childRuns = []
|
||||
const startCount = runs.length
|
||||
toArray(Array.from(current.childNodes)).forEach((child) => walk(child, { ...marks, underline: true }))
|
||||
childRuns.push(...runs.splice(startCount))
|
||||
if (href && childRuns.length > 0) {
|
||||
runs.push(new ExternalHyperlink({ link: href, children: childRuns }))
|
||||
} else {
|
||||
runs.push(...childRuns)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextMarks = {
|
||||
...marks,
|
||||
bold: marks.bold || current.tagName === 'STRONG' || current.tagName === 'B',
|
||||
italics: marks.italics || current.tagName === 'EM' || current.tagName === 'I',
|
||||
underline: marks.underline || current.tagName === 'U',
|
||||
code: marks.code || current.tagName === 'CODE',
|
||||
}
|
||||
|
||||
toArray(Array.from(current.childNodes)).forEach((child) => walk(child, nextMarks))
|
||||
}
|
||||
|
||||
walk(node)
|
||||
|
||||
if (runs.length === 0) {
|
||||
const text = normalizeText(node.textContent)
|
||||
if (text) runs.push(new TextRun(text))
|
||||
}
|
||||
|
||||
return runs
|
||||
}
|
||||
|
||||
async function domToDocxSections(root) {
|
||||
const docx = await import('docx')
|
||||
const {
|
||||
Paragraph,
|
||||
HeadingLevel,
|
||||
BorderStyle,
|
||||
} = docx
|
||||
|
||||
const paragraphs = []
|
||||
|
||||
const pushParagraph = (options) => {
|
||||
paragraphs.push(new Paragraph(options))
|
||||
}
|
||||
|
||||
const walkBlock = (node, depth = 0) => {
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
|
||||
const tag = node.tagName
|
||||
if (['SCRIPT', 'STYLE'].includes(tag)) return
|
||||
|
||||
if (tag === 'H1' || tag === 'H2' || tag === 'H3' || tag === 'H4') {
|
||||
const levelMap = {
|
||||
H1: HeadingLevel.HEADING_1,
|
||||
H2: HeadingLevel.HEADING_2,
|
||||
H3: HeadingLevel.HEADING_3,
|
||||
H4: HeadingLevel.HEADING_4,
|
||||
}
|
||||
pushParagraph({
|
||||
heading: levelMap[tag],
|
||||
children: inlineTextRuns(node, docx),
|
||||
spacing: { before: 240, after: 120 },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (tag === 'UL' || tag === 'OL') {
|
||||
toArray(Array.from(node.children)).forEach((child) => walkBlock(child, depth + 1))
|
||||
return
|
||||
}
|
||||
|
||||
if (tag === 'LI') {
|
||||
pushParagraph({
|
||||
children: inlineTextRuns(node, docx),
|
||||
bullet: { level: Math.max(0, depth - 1) },
|
||||
spacing: { after: 80 },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (tag === 'PRE') {
|
||||
const code = node.textContent || ''
|
||||
pushParagraph({
|
||||
children: [new docx.TextRun({ text: code, font: 'Menlo' })],
|
||||
spacing: { before: 120, after: 120 },
|
||||
border: {
|
||||
top: { style: BorderStyle.SINGLE, size: 1, color: 'D1D5DB' },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'D1D5DB' },
|
||||
left: { style: BorderStyle.SINGLE, size: 1, color: 'D1D5DB' },
|
||||
right: { style: BorderStyle.SINGLE, size: 1, color: 'D1D5DB' },
|
||||
},
|
||||
shading: { fill: 'F8FAFC' },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (tag === 'BLOCKQUOTE') {
|
||||
pushParagraph({
|
||||
children: inlineTextRuns(node, docx),
|
||||
indent: { left: 480 },
|
||||
spacing: { before: 120, after: 120 },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (tag === 'TABLE') {
|
||||
toArray(Array.from(node.querySelectorAll('tr'))).forEach((row) => {
|
||||
const cells = toArray(Array.from(row.children)).map((cell) => normalizeText(cell.textContent)).filter(Boolean)
|
||||
if (cells.length > 0) {
|
||||
pushParagraph({
|
||||
children: [new docx.TextRun(cells.join(' | '))],
|
||||
spacing: { after: 80 },
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (tag === 'IMG') {
|
||||
const alt = normalizeText(node.getAttribute('alt') || '')
|
||||
pushParagraph({
|
||||
children: [new docx.TextRun(alt ? `[图片] ${alt}` : '[图片]')],
|
||||
spacing: { before: 80, after: 80 },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const blockLike = ['P', 'DIV', 'SECTION', 'ARTICLE']
|
||||
const hasDirectBlockChildren = toArray(Array.from(node.children)).some((child) =>
|
||||
['P', 'DIV', 'SECTION', 'ARTICLE', 'UL', 'OL', 'LI', 'PRE', 'BLOCKQUOTE', 'TABLE', 'H1', 'H2', 'H3', 'H4'].includes(child.tagName)
|
||||
)
|
||||
|
||||
if (blockLike.includes(tag) && !hasDirectBlockChildren) {
|
||||
const text = normalizeText(node.textContent)
|
||||
if (text) {
|
||||
pushParagraph({
|
||||
children: inlineTextRuns(node, docx),
|
||||
spacing: { after: 120 },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
toArray(Array.from(node.children)).forEach((child) => walkBlock(child, depth))
|
||||
}
|
||||
|
||||
toArray(Array.from(root.children)).forEach((child) => walkBlock(child))
|
||||
|
||||
if (paragraphs.length === 0) {
|
||||
pushParagraph({ text: normalizeText(root.textContent) || '' })
|
||||
}
|
||||
|
||||
return { docx, paragraphs }
|
||||
}
|
||||
|
||||
export function createExpandedExportRoot(editorElement) {
|
||||
if (!(editorElement instanceof HTMLElement)) {
|
||||
throw new Error('导出失败:编辑器 DOM 不可用')
|
||||
}
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'rich-export-root'
|
||||
host.innerHTML = `
|
||||
<style>
|
||||
.rich-export-root {
|
||||
width: 800px;
|
||||
padding: 40px 48px;
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.rich-export-root * {
|
||||
box-sizing: border-box;
|
||||
color: inherit !important;
|
||||
}
|
||||
.rich-export-root img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.rich-export-root .export-doc-block,
|
||||
.rich-export-root .export-websearch-block {
|
||||
margin: 24px 0;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.rich-export-root .export-doc-block__meta,
|
||||
.rich-export-root .export-websearch-block__meta {
|
||||
margin: 6px 0 14px;
|
||||
color: #475569 !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rich-export-root h1,
|
||||
.rich-export-root h2,
|
||||
.rich-export-root h3,
|
||||
.rich-export-root h4 {
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
.rich-export-root pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rich-export-root table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.rich-export-root th,
|
||||
.rich-export-root td {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 8px 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.rich-export-root .btn-tooltip,
|
||||
.rich-export-root button,
|
||||
.rich-export-root svg {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
||||
const cloned = editorElement.cloneNode(true)
|
||||
if (!(cloned instanceof HTMLElement)) {
|
||||
throw new Error('导出失败:编辑器内容不可用')
|
||||
}
|
||||
|
||||
removeNodes(cloned, [
|
||||
'.history-buttons',
|
||||
'.action-buttons',
|
||||
'.top-actions-fixed',
|
||||
'.ProseMirror-gapcursor',
|
||||
'.ProseMirror-widget',
|
||||
'.doc-card__actions',
|
||||
'.doc-card__status-label',
|
||||
'.web-search-card__actions',
|
||||
'.web-search-card__progress',
|
||||
'.btn-tooltip',
|
||||
])
|
||||
|
||||
cloned.querySelectorAll('.doc-card__body, .web-search-card__body').forEach((node) => {
|
||||
if (node instanceof HTMLElement) {
|
||||
node.style.display = 'block'
|
||||
}
|
||||
})
|
||||
|
||||
transformDocCards(cloned)
|
||||
transformWebSearchCards(cloned)
|
||||
|
||||
host.appendChild(cloned)
|
||||
return host
|
||||
}
|
||||
|
||||
export async function exportEditorToPdf(editorElement, filename) {
|
||||
const html2pdfModule = await import('html2pdf.js')
|
||||
const html2pdf = html2pdfModule.default || html2pdfModule
|
||||
const root = createExpandedExportRoot(editorElement)
|
||||
root.style.position = 'fixed'
|
||||
root.style.left = '-100000px'
|
||||
root.style.top = '0'
|
||||
document.body.appendChild(root)
|
||||
|
||||
try {
|
||||
await html2pdf()
|
||||
.set({
|
||||
margin: [10, 10, 10, 10],
|
||||
filename,
|
||||
html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff' },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
||||
pagebreak: { mode: ['css', 'legacy'] },
|
||||
})
|
||||
.from(root)
|
||||
.save()
|
||||
} finally {
|
||||
root.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportEditorToDocxBlob(editorElement) {
|
||||
const root = createExpandedExportRoot(editorElement)
|
||||
const { docx, paragraphs } = await domToDocxSections(root)
|
||||
const { Document, Packer } = docx
|
||||
const doc = new Document({
|
||||
sections: [{ properties: {}, children: paragraphs }],
|
||||
})
|
||||
return Packer.toBlob(doc)
|
||||
}
|
||||
Reference in New Issue
Block a user